Skip to content
Open
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
5 changes: 5 additions & 0 deletions docs/ADRs/0100-codex-sandbox-hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@ becomes exit 2 with the reason on stderr, a rewrite the runtime cannot apply bec
`--dangerously-bypass-hook-trust`, justified by fullsend's own SHA-256 guard over the adapter,
which is a stronger check than the trust hash it replaces.

> **Implementation note (September 2026):** Security-sensitive and unclassified rewrites now
> block and withhold the original result. Only context suppression and ANSI-only cleanup with
> known-safe metadata pass unchanged; suppressed output runs through the full chain again with
> suppression disabled first.

Because the hook scripts and their directory stay agent-writable between iterations — the residue
Claude Code and pi also have — codex additionally **re-verifies every script against runner-held digests
before each invocation** — digests the runner records outside the sandbox at Bootstrap and injects into the launch command at Run, here carried in the codex process's
Expand Down
14 changes: 9 additions & 5 deletions docs/contributing/runtime-implementation.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,15 +96,15 @@ How the shared PostToolUse chain reaches each runtime — the three sanitizer ro

- **Claude Code:** `posttool_chain.py` runs on successful tool calls (#6357). On failed calls the same driver runs on `PostToolUseFailure`, where it detects, logs to `findings.jsonl` and warns the agent via `additionalContext` — Claude Code does not let a hook rewrite a failed call's output.
- **Pi:** `fullsend-hooks.js` `tool_result` → the same `posttool_chain.py` (sent `tool_response` + `tool_result`; `updatedToolOutput` applied to the result the model sees). pi's `tool_result` fires for failed calls too, so those are sanitized as well.
- **Codex:** `fullsend-codex-hook.py` (a `PostToolUse` handler in `hooks.json`) → the same `posttool_chain.py`. codex's `PostToolUse` fires for a command that exited non-zero as well, so failed calls are covered without a second phase — but **the rewrite cannot be applied**: codex accepts only `additionalContext` and `updatedMCPToolOutput` there, so the chain's `updatedToolOutput` is dropped and the model is warned that the output would have been redacted. A canary block still withholds the output entirely, because a codex `PostToolUse` block replaces the tool result with the reason.
- **Codex:** `fullsend-codex-hook.py` (a `PostToolUse` handler in `hooks.json`) → the same `posttool_chain.py`. codex's `PostToolUse` fires for a command that exited non-zero as well, so failed calls are covered without a second phase — but **the rewrite cannot be applied**: codex accepts only `additionalContext` and `updatedMCPToolOutput` there. The adapter uses the chain metadata to block and withhold results containing secrets, unsafe Unicode or an unclassified rewrite. It passes only context suppression and ANSI cleanup with known-safe metadata. Before suppressed output can pass, the original runs through the complete chain again with suppression disabled, preserving Unicode and NFKC-aware secret detection. A canary block also withholds the output entirely, because a codex `PostToolUse` block replaces the tool result with the reason.

| Feature | Where it runs | Claude Code | OpenCode (stub) | Pi | Codex | Notes for future runtimes |
|---------|---------------|-------------|-----------------|----|-------|---------------------------|
| **Tirith** (Bash command scanning) | Sandbox PreToolUse hook | ✓ (loaded via `--settings`, #6358) | N/A — stub | ✓ via `fullsend-hooks.js` (pi `tool_call` → `HookPlan` PreToolUse scripts) | ✓ via `fullsend-codex-hook.py` (a `PreToolUse` handler per `HookPlan` group in `$CODEX_HOME/hooks.json`; the script's `exit 1` + `decision:block` is translated to **exit 2 + reason on stderr**, the only reliable block on codex) | `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` | ✓ via `fullsend-codex-hook.py`; the group's `WebFetch` tool is dropped from the matcher because codex has no such tool — it fetches through the shell, which the `Bash` matcher already covers | `ssrf_pretool.py`; default on; when DNS resolution fails for a host on the `FULLSEND_EGRESS_ALLOWLIST`, the hook defers to the L7 egress proxy instead of failing closed — all other SSRF checks (scheme, hostname blocklist, IP blocklist, DNS rebinding) still apply. On GitLab CI, the forge host is also covered by the auto-generated `fullsend-gitlab-forge` provider profile (#6615), which opens the L7 proxy for the forge API |
| **Canary token detection** | Sandbox Pre/PostToolUse hooks | pre ✓; post-tool via `posttool_chain.py` on successful 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 `tool_call`; post via `tool_result` (sequential chain, block withholds the result) | ✓ pre and post via `fullsend-codex-hook.py`. A post-tool hit **blocks**, which on codex replaces the tool result with the reason, so the flagged output never reaches the model — stronger than Claude Code. In the **artifacts** the canary is only pattern-redacted if it happens to look like a credential, not withheld: artifact filtering is redaction, not the chain. It does **not halt the session**: `continue:false` is unsupported on PreToolUse and inert on PostToolUse, so codex has no hook-driven stop ([ADR 0100](../ADRs/0100-codex-sandbox-hooks.md)) | `canary_pretool.py` / `canary_posttool.py`; both inert unless `FULLSEND_CANARY_TOKEN` is set — on codex that value is read at bootstrap and re-exported after `.env`, so an agent cannot clear it for a later iteration. 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 | ✓ shared chain (above) | N/A — stub | ✓ shared chain (above) | Model context: detect + warn only — codex cannot rewrite a built-in tool's output, so the redaction is dropped and the model gets an `additionalContext` warning instead. Artifacts: `output.jsonl` and the extracted rollout are filtered through the same Go `security.SecretRedactor` the progress parsers use, because codex's artifacts keep raw tool output where Claude Code's stream carries the post-hook result | `secret_redact_posttool.py` |
| **Unicode normalization** | Sandbox PostToolUse hook | ✓ shared chain (above) | N/A — stub | ✓ shared chain (above) | Detect + log only — same reason as secret redaction (shared chain, above) | `unicode_posttool.py` |
| **Secret redaction** | Sandbox PostToolUse hook | ✓ shared chain (above) | N/A — stub | ✓ shared chain (above) | Model context: detect + withhold — codex cannot rewrite a built-in tool's output, so the adapter blocks the result instead of exposing the original secret. Artifacts: `output.jsonl` and the extracted rollout are filtered through the same Go `security.SecretRedactor` the progress parsers use, because codex's artifacts keep raw tool output where Claude Code's stream carries the post-hook result | `secret_redact_posttool.py` |
| **Unicode normalization** | Sandbox PostToolUse hook | ✓ shared chain (above) | N/A — stub | ✓ shared chain (above) | Unsafe hidden Unicode and OSC: detect + withhold. ANSI-only cleanup passes the original result because codex cannot apply the benign rewrite. Plain `fullwidth` findings keep their text and do not block; NFKC escape reassembly has its own unsafe category | `unicode_posttool.py` |
| **Context suppression** | Sandbox PostToolUse hook | ✓ shared chain (above) | N/A — stub | ✓ shared chain (above) | ✗ — it exists only to rewrite output, which codex does not allow for built-in tools; the stage still runs and its findings are logged, but nothing is condensed | `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 | opt-in; ✓ when enabled, via `fullsend-codex-hook.py` (names translated to Claude vocabulary first, #608). Unlike pi there is **no native allowlist**: codex has no `--tools`, so an agent's `tools:` frontmatter is documentation unless the harness enables this hook, and the `Bash(a,b)` first-token allowlist is recorded in the manifest but **not wired**. Note `apply_patch` arrives as `Edit`, so an agent allowlisted only for `Write` is blocked | `tool_allowlist_pretool.py`; requires `FULLSEND_TOOL_ALLOWLIST` (fail-closed when unset) |

Expand Down Expand Up @@ -717,8 +717,12 @@ Three codex behaviours are load-bearing, and the adapter exists because of the f
carries an `async` key at all, and `TestCodexHooksJSON_NeverAsync` asserts its absence.
3. **`PostToolUse` output is `deny_unknown_fields` and accepts only `additionalContext` and
`updatedMCPToolOutput`.** The sanitizers' `updatedToolOutput` would make the hook `Failed`, so
the adapter drops the rewrite and emits an `additionalContext` telling the model the output it is
about to read contains content that would have been redacted and is untrusted.
the adapter cannot return the rewrite. It classifies the chain metadata instead: secret and
unsafe-Unicode rewrites block and withhold the original result; context suppression and
ANSI-only cleanup pass silently because they are optimizations, not reasons to hide an otherwise
safe result. Suppressed output runs through the full chain again with suppression disabled before it can pass. An unclassified
rewrite, including OSC cleanup, is withheld rather than silently discarding a new sanitizer's
signal or exposing content the sanitizer meant to remove.

Consequences of those, recorded in the matrix and [ADR 0100](../ADRs/0100-codex-sandbox-hooks.md):

Expand Down
12 changes: 7 additions & 5 deletions docs/runtimes/codex.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,11 +164,13 @@ What a local codex run needs, beyond the guide:
`Command blocked by PreToolUse hook: <reason>`, so the agent understands it was refused rather
than that the command broke — in a smoke run it summarised the block as "blocked by a safety hook"
and moved on.
- **The security hooks block a tool result or warn about it; they never edit it.** Codex does not
let a hook replace the output of a built-in tool, so where another runtime would hand the model a
redacted result, codex either withholds it entirely or passes it through with a note saying what
it contained. Your run artifacts are redacted either way — `output.jsonl`, the transcripts and
`codex-debug.log` are all scrubbed before they are written.
- **The security hooks can withhold a tool result, but cannot edit it.** Codex does not let a hook
replace the output of a built-in tool, so a result containing a secret, unsafe hidden Unicode or
a canary is withheld instead of reaching the model unredacted. Context-only suppression and
ANSI cleanup are skipped because those results are safe to pass unchanged; suppressed output runs
through the full security chain again with suppression disabled first. OSC and unclassified rewrites are withheld. Your run
artifacts are redacted either way — `output.jsonl`, the transcripts and `codex-debug.log` are all
scrubbed before they are written.
- **Skills** work as they do on Claude Code: the harness's skills, plus your repository's own
`.agents/skills`, both scanned for injected content before the agent sees them. Codex's bundled
skills (`skill-installer`, `imagegen` and friends) are switched off, so an agent sees only yours.
Expand Down
Loading
Loading