Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
17 changes: 17 additions & 0 deletions docs/ADRs/0090-runtime-neutral-sandbox-hooks-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,3 +96,20 @@ feature:
> `tool_result`), replace via `hookSpecificOutput.updatedToolOutput`, and
> enforce unicode → canary → suppress → redact in `posttool_chain.py`. See
> [runtimes.md](../runtimes.md#sandbox-hook-contract).

> **Done ([#608](https://github.com/fullsend-ai/fullsend/issues/608)):**
> The canonical Claude tool-name vocabulary is recorded once in
> `security.CanonicalClaudeTools` (with `security.LegacyClaudeTools` for
> names agents and adapters still use), mirrored into
> `tool_allowlist_pretool.py` and kept identical by a Go test; `HookPlan`
> tools 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_unnormalized` when the adapter did not
> translate, `allowlist_entry_unnormalized` when the allowlist is the
> non-canonical side, `tool_name_case_collision` when neither spelling is
> a Claude tool; all `ALLOWLIST_HOOK_ERROR`, severity `high`) 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](../runtimes.md#sandbox-hook-contract).
1 change: 1 addition & 0 deletions docs/runtimes.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ The hook scripts in `internal/security/hooks/*.py` are plain programs with no Cl

- **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`, `PostToolUse` or `PostToolUseFailure` (the last carries Claude Code's failed-call payload — `hook_event_name`, `tool_name`, `tool_input`, a string `error` — 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 an `additionalContext` warning); adapters whose post-tool event already fires for failed calls, like pi, map it onto nothing); `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 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 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.
- **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.LegacyClaudeTools` lists names Claude Code no longer has but that agent `tools:` frontmatter and adapters still use (`LS`, `MultiEdit`, `Task` → `Agent`, …). `FULLSEND_TOOL_ALLOWLIST` and `security.HookGroup.Tools` are written in this vocabulary; it is a **reference** checked by tests, not validated at run time — `TestHookPlan_ToolsAreCanonical` pins every `HookPlan` tool, `TestPiToolNameMapsUseClaudeVocabulary` pins the pi adapter's maps to canonical *or legacy* names (pi's `ls` maps to `LS`, which Claude Code no longer sends — an agent allowlisted in canonical-only vocabulary sees pi's `ls` as a plain `tool_blocked`) (`piToolForClaude`/`claudeToolForPi` in `internal/runtime/pi_agent.go` → `fullsend-manifest.json` `hooks.toolNames` → `fullsend-hooks.js` `claudeToolName()`), and `TestToolAllowlistHook_VocabularyMatchesGo` keeps the copy inside `tool_allowlist_pretool.py` identical 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), but `tool_allowlist_pretool.py` distinguishes 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 is `ALLOWLIST_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 a `tool_name_unnormalized` finding (severity `high`, action `block`); if instead the *tool name* is the Claude one (e.g. `Bash` against an allowlist written as `bash`) the reason names the `FULLSEND_TOOL_ALLOWLIST` entry (`… is not Claude vocabulary (expected canonical name 'Bash'); fix the allowlist`) and logs `allowlist_entry_unnormalized`; if neither side is a Claude tool name the reason says so, blames neither, and logs `tool_name_case_collision`. These three findings are `high`, not `critical`, so an adapter gap does not trip `critical`-keyed escalation the way a forbidden tool does. Names with no case-insensitive match keep the `tool_blocked` finding (severity `critical`); a non-string `tool_name` blocks 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 as `Edit` while an agent is allowlisted only for `MultiEdit` surfaces as a plain `tool_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 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`), 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_text` writes 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 `Read` result means `Edit.old_string` no longer matches the file, and a `Write` of 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`, not `TOKEN_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-style `name = expr` counts 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 and `go 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 pytest` counts) — a command that merely mentions it, such as `grep -n scan-secrets hooks.py`, keeps its output; pipelines (`| tail` can cut the `FAIL` line; a `|` inside quotes such as `-run 'A|B'` is not a pipeline), `$(…)`, chains of two tools (`pytest; go test`, and deliberately also `go test && go vet` — one summary cannot speak for two), a trailing `echo $?`, 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 attaches `hookSpecificOutput.additionalContext` so the agent knows the output was changed and why, and every hook entry carries `timeout: 30` (Claude Code's 600 s default fails open — so does the 30 s one, for PreToolUse blockers included; the scripts finish in milliseconds and `tirith_check.py` bounds 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_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), and a stage failure is recorded in `findings.jsonl` as `<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, and `exit 1` is unconditional. Because `posttool_chain.py` is 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`) whenever `FULLSEND_CANARY_TOKEN` is 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.
Expand Down
24 changes: 24 additions & 0 deletions internal/runtime/pi_agent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package runtime
import (
"testing"

"github.com/fullsend-ai/fullsend/internal/security"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
Expand Down Expand Up @@ -158,8 +159,31 @@ func TestPiToolNameMapsAreInverse(t *testing.T) {
t.Parallel()
for claude, pi := range piToolForClaude {
if claude == "MultiEdit" {
// MultiEdit and Edit both map onto pi's single edit tool, so the
// adapter reports every pi edit as "Edit". An agent allowlisted
// only for MultiEdit is therefore blocked under pi as a plain
// tool_blocked — a renaming gap the hook's case-variant diagnostic
// (#608) cannot see. Documented in docs/runtimes.md; not a bug in
// the maps.
continue
}
assert.Equal(t, claude, claudeToolForPi[pi], "pi tool %q must map back to %q", pi, claude)
}
for pi, claude := range claudeToolForPi {
assert.Equal(t, pi, piToolForClaude[claude], "Claude tool %q must map back to %q", claude, pi)
}
}

// The hook scripts only recognise canonical (or legacy) Claude names, so the
// adapter's translation table must stay inside that vocabulary (#608).
func TestPiToolNameMapsUseClaudeVocabulary(t *testing.T) {
t.Parallel()
for pi, claude := range claudeToolForPi {
assert.True(t, security.KnownClaudeTool(claude),
"claudeToolForPi[%q] = %q is not a canonical or legacy Claude tool name", pi, claude)
}
for claude := range piToolForClaude {
assert.True(t, security.KnownClaudeTool(claude),
"piToolForClaude key %q is not a canonical or legacy Claude tool name", claude)
}
}
94 changes: 94 additions & 0 deletions internal/security/canonical_tools.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package security

// CanonicalClaudeTools is the set of tool names Claude Code exposes today.
// It is the canonical hook vocabulary (ADR 0090, #608): FULLSEND_TOOL_ALLOWLIST
// entries and HookGroup.Tools are written in these names, and a runtime adapter
// (pi: claudeToolForPi → fullsend-manifest.json hooks.toolNames →
// fullsend-hooks.js claudeToolName) translates its own names to them before
// any hook script runs. A name the adapter did not translate is still blocked
// (the allowlist is exact-match, fail-closed) but tool_allowlist_pretool.py
// reports it as an adapter gap rather than a forbidden tool.
//
// Verified 2026-08-23 against the live tools reference
// (https://code.claude.com/docs/en/tools-reference, which documents the
// latest release — 2.1.241 at the time); the CHANGELOG for 2.1.235–2.1.241
// records no tool additions or removals relative to the 2.1.234 pinned in
// images/sandbox/Containerfile. Re-check on every pin bump.
//
// MCP tool names (mcp__<server>__<tool>) are not canonical: they pass through
// verbatim and the allowlist matches them exactly, so the hook never treats
// their case variants as a translation gap.
//
// The hook script carries its own copy (CANONICAL_TOOLS / LEGACY_TOOLS in
// hooks/tool_allowlist_pretool.py) because the scripts have no access to Go;
// TestToolAllowlistHook_VocabularyMatchesGo keeps the two identical.
var CanonicalClaudeTools = map[string]bool{
"Agent": true,
"Artifact": true,
"AskUserQuestion": true,
"Bash": true,
"CronCreate": true,
"CronDelete": true,
"CronList": true,
"Edit": true,
"EndConversation": true,
"EnterPlanMode": true,
"EnterWorktree": true,
"ExitPlanMode": true,
"ExitWorktree": true,
"Glob": true,
"Grep": true,
"ListAgents": true,
"ListMcpResourcesTool": true,
"LSP": true,
"Monitor": true,
"NotebookEdit": true,
"PowerShell": true,
"PushNotification": true,
"Read": true,
"ReadMcpResourceTool": true,
"RemoteTrigger": true,
"ReportFindings": true,
"ScheduleWakeup": true,
"SendMessage": true,
"SendUserFile": true,
"ShareOnboardingGuide": true,
"Skill": true,
"TaskCreate": true,
"TaskGet": true,
"TaskList": true,
"TaskOutput": true,
"TaskStop": true,
"TaskUpdate": true,
"TodoWrite": true,
"ToolSearch": true,
"WaitForMcpServers": true,
"WebFetch": true,
"WebSearch": true,
"Workflow": true,
"Write": true,
}

// LegacyClaudeTools are tool names Claude Code no longer exposes but which
// still appear in agent `tools:` frontmatter and in runtime adapter maps (the
// pi adapter reports pi's `ls` as LS and accepts MultiEdit for its edit tool).
// Claude Code itself never sends these to a hook; an adapter may. The value is
// the current replacement, "" when there is none.
var LegacyClaudeTools = map[string]string{
"LS": "",
"MultiEdit": "Edit",
"NotebookRead": "Read",
"Task": "Agent",
"TodoRead": "",
}

// KnownClaudeTool reports whether name is a canonical or legacy Claude tool
// name — i.e. a name the hook scripts can be expected to see from a runtime
// adapter.
func KnownClaudeTool(name string) bool {
if CanonicalClaudeTools[name] {
return true
}
_, legacy := LegacyClaudeTools[name]
return legacy
}
Loading
Loading