90. Runtime-neutral sandbox tool hooks contract
Date: 2026-08-18
Status
Accepted
Context
fullsend run drives agents through a pluggable runtime.Runtime (runtimes.md), but the sandbox tool hooks — Tirith command scanning, SSRF and canary checks, secret redaction, unicode normalization, context suppression, tool allowlist — were only reachable through a Claude-specific ClaudeHooksBootstrap type assertion, and their wiring lived solely in the generated Claude Code settings.json. A second runtime (OpenCode, #1260) would therefore ship with no sandbox tool hooks, and every further runtime (Cursor CLI #6319, pi, …) would face the same choice: re-implement the hooks or drop them. Related runner code also branched on rt.Name() == "claude" and hardcoded Claude artifact paths.
The credential-isolation architecture (ADR 0017, ADR 0025) makes the OpenShell sandbox, its L7 egress policy, and credential placeholders the primary security boundary; the hooks are defense-in-depth. The recommendation on #1260 was that a runtime may lack them only if that absence is explicit. Longer-term hook layers (ACP proxy chains #587, tool proxies #5243) remain open.
Decision
The sandbox tool hooks are a runtime-neutral contract, not a Claude Code feature:
- The hook scripts (
internal/security/hooks/*.py) are the portable artifact.security.HookFilesreturns them andsecurity.HookPlanreturns their wiring — orderedHookGroup{Phase, Tools, Scripts}entries in PreToolUse/PostToolUse phases with Claude tool names as the canonical vocabulary. Claude'sGenerateClaudeSettingsis rendered fromHookPlanso the two cannot diverge. The stdin/stdout/exit-code wire protocol is documented in runtimes.md. - The bootstrap extension is
runtime.SandboxHooksBootstrap(carryingsecurity.SandboxHookConfig). Every runtime'sBootstrapSHOULD honour it by installing the scripts (installHookScripts, any directory) and wiringHookPlanthrough its own interception mechanism (Claude Code:settings.json; OpenCode:tool.execute.before/afterplugin; pi:tool_call/tool_resultextension). A runtime that cannot MUST record the absence in the security feature matrix indocs/runtimes.md.
Consequences
- New runtimes get Tirith/SSRF/canary/redaction parity by writing a thin adapter over the existing scripts instead of re-implementing scanners.
- The Claude runtime is unchanged in behaviour: same scripts, same
settings.json, sameclaude-debug.logand CLAUDE.md bridge. - Tool-name translation (Claude
Bashvs lowercasebash, #608) becomes an adapter responsibility; the plan keeps Claude names as the vocabulary. - The contract is versioned (v1 = the scripts' current stdin/stdout fields). Verifying it against Claude Code surfaced two pre-existing gaps the runtimes.md matrix now records instead of a blanket ✓: the runner's hook wiring was not loaded from where it was written (#6358 — since fixed via
--settings), and the PostToolUse payload differs (tool_response,hookSpecificOutput.updatedToolOutput, parallel execution — #6357). - Alongside this decision the runner's remaining Claude-specific branches were replaced by optional capability interfaces (
DebugLogNamer,ContextBridger) — an implementation detail recorded in architecture.md, not a separate architectural decision. - The security matrix and config-key matrix in
docs/runtimes.mdgain a column per runtime and become part of a runtime PR's definition of done. - ACP proxies (#587) or tool proxies (#5243) can later supersede per-runtime adapters without changing the scripts or the plan.
Done (#6357): PostToolUse contract v2 — scripts read
tool_response(fallbacktool_result), replace viahookSpecificOutput.updatedToolOutput, and enforce unicode → canary → suppress → redact inposttool_chain.py. See runtimes.md.
Done (#608): The canonical Claude tool-name vocabulary is recorded once in
security.CanonicalClaudeTools(withsecurity.LegacyClaudeToolsfor names agents and adapters still use), mirrored intotool_allowlist_pretool.pyand kept identical by a Go test;HookPlantools and the pi adapter's maps are tested against it. The allowlist hook stays exact-match and fail-closed — no case-insensitive allowing — but a blocked name that is a case variant of an allowlisted entry is reported as a normalization gap (tool_name_unnormalizedwhen the adapter did not translate,allowlist_entry_unnormalizedwhen the allowlist is the non-canonical side,tool_name_case_collisionwhen neither spelling is a Claude tool; allALLOWLIST_HOOK_ERROR, severityhigh) rather than as a forbidden tool (tool_blocked,critical). MCP names are matched verbatim. The pi adapter's maps are held to canonical-or-legacy names (ls→LS), a deliberate relaxation of "canonical only". See runtimes.md.
