Skip to content

Security

Internal developer/operator reference. Exempt from i18n and the feature catalog — not app chrome.

Shepherd wraps each spawned claude agent in a bubblewrap (bwrap) filesystem/process membrane with three profiles: trusted (no sandbox, the default), standard (membrane, interactive-only) and autonomous (membrane + per-spawn network-egress firewall; required for auto=true drain/autopilot). The egress firewall (slirp4netns + nftables + dnsmasq, shipped in PR #601, closed #551src/egress.ts) confines outbound traffic to api.anthropic.com + statsig.anthropic.com + the GitHub hosts, and watches for DNS drops. Egress is keyed to the autonomous profile, not to attendedness (src/sandbox.ts egressApplies).

This note records two residuals the operator has accepted after the audit.

R3 — in-membrane token readability (accepted)

Section titled “R3 — in-membrane token readability (accepted)”

The membrane keeps two token surfaces readable to any in-membrane tool call (buildMembraneFlags, src/sandbox.ts):

  • ~/.claude/.credentials.json — bound RW so OAuth refresh writes back (--bind-try); the whole ~/.claude dir is --ro-binded. In api-key mode (maskCredentials) this is different: the config dir is mounted with a --dir mount point plus per-child RO binds that omit .credentials.json (maskedClaudeDirBinds), and there is no credential bind of any kind — the OAuth token is genuinely absent inside the membrane, not an empty overlay. The api-key helper script is instead bound RO at its own path so the apiKeyHelper settings entry resolves inside the sandbox.
  • ~/.config/gh — bound RO (the gh token, needed to git push / gh pr create).

--clearenv strips all inherited env (ANTHROPIC_API_KEY, CLAUDE_CODE_OAUTH_TOKEN, GH_TOKEN, SHEPHERD_TOKEN, …), re-setting only HOME/PATH/TERM + non-secret locale vars — so these two bound files are the only token surfaces left inside the membrane.

Why accepted. A single-uid bwrap membrane has no privilege boundary between claude and its own tool calls: any file claude reads to authenticate, an injected tool call can also read. So the tokens the session legitimately needs stay reachable by a hijacked agent.

Out of scope, not impossible. A narrowing is technically conceivable — e.g. a nested sub-membrane for the Bash tool that omits the credentials bind, so claude reads the OAuth token only at startup and tool calls can’t. It was not pursued: significant nesting complexity, and a full external broker (intercepting/spoofing claude’s auth to api.anthropic.com) would edge into the prohibited “third-party harness piloting the account” conduct (audit R6) and break the unmodified-CLI + subscription-OAuth-refresh stance.

Compensating controls. On the autonomous profile the egress allowlist (#601) bounds where a leaked token can go; --clearenv keeps env-resident secrets out of the membrane entirely.

Egress confinement is keyed to the autonomous profile, not to whether a human is watching (willEgressConfine, src/sandbox.ts; applied in src/service.ts): the wrap applies iff the autonomous profile resolves and the fs + egress backends are present, independent of ctx.auto. Consequences:

  • An attended session on the autonomous profile is egress-confined (with an egress-degraded banner if the backend is missing).
  • The default trusted profile and standard are filesystem-confined only, never network-confined.

To get attended network confinement, select the autonomous profile — per-repo in the repo’s Settings panel, or globally via SHEPHERD_SANDBOX_DEFAULT_PROFILE.

Launch probe — the membrane is proven, the launcher is not (#2111)

Section titled “Launch probe — the membrane is proven, the launcher is not (#2111)”

detectBackend (src/sandbox.ts) answers “can bwrap build a sandbox here” by running node --version && git --version through the real derived membrane. It never launches claude/codex, and must not: null means run unconfined, so folding a launcher fault into that verdict would strip the membrane from around untrusted plan text on the very hosts that can sandbox.

src/membrane-launch.ts is the second, orthogonal signal — “does the agent binary actually start inside the membrane”. Its failure is loud (a sandbox_membrane DIAGNOSE row) and blocking (resolveAuxPatch returns a SpawnRefusal with the membrane-launch sentinel, per binary), never a change to whether the membrane is applied. Fail-open by construction: only a non-zero exit counts as broken; a probe that throws or times out is uninspectable and spawns proceed. The launcher’s output is the whole diagnosis but carries absolute host paths, so it is logged and never placed in a diagnostics or UI payload. The plan gate persists a visible error gate carrying that sentinel instead of skipping silently; the PR critic carries the same code.

The row and the refusal share one cache, so they can never disagree; the DIAGNOSE read probes fresh, so a repaired toolchain un-blocks wrapped roles as soon as the row goes green.

Input-side defenses (prompt-injection-hardening pass). Before any of the execution controls below, Shepherd bounds the injection surface at ingestion (src/untrusted.ts, src/service.ts):

  • Untrusted-content fencing. External text an agent or helper LLM might read — issue title/body, issue comments, PR bodies + author-notes, captured terminal tails, the recap context, and the changed-view-file markup the recap prompt carries (ui-markup) — is wrapped in unforgeable ⟦UNTRUSTED:…⟧ markers (fenceUntrusted, with a per-fence random nonce that the content cannot predict or close early) so the model treats it as data, never instructions. The fence carries its label and nonce only; the instruction hierarchy has one home, UNTRUSTED_CONTENT_DIRECTIVE, which every prompt that fences states exactly once — session spawns get it as the standing <untrusted-content-boundary> block (composeSystemPromptBlocks), and each aux prompt builder emits it itself. That invariant is pinned by test/untrusted.test.ts: a builder that fences without the directive fails there.
  • Trusted-by-provenance exception: the repo review policy. The PR critic’s prompt carries one block unfenced, as genuine instruction: the repo’s REVIEW.md (else .shepherd/review.md), read with git show from the PR’s base commit, never from the checked-out PR head (defaultReadReviewPolicy, src/critic-core.ts). Fenced it would be contractually ignorable and therefore inert; unfenced text the PR could author would let a branch (notably a fork PR at the standalone critic) rewrite the rules it is judged by. Base provenance closes that — only already-merged policy is honored, at the cost that the PR introducing REVIEW.md is not reviewed under it. The built-in contract stays the floor: the block may add passes and narrow which areas/classes get attention, never suppress a verified defect or touch the verdict-output contract. Text is clamped at REVIEW_POLICY_MAX_BYTES (8 KB) with a visible marker, a bad SHA or git failure yields no block, and the delimiters are plain markers rather than a nonce fence because the content is trusted by construction.
  • Fail-closed author-trust gate. An autonomous (auto=true) spawn from an issue whose author is not a trusted repo association (OWNER / MEMBER / COLLABORATOR — anything else, including an unresolvable, absent, or Gitea-side association, fails closed) is refused before any worktree is created (assertIssueAuthorTrustedUntrustedIssueAuthorError). It records an untrusted_author signal and toasts the operator (repo:untrusted-author) — once per (repo, issue) per process, so a stuck issue’s drain retries don’t grow the signal store. Operator-initiated creates are unaffected — a human can still start such an issue manually if they trust it. On forges that structurally can’t supply a GitHub-style association (non-GitHub — Gitea/local), autonomous drain would otherwise be silently disabled; an operator can opt back in with SHEPHERD_TRUST_ISSUE_AUTHORS=1 (scoped to non-GitHub — a GitHub miss or untrusted author still refuses).
  • Advisory injection scan. Issue content is scanned against a conservative signature set (scanForInjection); a hit is advisory only — it records an injection_detected signal and toasts the operator to eyeball the session, but never blocks the spawn.

These are content-boundary defenses; the execution-confinement residuals below still stand.

  • A PreToolUse tool guard denies two hazards at the call site on Claude spawns (scripts/tool-guard.mjs, wired by src/tool-guard-hook.ts, config.toolGuard / SHEPHERD_TOOL_GUARD): a bare git stash against the shared refs/stash stack, and a worktree-add or dependency install under a tmpfs root. It is a local command hook, not the fail-open HTTP ingest transport, precisely so the deny still holds for unattended sessions whose --clearenv membrane 401s the restricted ingress. Its script is bound RO into the membrane (agentSupportPathsagentSupportFlags, src/sandbox.ts), because the decision to drop the equivalent prompt notices is taken host-side — a guard missing inside the sandbox would leave that session with neither.

  • Autonomous task agents run --dangerously-skip-permissions, but behind both the filesystem and the egress membrane. standard auto-spawns are refused outright (src/sandbox.ts autoHoldReason).

  • Unattended reviewers (PR critic, plan-gate, and the maintain loop’s tier-2 diagnosis agent — src/maintain.ts) run read-only, not skip-permissions: --safe-mode --disable-slash-commands --allowedTools Read Grep Glob Bash(git diff *) Bash(git log *) Bash(git show *) Bash(git status) Write --permission-mode dontAsk (src/transient-agent-argv.ts, buildTransientAgentArgv("reviewer", …)).

  • Codex roles are isolated from the operator’s user config. --sandbox workspace-write bounds what a Codex role may WRITE; it says nothing about what the role is WIRED TO. Every Codex role therefore also carries --ignore-user-config --ignore-rules --skip-git-repo-check -c project_doc_fallback_filenames=["CLAUDE.md"] (src/codex-role-argv.ts), which drops $CODEX_HOME/config.toml — MCP servers, connector apps, hooks, exec policy and the model/effort defaults — while re-asserting the one inheritance a Claude role also has, the repo’s CLAUDE.md.

    The posture is uniform across all four transient kinds; the reason is per-kind. For reviewer and doc (untrusted git worktree) dropping [mcp_servers.*] is the direct analog of the Claude preset’s --safe-mode, which likewise disables MCP loading; Codex has no interactive project-MCP approval gate, so nothing corresponds to enableAllProjectMcpServers and none is emitted. For writer-ro and writer-only (disposable tmpdir) the Codex posture is deliberately stricter than its Claude counterpart: those Claude kinds are not mcpIsolated because dontAsk plus a closed --allowedTools deny every mcp__* call at the call site, so a loaded server is inert — Codex has no tool allowlist, so dropping the config is the only thing keeping them away from the operator’s servers and connectors. --ignore-rules additionally drops project execpolicy .rules, which an untrusted PR head could otherwise commit to widen what the reviewer runs unapproved.

    --skip-git-repo-check is a precondition skip, not a control: Codex refuses to start outside a git repo or a [projects.*] trust entry, and those entries live in the config we just stopped loading. Without it every tmpdir role dies at spawn. Three residuals are accepted: $CODEX_HOME/AGENTS.md still loads (no flag drops it — symmetric with a Claude role still reading ~/.claude/CLAUDE.md); there is no --allowedTools analog, so a Codex role can still run arbitrary shell inside its sandbox and cannot be narrowed to the reviewer’s read-only tool set (--sandbox read-only would also block the verdict-file write the result contract needs) — this is the largest remaining Claude↔Codex asymmetry; and an operator on a custom model_provider loses that wiring, unguarded because a guard would mean re-reading the ignored config. --ephemeral is deliberately not used: it would suppress the rollout files role activity and token totals are read from (src/codex-activity.ts).

  • Transient helpers reach outside their scratch dir only via --add-dir. The --allowedTools allowlist decides WHICH tools may run, not WHERE they may reach: a temp-cwd kind (writer-ro / writer-only) under --permission-mode dontAsk can otherwise read nothing but the files Shepherd itself wrote into its disposable cwd. The opt-in addDirs option emits --add-dir (src/transient-agent-argv.ts) and is the only thing that grants a helper repo access. Claude Code has no read-only form of the flag, so an added directory is also exposed to the preset’s bare Write — callers pass only a directory they accept as writable-in-the-worst-case. Codex spawns ignore it (--sandbox workspace-write restricts writes and network, not reads). Today the only caller is the New Task shaping round (src/task-shape.ts, a writer-ro spawn over the operator’s own rough prompt), and the repo path it passes is containment-checked against config.repoRoot by the POST /api/shape route before the spawn. Because that path is the operator’s REAL checkout rather than a disposable worktree, the residual risk is not merely “a stray new file”: the agent can overwrite a tracked file (destroying uncommitted work, visible in git status only after the fact) or write a gitignored path git status never shows at all, and disableAllHooks: true rules out the tool-guard hook. What bounds it is the allowlist — no Bash, no Edit, no git, no network — so the ceiling is file writes inside one operator-chosen repo, with no exec, commit, push or exfiltration. The prompt’s “write only your result file” instruction is guidance to a cooperative model, not an enforced boundary. Tightening this means giving the helper a read-only view (detached worktree / RO bind, as the reviewer roles get), not a narrower allowlist.

  • Research is the deliberately egress-UNCONFINED surface. A research session that would resolve to autonomous is downgraded to standard (src/service.ts researchSafeProfileOverride, warns once), because research needs open web egress (search/fetch + sub-agents) that the autonomous firewall would block. The same downgrade applies to an epic-authoring session (input.epicAuthoring, #1507), which likewise needs open web/repo egress to shape a draft — though unlike research it creates no GitHub issues itself: the hard write-gate is that only the server-side approve route materializes the draft. It is operator-created (cannot be auto-drained — standard refuses auto-spawn) but autopilot-steerable, so it runs unattended in practice (RESEARCH_PROCEED_STEER, src/autopilot.ts, dispatched from the steer loop). It ingests untrusted web content on trusted/standard with the network open, and can gh pr create / open issues via the bound gh token — so a hijacked research agent has both readable tokens and open egress.

    Compensating factors: the downgrade is explicit and warns once; research delivers a report PR or GitHub issue only, never a code PR (RESEARCH_PROCEED_STEER, src/autopilot.ts). The residual is accepted.

  • src/egress.ts, src/sandbox.ts, src/service.ts, src/autopilot.ts, src/transient-agent-argv.ts, src/task-shape.ts, src/maintain.ts, src/untrusted.ts, src/tool-guard-hook.ts, scripts/tool-guard.mjs.