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 #551 — src/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~/.claudedir is--ro-binded. In api-key mode (maskCredentials) this is different: the config dir is mounted with a--dirmount 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 theapiKeyHelpersettings entry resolves inside the sandbox.~/.config/gh— bound RO (the gh token, needed togit 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.
Attended-mode egress coverage
Section titled “Attended-mode egress coverage”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
trustedprofile andstandardare 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.
R4 — prompt-injection posture
Section titled “R4 — prompt-injection posture”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 bytest/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 withgit showfrom 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 introducingREVIEW.mdis 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 atREVIEW_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 (assertIssueAuthorTrusted→UntrustedIssueAuthorError). It records anuntrusted_authorsignal 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 withSHEPHERD_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 aninjection_detectedsignal 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
PreToolUsetool guard denies two hazards at the call site on Claude spawns (scripts/tool-guard.mjs, wired bysrc/tool-guard-hook.ts,config.toolGuard/SHEPHERD_TOOL_GUARD): a baregit stashagainst the sharedrefs/stashstack, and a worktree-add or dependency install under a tmpfs root. It is a localcommandhook, not the fail-open HTTP ingest transport, precisely so the deny still holds for unattended sessions whose--clearenvmembrane 401s the restricted ingress. Its script is bound RO into the membrane (agentSupportPaths→agentSupportFlags,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.standardauto-spawns are refused outright (src/sandbox.tsautoHoldReason). -
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-writebounds 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’sCLAUDE.md.The posture is uniform across all four transient kinds; the reason is per-kind. For
revieweranddoc(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 toenableAllProjectMcpServersand none is emitted. Forwriter-roandwriter-only(disposable tmpdir) the Codex posture is deliberately stricter than its Claude counterpart: those Claude kinds are notmcpIsolatedbecausedontAskplus a closed--allowedToolsdeny everymcp__*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-rulesadditionally drops project execpolicy.rules, which an untrusted PR head could otherwise commit to widen what the reviewer runs unapproved.--skip-git-repo-checkis 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.mdstill loads (no flag drops it — symmetric with a Claude role still reading~/.claude/CLAUDE.md); there is no--allowedToolsanalog, 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-onlywould also block the verdict-file write the result contract needs) — this is the largest remaining Claude↔Codex asymmetry; and an operator on a custommodel_providerloses that wiring, unguarded because a guard would mean re-reading the ignored config.--ephemeralis 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--allowedToolsallowlist decides WHICH tools may run, not WHERE they may reach: a temp-cwd kind (writer-ro/writer-only) under--permission-mode dontAskcan otherwise read nothing but the files Shepherd itself wrote into its disposable cwd. The opt-inaddDirsoption 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 bareWrite— callers pass only a directory they accept as writable-in-the-worst-case. Codex spawns ignore it (--sandbox workspace-writerestricts writes and network, not reads). Today the only caller is the New Task shaping round (src/task-shape.ts, awriter-rospawn over the operator’s own rough prompt), and the repo path it passes is containment-checked againstconfig.repoRootby thePOST /api/shaperoute 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 ingit statusonly after the fact) or write a gitignored pathgit statusnever shows at all, anddisableAllHooks: truerules out the tool-guard hook. What bounds it is the allowlist — noBash, noEdit, 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
autonomousis downgraded tostandard(src/service.tsresearchSafeProfileOverride, 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 —standardrefuses 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 ontrusted/standardwith the network open, and cangh 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.
See also
Section titled “See also”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.