Spec

Sandbox

Purpose

Every workflow phase from Workflow Engine that runs an agent does so by calling into this layer. The Sandbox is the security boundary: it isolates the agent from the host, applies a default-deny network egress policy, downscopes the GitHub App token to what the workflow’s profile allows, and forwards LLM provider keys so the agent can actually reason.

The Chat path does not go through this layer — it runs in-process. Everything else does.

Public contract

// src/engine/agent-executor.ts
export async function executeAgent(
  prompt: string,
  config: ExecutorConfig,
  opts?: {
    taskId?: string;
    onSessionId?: (sessionId: string) => void;
    githubAccess?: GitSandboxAccess;
  },
): Promise<ExecutionResult>;

ExecutorConfig (src/engine/profiles.ts:17–64) carries:

FieldMeaning
cwd?Agent’s working directory
model?Provider/model — e.g. anthropic/claude-sonnet-4-6
variant?Reasoning effort — `off
sandbox?Backend — gondolin (default) / docker / none
sessionsDir?Where the JSONL event log lands
unrestrictedEgress?Opt out of the strict allowlist
webSearch?Enable agentic-pi’s web tools for this phase
webSearchProvider?Force a specific provider (Tavily / Brave / Exa)
agentContextDir?Where agent-context/*.md is read from

ExecutionResult (profiles.ts:69–91) returns success, output, turns, error, durationMs, sessionId, costUsd, token counts, and stopReason.

Backends

Three modes; selected per-call at agent-executor.ts:55.

gondolin — default

Agentic-pi’s QEMU micro-VM. Invoked in-process via the agenticRun() call (agent-executor.ts:263–287). The agent’s working directory is the host worktree mounted at /workspace inside the VM. Network isolation is at the VM layer — agentic-pi’s HTTP interceptor 502s any outbound request whose host isn’t on allowedHttpHosts.

docker — container backend

Spawns a Docker container via DockerSandbox (src/sandbox/docker.ts). The container runs agentic-pi run --sandbox none internally — the isolation comes from the container plus the egress firewall, not from agentic-pi’s VM. Container name: lastlight-sandbox-{taskId}-{uuid}.

  • Worktree bind-mounted at /home/agent/workspace.
  • /data mounted from the shared data volume.
  • Network: lastlight_sandbox-egress (internal — no host route).
  • DNS: --dns 172.30.0.10 (strict) or --dns 172.30.0.11 (open).
  • Memory: --memory 2g --memory-swap 2g by default.
  • Timeout: 30 min default; runs longer than that are killed.

none — in-process

For local development. agentic-pi runs in the harness process with cwd set to the host worktree, no isolation at all. Set via LASTLIGHT_SANDBOX=none.

agentic-pi invocation

result = await agenticRun({
  model,
  prompt,
  thinking,
  profile,                  // GitHub access profile — see below
  sandbox: backend === "gondolin" ? "gondolin" : "none",
  sandboxEnv,               // env forwarded into the agent's bash
  cwd: agentCwd,
  noSession: true,
  allowedHttpHosts,         // egress allowlist or ["*"]
  webSearch: config.webSearch === true,
  webSearchProvider: config.webSearchProvider,
  onEvent: (record) => { shim.feed(record); /* ... */ },
  onWarn: (msg) => console.warn(`[agentic] ${msg}`),
});

The onEvent callback receives agentic-pi’s EmitterRecord events — session, message_end, tool_execution_end, usage_snapshot, fatal_error. The shim (src/engine/event-shim.ts) translates them into Claude-SDK-style JSONL envelopes — see State §JSONL.

Egress firewall

The same allowlist drives both backends. Defined in src/sandbox/egress-allowlist.ts:

GroupHosts (apex + all subdomains)
GITHUB_HOSTSgithub.com, githubusercontent.com
PROVIDER_HOSTSanthropic.com, openai.com, openrouter.ai
PACKAGE_REGISTRY_HOSTSnpmjs.org, yarnpkg.com, pypi.org, pythonhosted.org, crates.io, golang.org, rubygems.org, alpinelinux.org, debian.org

gondolin enforcement

allowedHttpHosts is passed verbatim to agenticRun(). The VM’s HTTP interceptor returns 502 for any off-list request. Unrestricted egress passes ["*"].

docker enforcement — SNI peek

Four firewall services on the sandbox-egress network (subnet 172.30.0.0/24):

coredns-strict       172.30.0.10   allowlist hosts → nginx-strict IP; everything else NXDOMAIN
coredns-open         172.30.0.11   any host → nginx-open IP; SSRF hard-denies NXDOMAIN
nginx-egress-strict  172.30.0.20   ssl_preread SNI; tunnel allowlist hosts to upstream
nginx-egress-open    172.30.0.21   tunnel any SNI (DNS already gated)

The sandbox is given a coredns IP as its DNS resolver and no proxy env. It dials real hostnames; the spoofed DNS routes them to nginx; nginx peeks the TLS ClientHello SNI and tunnels to the real upstream via the proxy-egress network. This works for every SDK regardless of whether it honours HTTP_PROXY — the OpenAI and Anthropic SDKs don’t, and that’s why the earlier tinyproxy approach failed.

Configs are generated by src/sandbox/egress-firewall-config.ts at harness boot and bind-mounted read-only into the firewall containers.

Strict vs open

unrestricted_egress: true on a phase opts into the open pair (coredns-open + nginx-egress-open). The phase can reach hosts not on the allowlist — useful for explore-style phases that need to read arbitrary docs sites or hit a web-search API.

SSRF floor

Even in open mode, the cloud-metadata literals are hard-blocked:

  • 169.254.169.254
  • metadata.google.internal

coredns-open returns NXDOMAIN for these regardless. This is the floor a misconfigured workflow cannot drop below.

Honest caveat

TLS is not terminated. A hostname like evil.example.com whose A record points at a private IP wouldn’t resolve at all in strict mode (coredns only knows allowlist hosts) — but in open mode it would resolve to the open-nginx IP, and nginx would tunnel to whatever it points at. Closing this requires real TLS termination (e.g. Envoy + dynamic_forward_proxy with post-resolve IP checks). We haven’t pulled it in. The nginx-egress-* containers are not attached to any network reachable from the harness process or the admin dashboard, so the blast radius is contained to the sandbox network.

Permissions and tokens

// src/engine/profiles.ts:93
export type GitAccessProfile = "read" | "issues-write" | "review-write" | "repo-write";

// :130–155
export const GITHUB_PERMISSION_PROFILES = {
  read:           { contents: "read",  issues: "read",  pull_requests: "read",  metadata: "read" },
  "issues-write": { contents: "read",  issues: "write", pull_requests: "read",  metadata: "read" },
  "review-write": { contents: "read",  issues: "write", pull_requests: "write", metadata: "read" },
  "repo-write":   { contents: "write", issues: "write", pull_requests: "write", metadata: "read" },
};

Per phase:

  1. refreshGitAuth() (git-auth.ts) mints a GitHub App installation token downscoped to the profile’s permissions. Optionally scoped to a specific repository allowlist.
  2. The token (not the PEM) is forwarded into the sandbox via GIT_TOKEN and GITHUB_TOKEN env vars.
  3. The PEM only reaches the sandbox if the profile sets allowMcpAppAuth: true — currently only repo-write does. The container entrypoint then copies /data/secrets/app.pem into the agent’s home directory.
  4. Low-trust sandboxes get GITHUB_APP_PRIVATE_KEY_PATH="" explicitly to short-circuit any inadvertent reads (agent-executor.ts:80–82).

The triage profile literally cannot push code, even if a prompt- injected attacker convinced the agent to try.

Agent-side tools

Built-in github tools

The standalone mcp-github-app MCP server has been removed in the agentic-pi migration. The agent now uses agentic-pi’s built-in github_* tools, gated by the profile option passed to agenticRun(). agentic-pi auto-injects GITHUB_TOKEN / GH_TOKEN when the profile is set.

Web search — opt-in per phase

Three providers, auto-detected (Tavily > Exa > Brave). Keys are forwarded into the sandbox only when the phase declares web_search: true:

// agent-executor.ts:120–124
if (config.webSearch === true) {
  if (process.env.TAVILY_API_KEY)        env.TAVILY_API_KEY        =
  if (process.env.BRAVE_SEARCH_API_KEY)  env.BRAVE_SEARCH_API_KEY  =
  if (process.env.EXA_API_KEY)           env.EXA_API_KEY           =
}

A phase that doesn’t opt in cannot reach the search providers even if the operator set the keys.

Other built-ins

agentic-pi’s standard kit: bash, read, edit, write, plus the gated web_search and github_* families.

LLM provider routing

Provider keys (ANTHROPIC_API_KEY, OPENAI_API_KEY, OPENROUTER_API_KEY) are forwarded unconditionally (agent-executor.ts:112–114). agentic-pi picks the provider from the model string:

  • anthropic/... → Anthropic Messages API
  • openai/... → OpenAI Chat Completions
  • openrouter/<vendor>/<model> → OpenRouter passthrough

Per-phase model and variant overrides resolve through config.models[phaseName] and config.variants[phaseName] — see Configuration §models.

Container entrypoint (docker)

deploy/sandbox-entrypoint.sh, executed as root before privilege drop:

  1. Fix workspace ownershipchown -R agent:agent "$WORKSPACE".
  2. Materialize app.pem if high-trust — copy /data/secrets/app.pem to $AGENT_HOME/.config/app.pem only when ALLOW_APP_PEM=1. Otherwise GITHUB_APP_PRIVATE_KEY_PATH="".
  3. Write AGENTS.mdcat /app/agent-context/*.md > "$WORKSPACE/AGENTS.md".
  4. Configure git identity + credentials — system-scoped, using GIT_TOKEN via git credential.helper store. No shell interpolation of the token value.
  5. Signal readinesstouch "$WORKSPACE/.ready". The harness waits up to 15 s for this file before sending the first command.
  6. Drop privilegesexec gosu agent "$@".

Lifecycle

  1. Pre-population — if prePopulateBranch is set, the harness clones the repo into the worktree before starting the sandbox. The agent enters a workspace already checked out to the right branch, saving a clone_repo MCP call. Pre-clone errors are token-scrubbed before logging (sandbox/index.ts:213–214).
  2. Spawndocker run -d or VM start. Container/VM mapped to the taskId in activeContainers.
  3. Rundocker exec -i -w <cwd> {container} sh -c "agentic-pi run ..." with streaming stdout. Stderr captured to a tail buffer for error reporting.
  4. Teardowndocker rm -f on completion or error.
  5. Boot-time cleanupcleanupOrphanedSandboxes() (sandbox/index.ts:12–26) kills any leftover lastlight-sandbox-* containers from prior crashes.

Invariants

  • One container, one phase. No sharing between phases or workflows. The container’s blast radius is one phase’s execution.
  • No host network for the sandbox. The sandbox-egress network is declared internal: true. The sandbox can reach the egress firewall and nothing else — not the harness HTTP server, not the admin dashboard, not the proxy-egress network directly.
  • Allowlist is a single source of truth. Both backends read the same constant. A change to allowed hosts is one file edit.
  • The PEM stays out unless explicitly allowed. allowMcpAppAuth must be true and ALLOW_APP_PEM=1 must be set on the container for the PEM to materialise. Default is no.
  • Provider keys are unconditional; web-search keys are gated. The asymmetry is deliberate. The agent always needs to reason; it only sometimes needs the public web.
  • Pre-population is best-effort. A pre-clone failure logs and proceeds; the agent will clone itself if needed.
  • TLS is not terminated. Hostname-based filtering only — see the caveat above.

Current implementation

PieceFile
executeAgent, agentic-pi callsrc/engine/agent-executor.ts
ExecutorConfig, GitAccessProfile, profilessrc/engine/profiles.ts
Token minting + downscopesrc/engine/git-auth.ts
Docker backendsrc/sandbox/docker.ts
Sandbox dispatch + orphan cleanupsrc/sandbox/index.ts
Egress allowlist (source)src/sandbox/egress-allowlist.ts
Firewall config generatorsrc/sandbox/egress-firewall-config.ts
Container entrypointdeploy/sandbox-entrypoint.sh
Docker compose (firewall topology)docker-compose.yml
Event shim (agent → JSONL)src/engine/event-shim.ts

Rebuild notes

  • Pick your isolation level deliberately. A re-implementation can choose container, VM, or unikernel — but the contract is the same: default-deny network, scoped token, isolated FS. Don’t drop any of those by accident.
  • Don’t rely on HTTP_PROXY env vars. Most SDKs ignore them. SNI peek + DNS sinkhole is what works generally; if you can do real TLS termination, do that — but only after exhausting the cheaper options.
  • The allowlist is data. Keep it in one place, generate firewall configs from it, validate at boot. A drift between the harness’s allowlist and the firewall’s allowlist is silent and ugly.
  • Profile permissions are the audit trail. A re-implementation should pick the smallest permission set that lets each workflow do its job. Over-broad profiles will be regretted the first time a prompt-injected attacker tries to escalate.
  • unrestricted_egress should be opt-in per phase, not per workflow. Phases that need broad web access (explore research) should declare it; phases that don’t (executor commits) inherit strict mode.
  • The PEM gate is not a knob; it’s a wall. A re-implementation that adds a “trust me, always materialize the PEM” option will be exploited.
  • Pre-population is an optimisation, not a contract. The agent’s prompt should assume the workspace might be empty; pre-population is a fast path, not the only path.