Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/ADRs/0090-runtime-neutral-sandbox-hooks-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,9 @@ feature:
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](https://github.com/fullsend-ai/fullsend/issues/6357)):**
> PostToolUse contract v2 — scripts read `tool_response` (fallback
> `tool_result`), replace via `hookSpecificOutput.updatedToolOutput`, and
> enforce suppress → unicode → redact → canary in `posttool_chain.py`. See
> [runtimes.md](../runtimes.md#sandbox-hook-contract).
18 changes: 9 additions & 9 deletions docs/runtimes.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,10 @@ When adding a runtime, fill in the security matrix below and register it in `run
| **Host-side runtime content scan** (agent def, SKILL.md, plugin JSON before upload) | Host (`scanRuntimeContent`) | ✓ | N/A — stub | Uses `security.InputPipeline()`; not part of `Runtime` interface — runner responsibility |
| **Tirith** (Bash command scanning) | Sandbox PreToolUse hook | ✓ (loaded via `--settings`, #6358) | N/A — stub | `tirith_check.py`; harness `security.sandbox_hooks.tirith`; fails open on missing binary/timeout unless `TIRITH_REQUIRED=1` |
| **SSRF pre-tool** | Sandbox PreToolUse hook | ✓ (e2e-guarded by `hooks-loaded.feature`) | N/A — stub | `ssrf_pretool.py`; default on |
| **Canary token detection** | Sandbox Pre/PostToolUse hooks | pre ✓; post-tool field mismatch (#6357) | N/A — stub | `canary_pretool.py` / `canary_posttool.py`; both inert unless `FULLSEND_CANARY_TOKEN` is set |
| **Secret redaction** | Sandbox PostToolUse hook | wired; not effective under Claude Code (#6357) | N/A — stub | `secret_redact_posttool.py` |
| **Unicode normalization** | Sandbox PostToolUse hook | wired; not effective under Claude Code (#6357) | N/A — stub | `unicode_posttool.py` |
| **Context suppression** | Sandbox PostToolUse hook | wired; not effective under Claude Code (#6357) | N/A — stub | `context_suppress_posttool.py` |
| **Canary token detection** | Sandbox Pre/PostToolUse hooks | pre ✓; post-tool via `posttool_chain.py` (`tool_response` / `updatedToolOutput`, #6357) | N/A — stub | `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` (#6357) | N/A — stub | `secret_redact_posttool.py` |
| **Unicode normalization** | Sandbox PostToolUse hook | ✓ via `posttool_chain.py` (#6357) | N/A — stub | `unicode_posttool.py` |
| **Context suppression** | Sandbox PostToolUse hook | ✓ via `posttool_chain.py` (#6357) | N/A — stub | `context_suppress_posttool.py` |
| **Tool allowlist** | Sandbox PreToolUse hook | opt-in; ✓ when enabled | N/A — stub | `tool_allowlist_pretool.py`; requires `FULLSEND_TOOL_ALLOWLIST` (fail-closed when unset) |
| **Prompt injection (DeBERTa)** | Host Path A + sandbox Path B | ✓ | N/A — stub | Same scanner stack as context files when enabled in harness |
| **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 | Hook scripts and wiring plan are runtime-neutral (see [Sandbox hook contract](#sandbox-hook-contract)); a runtime that ignores `SandboxHooksBootstrap` installs **no** sandbox tool hooks — say so explicitly here |
Expand All @@ -48,16 +48,16 @@ A runtime whose `Bootstrap` does not type-assert `SandboxHooksBootstrap` will **

### Sandbox hook contract

**Contract version: v1** — as implemented by the scripts today. Field names below are what the *scripts* consume; see the Claude Code caveat before assuming they match a given runtime's native hook payload. A corrected/extended field set will bump the version (tracked in #6357).
**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 `tool_call`/`tool_result`, Cursor hooks, …).

- **Files:** `security.HookFiles(cfg)` returns `filename → script bytes` for the enabled hooks; `runtime.installHookScripts(sandbox, dir, cfg)` creates `dir` in 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 ordered `HookGroup{Phase, Tools, Scripts}` entries. `Phase` is `PreToolUse` or `PostToolUse`; `Tools` are Claude Code tool names (`Bash`, `Read`, `WebFetch`, `*` = all) — runtimes with other names translate before matching (see #608). **Adapters must run the `Scripts` of one group sequentially in the listed order, feeding each script's modified result to the next** (the PostToolUse order suppress → unicode → redact is a security invariant). `GenerateHooksConfig` is rendered from `HookPlan`, so the two cannot diverge.
- **Wire protocol (per script):** JSON on stdin — `{"tool_name": ..., "tool_input": {...}}` for PreToolUse, plus `"tool_result"` for PostToolUse. Exit `0` = allow. *Blocking* scripts (all PreToolUse scripts, and `canary_posttool.py`) exit `1` and print `{"decision":"block","reason":"..."}` on stdout; the adapter must stop the tool call (or, post-tool, drop the result) and surface the reason. *Sanitizing* PostToolUse scripts (`context_suppress`, `unicode`, `secret_redact`) always exit `0` and print `{"tool_result": <modified>}` when they changed something; empty stdout = unchanged.
- **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_name` blocks only in the allowlist hook. `tirith_check.py` fails **open** when the `tirith` binary is missing, times out or errors, unless `TIRITH_REQUIRED=1` (which `appendHookEnv` writes when Tirith is enabled — adapters must make sure it reaches the script). Sanitizing scripts fail **open** — malformed or oversized input is passed through unchanged (exit 0, empty stdout; the unicode hook logs an `input_truncated` finding). Adapters must not treat a sanitizer's empty stdout as an error.
- **Wiring:** `security.HookPlan(cfg)` returns ordered `HookGroup{Phase, Tools, Scripts}` entries. `Phase` is `PreToolUse` or `PostToolUse`; `Tools` are Claude Code tool names (`Bash`, `Read`, `WebFetch`, `*` = all) — runtimes with other names translate before matching (see #608). PostToolUse is a **single** `posttool_chain.py` script on `*` that applies suppress → unicode → redact → canary in-process (Claude Code runs matching hooks in parallel and does not merge two `updatedToolOutput` rewrites). Individual sanitizer files and `canary_posttool.py` are shipped as libraries the driver imports; adapters should invoke the chain, not the stages. `GenerateHooksConfig` is rendered from `HookPlan`, so the two cannot diverge.
- **Wire protocol (per script):** JSON on stdin — `{"tool_name": ..., "tool_input": {...}}` for PreToolUse. PostToolUse payloads include the tool output as `tool_response` (Claude Code; string or structured object such as Bash `{stdout, stderr, interrupted, isImage}`) with `tool_result` accepted as a fallback. Exit `0` = allow. *Blocking* scripts (all PreToolUse scripts, standalone `canary_posttool.py`, and `posttool_chain.py` when its canary stage fires) exit `1` and 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 exit `0` and, when they changed something, print `{"hookSpecificOutput":{"hookEventName":"PostToolUse","updatedToolOutput": <same shape as the input value>}, "tool_result": <scan text>}`. Empty stdout = unchanged. `updatedToolOutput` must match the tool's output shape — a bare string is ignored for built-in Claude Code tools. `scan_text` flattens every string field (including `stderr`); `apply_text` writes a replacement into the first text slot and blanks the rest, or leaves unrecognized structured shapes unchanged.
- **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_name` blocks only in the allowlist hook. `tirith_check.py` fails **open** when the `tirith` binary is missing, times out or errors, unless `TIRITH_REQUIRED=1` (which `appendHookEnv` writes when Tirith is enabled — adapters must make sure it reaches the script). Sanitizing scripts and each `posttool_chain.py` sanitizer stage fail **open** — malformed input or a stage exception is passed through unchanged (exit 0; the unicode hook logs an `input_truncated` finding). Adapters must not treat a sanitizer's empty stdout as an error.
- **Environment:** `runtime.appendHookEnv` writes `TIRITH_FAIL_ON` / `TIRITH_REQUIRED` into `/sandbox/workspace/.env`; the runtime must launch the scripts with that file sourced (Claude's run command does). Scripts also read `FULLSEND_TRACE_ID`, `FULLSEND_TOOL_ALLOWLIST` (allowlist hook, fail-closed when unset) and `FULLSEND_CANARY_TOKEN` (both canary hooks are no-ops when it is empty; supply it via harness `env.sandbox`/`host_files`), and write findings to `/sandbox/workspace/.security/findings.jsonl`.
- **Claude Code caveats (#6357):** (1) *Loading* — fixed by #6358: the hook wiring is written to the runner-owned `/sandbox/claude-config/hooks.json` and passed explicitly via `--settings`, so it loads regardless of the CLI's working directory (previously it sat unread in `/sandbox/workspace/.claude/`); the `hooks-loaded.feature` behaviour scenario guards the "silently not loaded" regression class. Note Claude Code still auto-loads a target repo's own `<repo>/.claude/settings.json` hooks from `<cwd>` — a separate exposure to assess. (2) *Payload* — Claude Code's PostToolUse input carries the output as `tool_response` (the scripts read `tool_result`), replacing output requires `hookSpecificOutput.updatedToolOutput` (the scripts print a bare `tool_result`), and all matching hooks run in parallel with no output chaining; tracked in #6357. (3) *Blocking* — Claude Code keys on the stdout JSON on any exit code (`decision:"block"` is deprecated for PreToolUse but still maps to `deny`) and treats a bare exit `1` as non-blocking (exit `2` is its own blocking code); a local control run confirmed the scripts' "exit 1 + `{"decision":"block"}`" convention does block once the settings are loaded. Net: the PreToolUse half of the contract is effective under Claude Code; the PostToolUse scripts additionally need #6357.
- **Claude Code caveats (#6358, #6357):** (1) *Loading* — fixed by #6358: the hook wiring is written to the runner-owned `/sandbox/claude-config/hooks.json` and passed explicitly via `--settings`, so it loads regardless of the CLI's working directory (previously it sat unread in `/sandbox/workspace/.claude/`); the `hooks-loaded.feature` behaviour scenario guards the "silently not loaded" regression class. Note Claude Code still auto-loads a target repo's own `<repo>/.claude/settings.json` hooks from `<cwd>` — a separate exposure to assess. (2) *Payload (fixed in #6357, contract v2)* — scripts read `tool_response` (fallback `tool_result`) and replace output via `hookSpecificOutput.updatedToolOutput` with the original shape preserved. Sanitizer order and canary detection share `posttool_chain.py` so two PostToolUse hooks cannot race. `scan_text` inspects every string field (including `stderr`). (3) *Blocking* — Claude Code keys on the stdout JSON on any exit code (`decision:"block"` is deprecated for PreToolUse but still maps to `deny`) and treats a bare exit `1` as non-blocking (exit `2` is 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 appends `reason` next to the tool result — Claude still sees the original output**. `canary_posttool.py` therefore also emits `updatedToolOutput` with the token redacted to `[CANARY_REDACTED]`. Net: after #6358 and #6357, both PreToolUse and PostToolUse halves of the contract are effective under Claude Code.

### Runtime-specific config key support

Expand Down
85 changes: 51 additions & 34 deletions internal/security/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,15 @@ var CanaryPostToolHook []byte
//go:embed hooks/tool_allowlist_pretool.py
var ToolAllowlistPreToolHook []byte

// HookIO is the shared PostToolUse protocol library imported by the chain
// and by individual sanitizer/canary scripts. It is not itself a hook.
//
//go:embed hooks/hook_io.py
var HookIO []byte

//go:embed hooks/posttool_chain.py
var PostToolChainHook []byte

// hookEntry represents a single hook command in Claude settings.
type hookEntry struct {
Type string `json:"type"`
Expand Down Expand Up @@ -128,42 +137,15 @@ func HookPlan(hooks SandboxHookConfig) []HookGroup {
})
}

// PostToolUse hooks for Bash|WebFetch|Read. Combined into a single group
// so adapters chain them sequentially, each feeding its modified result to
// the next. Order: context suppress (compacts verbose success output) →
// unicode normalize → secret redact. Suppressing first avoids scanning
// text we'd discard. Invariant: unicode normalization must run before
// secret redaction so zero-width characters cannot break prefix regexes
// and reconstruct secrets.
// NOTE: Claude Code runs all matching hooks in parallel and does not
// pipe one hook's output into the next, and its PostToolUse payload uses
// `tool_response` rather than the `tool_result` these scripts read — so
// under Claude Code this chain is not effective today (fullsend#6357).
// The ordering here is the contract adapters must meet.
var postScripts []string
if contextSuppressPostToolEnabled(hooks) {
postScripts = append(postScripts, "context_suppress_posttool.py")
}
if unicodePostToolEnabled(hooks) {
postScripts = append(postScripts, "unicode_posttool.py")
}
if secretRedactPostToolEnabled(hooks) {
postScripts = append(postScripts, "secret_redact_posttool.py")
}
if len(postScripts) > 0 {
plan = append(plan, HookGroup{
Phase: HookPhasePostToolUse, Tools: []string{"Bash", "WebFetch", "Read"},
Scripts: postScripts,
})
}

// Canary PostToolUse hook (all tools). Separate group from the
// Bash|WebFetch|Read chain because canary must catch leaks from any
// tool including MCP tools.
if canaryPostToolEnabled(hooks) {
// PostToolUse driver for every tool. Claude Code runs matching hooks in
// parallel and does not merge two updatedToolOutput rewrites, so suppress →
// unicode → redact → canary must share one process (fullsend#6357). The
// driver skips sibling scripts that HookFiles omitted. Adapters that
// invoke HookPlan should call this one script rather than the stages.
if postToolChainEnabled(hooks) {
plan = append(plan, HookGroup{
Phase: HookPhasePostToolUse, Tools: []string{AllTools},
Scripts: []string{"canary_posttool.py"},
Scripts: []string{"posttool_chain.py"},
})
}

Expand Down Expand Up @@ -205,6 +187,9 @@ func HookFiles(hooks SandboxHookConfig) map[string][]byte {
if ssrfPreToolEnabled(hooks) {
files["ssrf_pretool.py"] = SSRFPreToolHook
}
if postToolChainEnabled(hooks) {
files["posttool_chain.py"] = PostToolChainHook
}
if secretRedactPostToolEnabled(hooks) {
files["secret_redact_posttool.py"] = SecretRedactPostToolHook
}
Expand All @@ -214,6 +199,9 @@ func HookFiles(hooks SandboxHookConfig) map[string][]byte {
if contextSuppressPostToolEnabled(hooks) {
files["context_suppress_posttool.py"] = ContextSuppressPostToolHook
}
if postToolProtocolEnabled(hooks) {
files["hook_io.py"] = HookIO
}
if canaryPreToolEnabled(hooks) {
files["canary_pretool.py"] = CanaryPreToolHook
}
Expand Down Expand Up @@ -251,6 +239,35 @@ func ssrfPreToolEnabled(hooks SandboxHookConfig) bool {
return boolDefault(sh.SSRFPreTool, true)
}

func postToolSanitizeEnabled(hooks SandboxHookConfig) bool {
return contextSuppressPostToolEnabled(hooks) ||
unicodePostToolEnabled(hooks) ||
secretRedactPostToolEnabled(hooks)
}

func postToolChainEnabled(hooks SandboxHookConfig) bool {
return postToolSanitizeEnabled(hooks) || canaryPostToolEnabled(hooks)
}

func postToolProtocolEnabled(hooks SandboxHookConfig) bool {
return postToolChainEnabled(hooks)
}

// hookLibraryFile reports scripts shipped as imports for other hooks, not
// invoked directly by HookPlan or settings.json.
func hookLibraryFile(name string) bool {
switch name {
case "hook_io.py",
"context_suppress_posttool.py",
"unicode_posttool.py",
"secret_redact_posttool.py",
"canary_posttool.py":
return true
default:
return false
}
}

func secretRedactPostToolEnabled(hooks SandboxHookConfig) bool {
sh := hooks.sandboxHooks()
if sh == nil {
Expand Down
33 changes: 20 additions & 13 deletions internal/security/hooks/canary_posttool.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,13 @@
This hook blocks (exit 1) on canary detection because a canary leak
is a definitive exfiltration signal requiring immediate termination.

Protocol: reads JSON from stdin (tool_name, tool_input, tool_result),
writes JSON to stdout if blocking. Exit 0 = allow, exit 1 = block.
Protocol: reads JSON from stdin (``tool_response`` preferred, ``tool_result``
fallback). Exit 0 = allow, exit 1 = block.

Under Claude Code, ``decision: "block"`` only appends ``reason`` next to the
tool result — Claude still sees the original output. This hook therefore also
emits ``hookSpecificOutput.updatedToolOutput`` with the canary redacted so the
leaked token is not left in context.

Environment variables:
FULLSEND_CANARY_TOKEN: The canary string to watch for in tool results.
Expand All @@ -27,6 +32,8 @@
import sys
from datetime import UTC, datetime

import hook_io

FINDINGS_PATH = "/sandbox/workspace/.security/findings.jsonl"
MAX_INPUT_BYTES = 10 * 1024 * 1024 # 10 MB

Expand Down Expand Up @@ -77,18 +84,18 @@ def main() -> None:
if not canary:
sys.exit(0)

tool_result = hook_input.get("tool_result", "")
if not isinstance(tool_result, str):
tool_result = json.dumps(tool_result)

if canary.lower() in tool_result.lower():
tool_name = hook_input.get("tool_name", "unknown")
reason = f"CANARY_LEAKED: canary token found in {tool_name} result"
log_finding("canary_leak", "critical", reason, "block")
json.dump({"decision": "block", "reason": reason}, sys.stdout)
sys.exit(1)
original = hook_io.payload(hook_input)
text = hook_io.scan_text(original)
if canary.lower() not in text.lower():
sys.exit(0)
Comment thread
qodo-code-review[bot] marked this conversation as resolved.

sys.exit(0)
tool_name = hook_input.get("tool_name", "unknown")
reason = f"CANARY_LEAKED: canary token found in {tool_name} result"
log_finding("canary_leak", "critical", reason, "block")
# Block for adapters; also replace output so Claude Code does not keep
# the leaked token (decision:block alone does not hide PostToolUse output).
hook_io.emit_block(reason, hook_io.redact_canary(original, canary))
sys.exit(1)


if __name__ == "__main__":
Expand Down
Loading
Loading