Implementing an agent runtime
Everything needed to add or change a runtime.Runtime backend: the security controls a runtime must wire, the interfaces it implements, the sandbox hook contract its adapter must satisfy, and the on-disk layout it writes.
Using a runtime — picking one, choosing models, troubleshooting a run — is runtimes.md. This page is the implementer's half.
When adding a runtime: register it in runtime.Resolve(), fill in every column of the security matrix below (including the cells that are "not wired" — say so explicitly), and add its row to the config-key table in runtimes.md.
Security feature matrix
The sandbox is the containment boundary; everything a runtime does with hooks and tool restrictions is steering inside it (ADR 0027). Read the matrix with that picture in mind:
| Feature | Where it runs | Claude Code | OpenCode (stub) | Pi | Notes for future runtimes |
|---|---|---|---|---|---|
| Host-side context injection scan (unicode, SSRF patterns on repo context files) | Host + sandbox scan context | ✓ | N/A — stub | ✓ (runner-level, runtime-agnostic) | Harness security.host_scanners; heuristic scanners only — DeBERTa ML model removed from sandbox in #6522 (its only consumer is the host-side scan input, not scan context) |
| Host-side runtime content scan (agent def, SKILL.md, plugin JSON before upload) | Host (scanRuntimeContent) | ✓ | N/A — stub | ✓ (runner-level, runtime-agnostic) | Uses security.InputPipeline(); not part of Runtime interface — runner responsibility |
| Tirith (Bash command scanning) | Sandbox PreToolUse hook | ✓ (loaded via --settings, #6358) | N/A — stub | ✓ via fullsend-hooks.js (pi tool_call → HookPlan PreToolUse scripts) | tirith_check.py; harness security.sandbox_hooks.tirith; fails open on missing binary/timeout unless TIRITH_REQUIRED=1 |
| SSRF pre-tool | Sandbox PreToolUse hook | ✓ (hooks-loaded.feature runs under the dummy runtime, which installs no hooks — it guards the sandbox egress boundary; the hook itself is unit-tested) | N/A — stub | ✓ via fullsend-hooks.js (pi tool_call → HookPlan PreToolUse scripts) | ssrf_pretool.py; default on |
| Canary token detection | Sandbox Pre/PostToolUse hooks | pre ✓; post-tool via posttool_chain.py on successful tool calls (tool_response / updatedToolOutput, #6357); failed calls: the same driver on PostToolUseFailure (detect + halt; the error text cannot be rewritten) | N/A — stub | ✓ pre via fullsend-hooks.js tool_call; post via tool_result (sequential chain, block withholds the result) | canary_pretool.py / canary_posttool.py; both inert unless FULLSEND_CANARY_TOKEN is set. Post-tool canary is an in-process chain stage so it cannot race sanitizer rewrites. Claude Code decision:block does not hide PostToolUse output, so the chain also redacts the token in updatedToolOutput. |
| Secret redaction | Sandbox PostToolUse hook | ✓ via posttool_chain.py on successful tool calls (#6357); on failed calls the same driver detects, logs to findings.jsonl and warns the agent via additionalContext — Claude Code does not let a hook rewrite a failed call's output | N/A — stub | ✓ via fullsend-hooks.js tool_result → the same posttool_chain.py (sent tool_response + tool_result; updatedToolOutput applied to the result the model sees) | secret_redact_posttool.py |
| Unicode normalization | Sandbox PostToolUse hook | ✓ via posttool_chain.py on successful tool calls (#6357); on failed calls the same driver detects, logs to findings.jsonl and warns the agent via additionalContext — Claude Code does not let a hook rewrite a failed call's output | N/A — stub | ✓ via fullsend-hooks.js tool_result → the same posttool_chain.py (sent tool_response + tool_result; updatedToolOutput applied to the result the model sees) | unicode_posttool.py |
| Context suppression | Sandbox PostToolUse hook | ✓ via posttool_chain.py on successful tool calls (#6357); on failed calls the same driver detects, logs to findings.jsonl and warns the agent via additionalContext — Claude Code does not let a hook rewrite a failed call's output | N/A — stub | ✓ via fullsend-hooks.js tool_result → the same posttool_chain.py (sent tool_response + tool_result; updatedToolOutput applied to the result the model sees) | context_suppress_posttool.py |
| Tool allowlist | Sandbox PreToolUse hook | opt-in; ✓ when enabled | N/A — stub | ✓ tool_allowlist_pretool.py via tool_call (names translated to Claude vocabulary first, #608) plus pi's native --tools from the agent tools: and the Bash(a,b) first-token allowlist enforced in the extension | tool_allowlist_pretool.py; requires FULLSEND_TOOL_ALLOWLIST (fail-closed when unset) |
| Prompt injection (DeBERTa) | Host fullsend scan input only | ✓ in the runner image (built CGO_ENABLED=1 -tags ORT with libtokenizers.a + ONNX Runtime >= 1.28); ✗ in the release tarballs, which stay CGO_ENABLED=0 and untagged (#6522) | N/A — stub | Same as Claude Code — scan input is host-side and runtime-agnostic, so this row is not a runtime distinction | Shipped enabled only in ghcr.io/fullsend-ai/fullsend-runner; the release tarball the composite action downloads has it compiled out, so CI runs never reach it. Note this is not an active control on the fullsend run path either way: RunMLScan is called only from fullsend scan input, which nothing in this repo or fullsend-ai/agents invokes. See #6506 (decision), #6522 (build constraints) |
| Sandbox tool hooks wiring | SandboxHooksBootstrap type assert in Bootstrap | ✓ scripts at claude-config/hooks/, wiring at claude-config/hooks.json via --settings (#6358) | ✗ — Bootstrap is a stub; must wire security.HookPlan via OpenCode plugin hooks | ✓ Bootstrap installs security.HookFiles under /sandbox/pi-config/hooks/, writes the HookPlan into fullsend-manifest.json and loads the embedded fullsend-hooks.js extension with -e under --no-extensions (per pi v0.84.2 docs/extensions.md); a script that cannot be spawned blocks (fail closed); whether the adapter is loaded is decided from the runner's own security signal, never from the agent-writable manifest, Run refuses to start pi (exit -1) when security is enabled but the manifest carries no hook plan, and the run command fails closed (exit 97) if the adapter or manifest file is missing or the adapter's SHA-256 differs from the embedded copy (checked before .env is sourced, with command -p) — pi silently skips a missing -e path — while an adapter loaded with a manifest lacking a hook plan blocks every tool call) | Hook scripts and wiring plan are runtime-neutral (see Sandbox hook contract); a runtime that ignores SandboxHooksBootstrap installs no sandbox tool hooks — say so explicitly here |
| Transcript / debug artifacts | TranscriptHandler (+ optional DebugLogNamer) | ✓ (stream-json, claude-debug.log) | No-op — see #1935 | ✓ session JSONL under PI_CODING_AGENT_SESSION_DIR (ExtractTranscripts), pi-debug.log (DebugLogNamer; pi's stderr when --debug is set), ParseTranscriptFile judges the tee'd --mode json stream and session files | Format-specific; not shared across runtimes. Debug-log filename defaults to agent-debug.log unless the runtime implements DebugLogNamer |
Fail modes
Harness security.fail_mode controls whether critical findings block the run (closed, default) or warn and continue (open). This applies to host scans, sandbox scan context, and host-side runtime content scan alike.
Runtime interface contract
| Interface | Responsibility |
|---|---|
runtime.Runtime | Name, config dir, env exports, bootstrap, run loop, per-iteration artifact cleanup |
runtime.BootstrapInput | Portable agent name/path, skill dirs, and plugin dirs to upload |
runtime.SandboxHooksBootstrap | Optional BootstrapInput extension — runtime-neutral sandbox tool hook config (security.SandboxHookConfig); every runtime should honour it |
runtime.TranscriptHandler | Extract transcripts/debug logs; parse errors for CI annotations |
runtime.DebugLogNamer | Optional — names the per-iteration debug-log artifact (default agent-debug.log) |
runtime.ContextBridger | Optional — runtime auto-loads only CLAUDE.md, so the runner injects a CLAUDE.md→AGENTS.md pointer (Claude Code: yes; runtimes that read AGENTS.md natively: omit) |
A runtime whose Bootstrap does not type-assert SandboxHooksBootstrap will not install Tirith, SSRF, canary, or the other hook scripts. The primary security boundary is the OpenShell sandbox, its L7 egress policy, and credential placeholders (ADR 0017, ADR 0025); the hooks are defense-in-depth that every runtime should wire rather than silently drop (ADR 0090). Fill in the matrix column above either way.
Sandbox hook contract
Contract version: v2 — PostToolUse scripts consume Claude Code's tool_response (falling back to tool_result for adapters/tests) and replace output via hookSpecificOutput.updatedToolOutput. v1 (tool_result in/out only) was inert under Claude Code (#6357).
The hook scripts in internal/security/hooks/*.py are plain programs with no Claude Code dependency; Claude Code invokes them through settings.json. Any runtime can call them from its own tool-call interception point (OpenCode tool.execute.before/after, pi TypeScript extension API tool_call/tool_result with {block: true, reason} structured denial, Cursor hooks, …).
- Files:
security.HookFiles(cfg)returnsfilename → script bytesfor the enabled hooks;runtime.installHookScripts(sandbox, dir, cfg)createsdirin the sandbox and uploads them there (executable) — any directory works. Claude uses/sandbox/claude-config/hooks/(security.SandboxHooksDir), with the wiring at/sandbox/claude-config/hooks.json(security.SandboxHooksSettings) loaded via--settings. - Wiring:
security.HookPlan(cfg)returns orderedHookGroup{Phase, Tools, Scripts}entries.PhaseisPreToolUse,PostToolUseorPostToolUseFailure(the last carries Claude Code's failed-call payload —hook_event_name,tool_name,tool_input, a stringerror— and allows no output rewrite, so the chain halts there on a canary and otherwise only detects (logging credential-shaped and control content and returning anadditionalContextwarning); adapters whose post-tool event already fires for failed calls, like pi, map it onto nothing);Toolsare Claude Code tool names (Bash,Read,WebFetch,*= all) — runtimes with other names translate before matching (see #608). PostToolUse is a singleposttool_chain.pyscript on*that applies unicode → canary → suppress → redact in-process. Unicode normalization runs first because every later content decision is made on its output: an attacker who splits a canary or a secret with zero-width or fullwidth characters must not evade detection and then have the chain reassemble the clean value (Claude Code runs matching hooks in parallel and does not merge twoupdatedToolOutputrewrites). Individual sanitizer files andcanary_posttool.pyare shipped as libraries the driver imports; adapters should invoke the chain, not the stages.GenerateHooksConfigis rendered fromHookPlan, so the two cannot diverge. - Canonical tool-name vocabulary (#608):
security.CanonicalClaudeTools(internal/security/canonical_tools.go) lists the tool names Claude Code exposes (verified 2026-08-23 against the live tools reference, i.e. the latest release; the CHANGELOG records no tool changes since the 2.1.234 pinned in the sandbox image — re-check on every pin bump);security.LegacyClaudeToolslists names Claude Code no longer has but that agenttools:frontmatter and adapters still use (LS,MultiEdit,Task→Agent, …).FULLSEND_TOOL_ALLOWLISTandsecurity.HookGroup.Toolsare written in this vocabulary; it is a reference checked by tests, not validated at run time —TestHookPlan_ToolsAreCanonicalpins everyHookPlantool,TestPiToolNameMapsUseClaudeVocabularypins the pi adapter's maps to canonical or legacy names (pi'slsmaps toLS, which Claude Code no longer sends — an agent allowlisted in canonical-only vocabulary sees pi'slsas a plaintool_blocked) (piToolForClaude/claudeToolForPiininternal/runtime/pi_agent.go→fullsend-manifest.jsonhooks.toolNames→fullsend-hooks.jsclaudeToolName()), andTestToolAllowlistHook_VocabularyMatchesGokeeps the copy insidetool_allowlist_pretool.pyidentical to the Go set. Adapters must translate to this vocabulary before invoking any hook script. An un-translated name is still blocked (the allowlist is exact-match, fail-closed), buttool_allowlist_pretool.pydistinguishes a normalization gap from a forbidden tool when the blocked name equals an allowlisted entry case-insensitively: if the allowlisted entry is a Claude name the reason isALLOWLIST_HOOK_ERROR: tool name '<name>' is not canonical Claude vocabulary (expected '<entry>'); the runtime adapter must translate it(for a legacy entry:… is not the legacy Claude name the allowlist uses (expected 'LS') …) with atool_name_unnormalizedfinding (severityhigh, actionblock); if instead the tool name is the Claude one (e.g.Bashagainst an allowlist written asbash) the reason names theFULLSEND_TOOL_ALLOWLISTentry (… is not Claude vocabulary (expected canonical name 'Bash'); fix the allowlist) and logsallowlist_entry_unnormalized; if neither side is a Claude tool name the reason says so, blames neither, and logstool_name_case_collision. These three findings arehigh, notcritical, so an adapter gap does not tripcritical-keyed escalation the way a forbidden tool does. Names with no case-insensitive match keep thetool_blockedfinding (severitycritical); a non-stringtool_nameblocks with the JSON contract rather than a traceback. MCP tools (mcp__<server>__<tool>) are not canonical — they are matched verbatim and a case variant is treated as a different tool (tool_blocked). The diagnostic only sees case variants: a renaming gap such as pi reporting every edit asEditwhile an agent is allowlisted only forMultiEditsurfaces as a plaintool_blocked. No case-insensitive allow is performed. - Wire protocol (per script): JSON on stdin —
{"tool_name": ..., "tool_input": {...}}for PreToolUse. PostToolUse payloads include the tool output astool_response(Claude Code; string or structured object such as Bash{stdout, stderr, interrupted, isImage}) withtool_resultaccepted as a fallback. Exit0= allow. Blocking scripts (all PreToolUse scripts, standalonecanary_posttool.py, andposttool_chain.pywhen its canary stage fires) exit1and print{"decision":"block","reason":"..."}on stdout; the adapter must stop the tool call (or, post-tool, drop the result) and surface the reason. Sanitizing stages (suppress/unicode/redact) always exit0and, when they changed something, print{"hookSpecificOutput":{"hookEventName":"PostToolUse","updatedToolOutput": <same shape as the input value>}, "tool_result": <scan text>}. Empty stdout = unchanged.updatedToolOutputmust match the tool's output shape — a bare string is ignored for built-in Claude Code tools.scan_textflattens every string field (includingstderr), newline-joined so a needle cannot match across a field boundary (such a match would be unredactable, since the redactors rewrite each field independently);apply_textwrites a replacement into the first text slot and blanks the rest, or leaves unrecognized structured shapes unchanged. Unicode normalization skips identifier fields (hook_io.IDENTIFIER_KEYS: paths, URLs, commands, exact-match edit strings) — NFKC would hand Claude a path that does not exist on disk; secret redaction still walks them, since it only replaces matched patterns. - Sanitizer scope (what is rewritten, and what is not): the PostToolUse stages exist to remove controls-relevant content and nothing else, because an agent edits against what it reads — a rewritten
Readresult meansEdit.old_stringno longer matches the file, and aWriteof what it saw persists the rewrite. Secret redaction masks credential-shaped values only: the prefix patterns (ghp_…,sk-…,AKIA…, bearer headers, private-key blocks, database URLs) plus env/JSON shapes that need both a secret-bearing name (…_TOKEN,api_key,accessToken, notTOKEN_URL/KEY_ID/publicKey) and a value that is not an identifier, member path (request.headers.authorization), URL, path, placeholder or word phrase (test-secret,ghs_policy_token); a source-stylename = exprcounts only when the value is a quoted literal. A sweep of 900 fullsend files through the chain rewrites only test files holding token-shaped fakes. Context suppression condenses the output of exactly one verification command (go test,pytest,npm test,make test,pre-commit run,gitleaks detect,scan-secrets) with optional setup prefixes (cd,export,source), and only from positive evidence the tool printed (ok <pkg>,N passed,<hook>…Passed,no leaks) — silence is never condensed into "passed", because a hook whose interpreter is missing is silent too and Claude Code's Bash result carries no exit code (so linters andgo vet/go build, whose clean run prints nothing, are never condensed); the command must start with the tool (after wrappers that run it:VAR=…,sudo,nice,timeout <n>,env VAR=…,uvx,npx,uv run,mise exec --, stacked;python3.12 -m pytestcounts) — a command that merely mentions it, such asgrep -n scan-secrets hooks.py, keeps its output; pipelines (| tailcan cut theFAILline; a|inside quotes such as-run 'A|B'is not a pipeline),$(…), chains of two tools (pytest; go test, and deliberately alsogo test && go vet— one summary cannot speak for two), a trailingecho $?, and any output carrying a failure marker (FAIL,panic:,Traceback,3 failed) pass through untouched; comment lines and backslash continuations are tolerated. Unicode strips invisible, bidi, tag, NUL and ANSI/OSC characters and runs of variation selectors, but keeps compatibility characters (fullwidth, ligatures, CJK punctuation) and single emoji/CJK selectors — NFKC is applied to a detection copy (canary, secret patterns); a field is emitted normalized only when the normalized copy reveals an escape sequence or a secret the original hid. Every rewrite attacheshookSpecificOutput.additionalContextso the agent knows the output was changed and why, and every hook entry carriestimeout: 30(Claude Code's 600 s default fails open — so does the 30 s one, for PreToolUse blockers included; the scripts finish in milliseconds andtirith_check.pybounds its own scan at 5 s, so the budget is headroom, not a ceiling the scripts approach). - Fail modes: blocking scripts fail closed on malformed JSON or oversized input (> 10 × 1024 × 1024 characters, read from text-mode stdin) — they block. Empty/whitespace-only stdin is treated as "no tool call" and allowed by every script; a payload without
tool_nameblocks only in the allowlist hook.tirith_check.pyfails open when thetirithbinary is missing, times out or errors, unlessTIRITH_REQUIRED=1(whichappendHookEnvwrites when Tirith is enabled — adapters must make sure it reaches the script). Sanitizing scripts and eachposttool_chain.pysanitizer stage fail open — malformed input or a stage exception is passed through unchanged (exit 0; the unicode hook logs aninput_truncatedfinding), and a stage failure is recorded infindings.jsonlas<stage>_stage_error. Adapters must not treat a sanitizer's empty stdout as an error. The canary stage fails closed: a scan that raises is treated as a hit, a hit whose redaction cannot be verified clean withholds the output entirely rather than emitting it, andexit 1is unconditional. Becauseposttool_chain.pyis the only PostToolUse entry point Claude Code schedules, input the driver cannot read — malformed JSON, or more than the 10 MB limit — also blocks (exit 1,continue: false) wheneverFULLSEND_CANARY_TOKENis set, instead of skipping detection; with no canary token configured it stays fail-open. Detection and redaction share one case-insensitive matcher (hook_io.canary_pattern), so a token that is detected is always one that can be redacted. - Environment:
runtime.appendHookEnvwritesTIRITH_FAIL_ON/TIRITH_REQUIREDinto/sandbox/workspace/.env; the runtime must launch the scripts with that file sourced (Claude's run command does). Scripts also readFULLSEND_TRACE_ID,FULLSEND_TOOL_ALLOWLIST(allowlist hook, fail-closed when unset) andFULLSEND_CANARY_TOKEN(both canary hooks are no-ops when it is empty; supply it via harnessenv.sandbox/host_files), and write findings to/sandbox/workspace/.security/findings.jsonl. - Suppression reachability: under Claude Code a non-zero-exit command never reaches
PostToolUseat all, so the suppressors only ever see zero-exit output; a tool that exits 0 with nothing to say is the case that used to be summarized as "passed". Adapters whose post-tool event also fires for failures (pi'stool_result) do deliver failed calls to the same chain, which is why the positive-evidence rule matters on both. - Claude Code caveats (#6358, #6357): (1) Loading — fixed by #6358: the hook wiring is written to the runner-owned
/sandbox/claude-config/hooks.jsonand passed explicitly via--settings, so it loads regardless of the CLI's working directory (previously it sat unread in/sandbox/workspace/.claude/); thehooks-loaded.featurebehaviour scenario guards the "silently not loaded" regression class. Note Claude Code still auto-loads a target repo's own<repo>/.claude/settings.jsonhooks from<cwd>— a separate exposure to assess. (2) Payload (fixed in #6357, contract v2) — scripts readtool_response(fallbacktool_result) and replace output viahookSpecificOutput.updatedToolOutputwith the original shape preserved. Sanitizer order and canary detection shareposttool_chain.pyso two PostToolUse hooks cannot race.scan_textinspects every string field (includingstderr). (3) Failed tool calls — Claude Code firesPostToolUseonly when a tool succeeds; a failed call (non-zero-exit Bash included) firesPostToolUseFailure, which delivers the error text but supports no output rewrite.HookPlanwires the sameposttool_chain.pythere, where it runs canary detection (halt) plus detection-only secret and unicode passes that log tofindings.jsonland return anadditionalContextwarning —additionalContextis the only output the event accepts, so a credential or an ANSI/zero-width sequence in a failed command's output still reaches the transcript unmasked and the agent is told not to copy or obey it. Scanning covers every string in the payload rather than one named key (the documented field iserror; doc versions differ), halting viacontinue: false(the only decision control the event honours), also on a detection copy — NFKC-normalized with combining marks, format characters (zero-width, bidi, tag), line/paragraph separators, control characters and whole ANSI/OSC sequences removed, i.e. everything the unicode stage strips from a successful call, so detection sees through the same obfuscation on both paths; suppression, unicode normalization and redaction cannot apply to a failed call under Claude Code — pi sanitizes those too, because itstool_resultevent fires for failures.interruptedon a Bashtool_responsemarks a cancelled tool, not an exit code — theExit codeprefix check inlooks_failedtherefore serves the v1 adapter path only. (4) Blocking — Claude Code keys on the stdout JSON on any exit code (decision:"block"is deprecated for PreToolUse but still maps todeny) and treats a bare exit1as non-blocking (exit2is its own blocking code); a local control run confirmed the scripts' "exit 1 +{"decision":"block"}" convention does block once the settings are loaded. For PostToolUse,decision:"block"only appendsreasonnext to the tool result — Claude still sees the original output.canary_posttool.pytherefore also emitsupdatedToolOutputwith the token redacted to[CANARY_REDACTED], and sets the universalcontinue: falsefield — the documented control that actually halts the session — so a leak still terminates the run. Net: after #6358 and #6357, both PreToolUse and PostToolUse halves of the contract are effective under Claude Code.
Sandbox workspace layout
The sandbox has two key directories that map to Claude Code's config levels (plus a runner-owned config directory per additional runtime, e.g. pi-config/ for pi):
/sandbox/
├── pi-config/ ← PI_CODING_AGENT_DIR (pi runtime; written by PiRuntime.Bootstrap)
│ ├── APPEND_SYSTEM.md Agent definition body (appended to pi's default system prompt)
│ ├── settings.json defaultProjectTrust: never, quietStartup, retry/compaction on
│ ├── skills/<name>/SKILL.md Harness skills (pi's native skill discovery)
│ ├── hooks/*.py Security hook scripts (same files as claude-config/hooks/)
│ ├── fullsend-hooks.js Hook adapter extension (loaded with -e; --no-extensions otherwise)
│ ├── fullsend-manifest.json Agent tools/allowlist, HookPlan, pi version — read by Run and the extension
│ └── sessions/ PI_CODING_AGENT_SESSION_DIR (session JSONL → transcripts)
│
├── claude-config/ ← CLAUDE_CONFIG_DIR (personal level)
│ ├── agents/
│ │ └── <name>.md Agent definition (filename derived from the agent name)
│ ├── skills/
│ │ ├── code-review/SKILL.md Built-in skills (personal level — wins on collision)
│ │ ├── pr-review/SKILL.md
│ │ └── ...
│ ├── plugins/
│ │ └── ... Plugin state (simplified; see bootstrapPlugins())
│ ├── hooks/ Security hook scripts (PreToolUse, PostToolUse)
│ └── hooks.json Hook wiring (loaded via --settings in buildRunCommand)
│
└── workspace/ ← SandboxWorkspace
├── .env Environment variables (sourced before claude)
├── .env.d/ Additional env files (host_files expand)
│
└── <repo-name>/ ← Claude Code's working directory (cd target)
├── CLAUDE.md Project instructions (repo's own or injected bridge)
├── AGENTS.md Project rules (repo's own or org default injected)
├── .claude/skills/ Repo skills (project level — shadowed on collision)
│ └── custom-lint/SKILL.md
└── src/... Target repo source codeAgent rule layering
When fullsend run executes an agent, Claude Code loads instructions from multiple sources. These compose — they occupy different layers, not competing slots:
┌────────────────────────────────────────────────────────┐
│ Layer 1: Agent Definition (system prompt) │
│ Source: /sandbox/claude-config/agents/<name>.md │
│ Loaded via: --agent flag │
│ Controls: role, task, tools, disallowedTools, model, │
│ built-in skills list │
│ Authority: highest — repo cannot modify │
├────────────────────────────────────────────────────────┤
│ Layer 2: Project Instructions (advisory) │
│ Source: /sandbox/workspace/<repo>/CLAUDE.md │
│ /sandbox/workspace/<repo>/AGENTS.md │
│ Loaded via: Claude Code auto-loads from working dir │
│ Controls: conventions, architecture, domain context │
│ Authority: advisory — cannot override layer 1 │
├────────────────────────────────────────────────────────┤
│ Layer 3: Skills │
│ Personal: /sandbox/claude-config/skills/ (fullsend) │
│ Project: <repo>/.claude/skills/ (repo) │
│ Precedence: personal > project (name collision → │
│ fullsend wins, repo version shadowed) │
│ Repo skills extend the agent; use config-driven │
│ agent registration for org-level skill overrides │
└────────────────────────────────────────────────────────┘AGENTS.md injection logic
run.go step 8a (hasAgentsMD() / injectClaudeMDPointer()):
- If target repo has no AGENTS.md → inject org-level default from config repo, add to
.git/info/exclude - If the runtime implements
ContextBridger(Claude Code does), target repo has AGENTS.md but no CLAUDE.md → inject bridge CLAUDE.md pointing to AGENTS.md, add to.git/info/exclude - If target repo has both → use as-is
Context file security scanning
run.go steps 8c and 9b:
Repo context files (CLAUDE.md, AGENTS.md, SKILL.md) are scanned in two defense-in-depth passes before the agent starts:
- Host-side (Path A, step 8c):
scanRepoContextFiles()runs theInputPipeline(unicode normalizer, context injection scanner) on the host before files enter the sandbox. - Sandbox-side (Path B, step 9b):
buildScanContextCommand()runsfullsend scan contextinside the sandbox after all files are assembled.
Critical findings block the run in fail_mode: closed.
Dummy runtime operations
The dummy runtime executes a YAML script of operations inside the real sandbox (behaviour tests only). Besides write_fixture and fail, dispatch behaviour tests use:
| Op | Args | Purpose |
|---|---|---|
assert_env | VAR_NAME | Assert env var is set and non-empty in the sandbox |
assert_file | path | Assert file exists and is readable under the workspace |
assert_json | path,json_path | Assert JSON file exists and dot-path field is present and non-null (uses jq) |
pi runtime internals (#6464)
User-facing pi behaviour is in Pi. This section keeps the verification provenance: what was checked against pi's source, on which version, and what must be re-checked on a PI_VERSION or extension bump.
One iteration, end to end — the amber decision is what makes "hooks enabled" enforceable, since pi silently skips a missing -e extension:
- No permission system at all — pi's stated posture is "run in a container". The OpenShell sandbox + L7 egress policy + credential placeholders (ADR 0017/0025) are the boundary, with the fullsend extension adapter as defense-in-depth (same posture as accepted for OpenCode in #1260 / ADR 0090).
--mode jsonexits 0 on model error — only text mode mapsstopReason: error|abortedto exit 1.parsePiStreamis the intended detector (assistantstopReasononmessage_end.message/ lastagent_end.messagesentry) for the runner's exit-0-override (#2786/#5361).Runtees the stream tooutput.jsonl,ParseTranscriptFilereads it, andRunitself returns 1 on a stream-reported error, so the override and the runtime agree.- No
--max-turns/--timeout— runner's exec timeout covers it; pi'sbashtool has no default command timeout either (core/tools/bash.ts), so a runaway command is bounded only by the iteration timeout, as with Claude Code. - Runs unattended (parity with
claude -p --dangerously-skip-permissions, verified against pi v0.84.2 source and empirically on the pinned build) — pi has no tool-approval layer at all (nothing incore/tools/*orcore/bash-executor.tsprompts); in--printmode extensions get a no-op UI context, soctx.ui.confirm/select/input/editorresolve immediately (modes/print-mode.ts,core/extensions/runner.ts);--no-approvesets the project-trust override, so the trust-gated project resources —.pi/{settings.json,extensions,skills,prompts,themes,SYSTEM.md,APPEND_SYSTEM.md}and.agents/skills(core/trust-manager.ts);AGENTS.mditself is still read as context — are ignored without a dialog (cli/args.ts,main.ts), anddefaultProjectTrust: neverin the global settings covers the no-flag case (verified on the pinned build: a planted.pi/extensions/evil.jsin the repo does not load under--no-approveand does under--approve); first-run setup, theme selection, telemetry consent and the version check are interactive-only code paths (PI_TELEMETRY=0,PI_SKIP_VERSION_CHECK=1/PI_OFFLINE=1set anyway); a missing credential raisesNo API key foundand exits 1 — no/loginprompt (core/agent-session.ts,modes/print-mode.ts); retries are bounded (retry.maxRetries: 3, 2/4/8 s) and compaction is automatic. The one blocker found: print mode reads a non-TTY stdin to EOF before the first prompt, even with a positional message (main.tsreadPipedStdin), so an exec that keeps stdin open with no writer hangs pi —Runtherefore appends</dev/nullto thepiinvocation; an idle upstream pipe then exits immediately (verified: open pipe without the redirect → killed by timeout; with it → proceeds). - No built-in MCP — out of scope; fleet uses none.
- Claude-on-Vertex via an interim extension — pi's
google-vertexprovider is Gemini-only and the upstreamanthropic-vertexprovider is an open PR (earendil-works/pi#5262, still open as of 2026-08-22). The sandbox image vendorstwoGiants/pi-anthropic-vertexv0.1.13 (commitd3c9d10d, MIT; reviewed — a ~300-line entry point plus ~220 lines mirrored from pi'sstreamSimplehelpers; it registers provideranthropic-vertexand delegates streaming to pi's built-in Anthropic provider through anAnthropicVertexclient) under/usr/local/share/pi-extensions/anthropic-vertex, pinned by tag + tarball SHA256 (PI_ANTHROPIC_VERTEX_VERSION/_SHA256). It is root-owned and outsidePI_CODING_AGENT_DIR, so pi never auto-loads it; for theanthropic-vertexproviderRunpasses it with-e(runtime.piVertexExtensionPath; providers without a vendored extension get pi's built-ins only) and unsetsANTHROPIC_API_KEY/ANTHROPIC_AUTH_TOKEN/ANTHROPIC_BASE_URL/ANTHROPIC_VERTEX_BASE_URLafter sourcing.envand pinsGOOGLE_CLOUD_PROJECTtoANTHROPIC_VERTEX_PROJECT_IDwhen that is set, so pi targets the same project as Claude Code on Vertex. Project resolution order isGOOGLE_CLOUD_PROJECT,GCLOUD_PROJECT,ANTHROPIC_VERTEX_PROJECT_ID,GOOGLE_CLOUD_PROJECT_ID(the fleet env exports both the first and the third; the pin above keeps them equal so the extension's first-wins order cannot diverge from Claude Code); region isCLOUD_ML_REGION, thenGOOGLE_CLOUD_LOCATION, defaultus-east5; auth is Google'sgoogle-auth-libraryreadingGOOGLE_APPLICATION_CREDENTIALS— in CI that is the Workload Identity Federationexternal_accountconfig the runner delivers viahost_files(ADR 0025 tier 4), whosecredential_source.fileis the OIDC token at/sandbox/workspace/.gcp-oidc-tokenthat the runner refreshes every 4 minutes; the library exchanges it atsts.googleapis.comfor a short-lived access token (direct federated identity, no impersonation) — exactly the path Claude Code uses, under the same*.googleapis.comegress allowlist and**/nodebinary rule. The bundled Vertex client (@anthropic-ai/vertex-sdk0.14.4 over@anthropic-ai/sdk0.91.1) honoursANTHROPIC_VERTEX_BASE_URLas its endpoint and would send a strayANTHROPIC_API_KEYto Google asX-Api-Key;ANTHROPIC_AUTH_TOKENandANTHROPIC_BASE_URLare overridden by the Google bearer and the explicit Vertex base URL, but pi's built-inanthropicprovider readsANTHROPIC_AUTH_TOKENand the SDK would readANTHROPIC_BASE_URLfor any provider that leavesbaseURLunset, soRununsets all four for theanthropic-vertexprovider (matched case-insensitively, as pi resolves provider prefixes) and keeps them for a directanthropicprovider, which needs the key. Thetool_call/tool_resultevent shapes the hook adapter relies on (toolName,input,content,isError;{block, reason}and{content, isError}replies) are verified against pi v0.84.2src/extensions/types.ts/runner.ts; the lifecycle run is the live confirmation. Known risks, to re-check on everyPI_VERSIONor extension bump: v0.1.13 was synced against pi 0.81.1 (upstream sync issue twoGiants/pi-anthropic-vertex#24 is open) and its mirrored option mapping can drift; the extension pins@anthropic-ai/sdkviaoverridesand that must match the SDK version in pi'spackages/ai/package.json(both 0.91.1 today) because the Vertex client is cast to pi's Anthropic client type; and it copies pi's first-party Anthropiccompatflags (strict tools, eager input streaming, adaptive thinking) onto the Vertex models — the Run PR must smoke an adaptive and a non-adaptive model against Vertex and, if Vertex rejects any of these, override them inPI_CODING_AGENT_DIR/models.jsonrather than patching the extension. Replace with the upstream provider once #5262 ships in a pinned release. - Grok-on-Vertex via a fullsend-owned extension — pi's built-in
xaiprovider targets xAI's native API (api.x.ai, needsXAI_API_KEY) andgoogle-vertexis Gemini-only, so neither reaches Grok on Vertex, which speaks the OpenAI-completions protocol. The sandbox image vendorsfullsend-ai/pi-xai-vertex(MIT) under/usr/local/share/pi-extensions/xai-vertex, pinned by tag + tarball SHA256 (PI_XAI_VERTEX_VERSION/_SHA256, Renovate-tracked like the Anthropic one) and registering providerxai-vertexwith modelxai/grok-4.6. Unlike the Anthropic extension it mirrors no pi internals — it registersopenAICompletionsApi()and lets pi do streaming, tools and usage — so there is nosync/compat.jsondrift to re-check on aPI_VERSIONbump; confirm itspeerDependenciesfloor instead. Auth is ambient ADC throughgoogle-auth-libraryreadingGOOGLE_APPLICATION_CREDENTIALS, the same WIFexternal_accountpath as Claude-on-Vertex and under the same*.googleapis.comegress allowlist, so no new credential plumbing. The endpoint is fixed to the global location (/locations/global/endpoints/openapi) because Vertex serves this model only there — regional endpoints answerFAILED_PRECONDITION— soCLOUD_ML_REGION/GOOGLE_CLOUD_LOCATIONare deliberately ignored for this provider.Runpasses it with-e(runtime.piXaiVertexExtensionPath), unsetsXAI_API_KEYafter sourcing.envso pi's built-inxaiprovider cannot shadow it, and defaultsXAI_VERTEX_PROJECT_IDtoANTHROPIC_VERTEX_PROJECT_ID(thenGOOGLE_CLOUD_PROJECT) only when the runner has not set it, so the fleet's Vertex project is the default without becoming a ceiling. Each Vertex provider resolves its own project variable, so one pi process can serve Grok, Claude and Gemini from different GCP projects; overriding an explicit value would collapse that and leave no way to point Grok at a project where it is actually enabled in Model Garden (the call then fails 403PERMISSION_DENIED, and the extension does not warn because it did have a project -- just the wrong one). Model spec: pi sendsModel.idon the wire verbatim and Vertex wants the publisher-qualified name, so the id keeps its slash and the canonical spec is the three-segmentxai-vertex/xai/grok-4.6.translatePiModelnormalises the shortxai/...form and a bare id underFULLSEND_PI_PROVIDER=xai-vertexto that form, case-insensitively — matching the gate, which usesEqualFold— because a spec that escapes normalisation reaches pi's built-inxaiprovider withXAI_API_KEYstill set. SincevalidModelNameforbids/, a harness selects this provider with a baremodel:plusFULLSEND_PI_PROVIDER. - Binary present but unhooked — the pinned
piCLI and the vendored Vertex extension ship in every sandbox image (so Bootstrap/Run work targets a reviewed version), so an agent on another runtime can invokepi -e /usr/local/share/pi-extensions/anthropic-vertexad hoc from Bash with none of that runtime's tool hooks — and in a Claude-on-Vertex sandbox the ADC credentials and project id it needs are already in the environment, so that is a working nested agent, not an inert binary. This is the same class of exposure as any interpreter the agent can run (python,node,curlwith the same ADC token): the sandbox tool hooks are defense-in-depth and only see the top-level tool call (ADR 0090); the boundary remains the OpenShell sandbox, its L7 egress allowlist and the credential placeholders, which a nestedpicannot escape either. The image bakesPI_OFFLINE=1/PI_TELEMETRY=0and the runner-owned config paths as defaults; treat theN/A — stubmatrix cells as "not wired", not "cannot run". - Fast release cadence (~weekly minors; 0.84.0 changed
message_updatewire shape) — pin exact versions;parsePiStreamfixtures are hand-authored topackages/coding-agent/docs/json.md(andcore/agent-session.tsfor the session-level events) for the pinned version;internal/runtime/testdata/pi/regen.shre-recordsbasic_run.ndjsonfrom a live run. - Tool names are lowercase (
bash,read,write,edit) — the hook adapter translates to the contract's Claude-name vocabulary (#608). - Reads AGENTS.md natively — no CLAUDE.md bridge needed (does not implement
ContextBridger). - Hardening levers in use —
Runexecutespi --print --mode json --no-approve --no-extensions --no-prompt-templates --no-themes --session-dir /sandbox/pi-config/sessions [-e /usr/local/share/pi-extensions/anthropic-vertex | -e /usr/local/share/pi-extensions/xai-vertex] [-e /sandbox/pi-config/fullsend-hooks.js] [--tools …] --model <provider/id> --thinking <effort|high> '<RunParams.Prompt, default "Run the agent task">' </dev/null [2>>/sandbox/workspace/pi-debug.log];settings.jsonsetsdefaultProjectTrust: never(repo-owned.pi/never loaded);PI_OFFLINE=1/PI_TELEMETRY=0/PI_SKIP_VERSION_CHECK=1come fromEnvExports. Context files (AGENTS.md) and skills stay on — they are the harness's own inputs.PI_CODING_AGENT_DIR/extensions/is arbitrary TypeScript loaded at startup and the config dir is not a permission boundary, which is why only the explicit-epaths load (at most one vendored provider extension plus the hook adapter). - Agent definition translation — the Claude-style agent
.mdis parsed byBootstrap: body →APPEND_SYSTEM.md(pi's default prompt and tool guidance are kept;SYSTEM.mdwould replace them — a deliberate difference from Claude Code, whose--agentmakes the body the system prompt; the lifecycle run should confirm the fleet prompts tolerate pi's preamble, otherwise switch to--system-prompt), frontmattertools:→--tools(pi enforces this strictly, Claude Code ≥ 2.1.119 enforces it unreliably) + an advisory Bash allowlist,model:→ fallback for the harnessmodel:,description→ header line.metrics.json/InitEventcarry the provider-stripped model id (claude-opus-4-6), as for Claude Code; the provider isgen_ai.system's job. For a provider whose ids are publisher-qualified this keeps that segment (xai/grok-4.6), since it is the wire id. EverythingRunand the hook extension need is infullsend-manifest.jsonbecauseBootstrapandRunare separate calls with no shared process state. - Hook adapter contract —
fullsend-hooks.jssends the scripts{tool_name, tool_input, tool_result, tool_response}with Claude tool names (bash→Bash,read→Read,write→Write,edit→Edit,grep→Grep,find→Glob,ls→LS;pathmirrored tofile_path) and reads back either the v1tool_resultor the v2hookSpecificOutput.updatedToolOutput(#6357), so the same extension works before and after the PostToolUse chain lands. PreToolUse groups run inHookPlanorder and stop at the first block; a script that cannot be spawned blocks; PostToolUse blocks withhold the result and mark itisError. An unreadable manifest, or one without a hook plan, blocks every tool call; because pi silently skips a missing-epath,Runchecks — before sourcing the agent-writable.env, withcommand -p sha256sum/command -p cutso nothing in the shell environment can stand in for them — that the adapter exists and matches the embedded copy's SHA-256 and that the manifest exists, failing closed (exit 97) otherwise, refuses to start at all when security is enabled but the manifest carries no hook plan, and decides whether to load the adapter from the runner's security signal rather than the manifest. The manifest and the hook scripts themselves stay agent-writable between iterations — the same residue Claude Code has withclaude-config/hooks.jsonand its scripts (both are written once atBootstrap). Edit inputs keep pi'sedits[]shape, withpathmirrored tofile_pathand the firstoldText/newTextpair mirrored toold_string/new_string; no shipped script reads the latter. pi firestool_resultfor failed calls too, so — unlike Claude Code'sPostToolUse— errored tool output is sanitized as well. - Exit code —
Runreturns 1 when pi exited 0 but the stream's singleResultEventreports an error (model error, incomplete stream), so the runner's exit-0 override and this agree;ParseTranscriptFilegives the same verdict from the tee'doutput.jsonl. - Not yet exercised —
runtime: piis selectable, but no fleet lifecycle run on Vertex has been recorded yet: the Vertex model ids and the copiedcompatflags have not been exercised against Vertex (smoke an adaptive and a non-adaptive model first; override with--model/FULLSEND_MODELif an id is rejected); parser fixtures are hand-authored to the v0.84.2 wire docs (re-record withinternal/runtime/testdata/pi/regen.shonce a run exists);extension_errorevents are not mapped; the behaviour scenariofeatures/runtime/pi.feature(a real haiku run on Vertex of a minimal tool-using agent, assertingmetrics.jsonruntime: pi, atoolCallin the pi session transcript and token usage) is gated onBEHAVIOUR_CAPABILITIES=runtime-piuntilfullsend-sandbox:latestcarriesPI_VERSION, andfeatures/triage/triage.featureasserts the runtime selected from the repo config on every run. Pilot on a disposable org withtriage/prioritize(no sub-agent assumptions) beforecode/fix;review/retrorely on Claude sub-agent rosters and are not supported: pi v0.84.2 has no sub-agent tool oragents/*.mdconcept in core — only the bundled example extension (examples/extensions/subagent/, spawnspi -p --mode jsonchildren without our hook adapter, Vertex provider,--no-approveor session dir) and the SDK route (createAgentSession()per child; parent extensions do not fire for children) — so a fullsend-owned sub-agent extension with the full child flag set is a follow-up tracked on #6527 (runtime parity backlog); until thenBootstrapappends a runtime note telling the agent no sub-agent tool exists and to execute sub-agent definitions itself, in order. - Other clouds — pi ships native
amazon-bedrock(SDK default credential chain, incl.AWS_WEB_IDENTITY_TOKEN_FILE) andazure-openai-responses(api-keyonly, no Entra ID) providers; neither is wired intoRun's alias table, credential hygiene or the runner's OIDC refresh yet, and the egress profile allows only Anthropic + Google hosts. Follow-up tracked against #6464.
