fix(security): mirror credential shapes into the PostToolUse hook, scoped to checkout content - #7009
Conversation
E2E tests did not runE2E tests run automatically for org/repo members and collaborators on pull requests. For other contributors, a maintainer must add the See E2E testing guide for details. |
PR Summary by QodoHarden PostToolUse redaction with checkout-aware JWT handling
AI Description
Diagram
High-Level Assessment
Files changed (6)
|
Site previewPreview: https://55d8235f-site.fullsend-ai.workers.dev Commit: |
Code Review by Qodo
1.
|
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
…end cwd from pi Two review findings on the checkout-scoped bare-JWT skip. The checkout root was the nearest .git ancestor of the resolved cwd, wherever that walk led: a .git an agent plants at /sandbox/workspace, reached through a symlink inside the checkout, made the runner's sibling OIDC token file 'inside' and skipped the mask. The root is now searched only strictly below SANDBOX_WORKSPACE (/sandbox/workspace, read once at process start from FULLSEND_SANDBOX_WORKSPACE; tests set it), so a .git at or above the workspace, or a cwd outside it, yields no root and no skip. The claude and pi launch commands re-export the runtime's value after sourcing the agent-writable .env, and the key is reserved from harness env.sandbox, so an assignment in .env does not move the boundary either; a .env that redefines the claude command is the pre-existing launch shim seam. Residual, stated in the hook: a .git planted in another runner-owned workspace subdirectory (.env.d) can still be a root through the same symlink, for a bare JWT no structural shape covers. pi's adapter sent no cwd, so under pi every file-content Read got no skip and the newly mirrored JWT pattern rewrote checkout fixtures — the edit-corruption the skip exists to prevent, and not prior behaviour, since the pattern is new. The adapter now sends its process working directory: pi is started in the checkout and its tools run in child shells, so the agent cannot move it. codex still sends no cwd; it cannot apply the rewrite at all, so nothing is corrupted there, only warned about. Tests pin a planted .git at and above the workspace (direct and through a symlinked cwd), a checkout outside the workspace, the re-exported boundary after .env on both launch commands, and cwd on every pi PostToolUse payload; the contract doc and the adapter header say the same. Raised by Qodo's review of fullsend-ai#7009. Signed-off-by: Dharit Shah <dhshah@redhat.com>
…end cwd from pi Two review findings on the checkout-scoped bare-JWT skip, and a third gap the second fix exposed. The checkout root was the nearest .git ancestor of the resolved cwd, wherever that walk led: a .git an agent plants at /sandbox/workspace, reached through a symlink inside the checkout, made the runner's sibling OIDC token file 'inside' and skipped the mask. The root is now searched only strictly below SANDBOX_WORKSPACE, so a .git at or above the workspace, or a cwd outside it, yields no root and no skip. The boundary is the runner's constant; the hook takes an override only from its own command line, the seam the subprocess tests use, which the runner-owned hooks.json and the pi adapter build without it. It is not read from environment variables: under fullsend's plain launch, Claude Code (observed locally on 2.1.235; the sandbox image pins 2.1.260) applies a checkout's .claude/settings.json env block to hook processes over the launch environment, so an env-carried boundary could be moved by a committed settings file, and the sandbox .env every launch command sources lives in the sandbox user's workspace. Not covered, and pre-existing: under claude and pi the hook interpreter is a bare python3 resolved through a PATH with the agent-writable workspace bin first, with PYTHONPATH and the user site directory honoured (codex isolates its adapter); left for a follow-up. Residual, stated in the hook: a .git planted in another runner-owned workspace subdirectory (.env.d) can still be a root through a plain cd or a symlink, for a bare JWT no structural shape covers, and only for files under that subdirectory. pi's adapter sent no cwd, so under pi every file-content Read got no skip and the newly mirrored JWT pattern rewrote checkout fixtures — the edit-corruption the skip exists to prevent, and not prior behaviour, since the pattern is new. The adapter now sends its process working directory: pi is started in the checkout and its tools run in child shells, so the agent cannot move it. codex still sends no cwd; it cannot apply the rewrite at all, so nothing is corrupted there, only warned about. Sending cwd exposed a gap of its own under pi: pi strips a leading '@', expands '~' and turns a file:// URL into a path before opening, while the adapter forwards the raw argument, so a read of '@../.gcp-oidc-token' resolved under the checkout in the hook and the token came back unmasked. Those forms now never skip, for every file-content tool; any URL scheme is refused, broader than pi's rewrite on purpose. Tests pin a planted .git at and above the workspace (the at-workspace case direct and through a symlinked cwd), a checkout outside the workspace, the rewritten path forms for Read and Grep, a symlinked workspace path, the boundary ignoring the environment (standalone and through the chain) and honouring only an absolute command-line value (standalone), and cwd on every pi PostToolUse payload across events; the contract doc and the adapter header say the same. Raised by Qodo's review of fullsend-ai#7009. Signed-off-by: Dharit Shah <dhshah@redhat.com>
096d375 to
d571888
Compare
waynesun09
left a comment
There was a problem hiding this comment.
Automated review sweep — 1 finding could not be attached inline because the file it concerns is outside this PR's diff.
MEDIUM — Codex adapter never forwards cwd, so Codex never gets the checkout-scoped JWT skip this PR just fixed for pi
internal/runtime/codex_hook/fullsend-codex-hook.py:404 (run_post_tool_use, and run_pre_tool_use similarly)
run_post_tool_use() builds the payload sent to hook scripts from only tool_name/tool_input/tool_response/tool_result — cwd is never forwarded, even though Codex's own hook input carries it (see fixtures in internal/runtime/codex_hook_test.go:94). Codex's apply_patch tool maps to Claude's Edit (CLAUDE_TOOL_FOR_CODEX), and Edit is one of the tools content_skips keys on (_TOOL_PATH_KEY). Because cwd is always missing, content_skips always returns no skip for Codex, so a Codex edit of a checkout file containing a jwt.io-style fixture is masked and rewritten to something different from what's on disk — the exact edit-corruption failure mode this PR's checkout-scoped skip exists to prevent, and that d5718882 just fixed for pi (see the "Pi corrupts jwt fixtures" thread on this PR). The same gap is unfixed on Codex.
This file isn't touched by this PR's diff, so it's a pre-existing parity gap rather than a regression — but the PR's new docs/contributing/runtime-implementation.md contract line generically covers it ("an adapter that omits it gets no bare-JWT skip"), so it's now a documented, unaddressed hole.
Suggestion: forward hook_input['cwd'] into the payload built in run_post_tool_use (mirroring what Claude Code and the pi adapter now send), so Codex Edit/Read/Grep calls get the same checkout-scoped skip and don't corrupt JWT-shaped fixtures the agent then edits against.
| # host_files credentials) can still be a root through a plain cd into it or an | ||
| # in-checkout symlink; only a bare JWT no structural shape covers would skip | ||
| # there, and only for files under that subdirectory. | ||
| SANDBOX_WORKSPACE: str = "/sandbox/workspace" |
There was a problem hiding this comment.
MEDIUM — No cross-check ties the Python hook's hardcoded SANDBOX_WORKSPACE to internal/sandbox.SandboxWorkspace
secret_redact_posttool.py hardcodes SANDBOX_WORKSPACE: str = "/sandbox/workspace" as a Python literal, documented as mirroring internal/sandbox.SandboxWorkspace (verified equal today, internal/sandbox/sandbox.go:29, pinned by internal/sandbox/sandbox_test.go:33). The checkout-scoped bare-JWT skip's entire security boundary (introduced by this PR's d5718882 fix, replacing the walk that let a forged .git widen the trusted root to the sibling OIDC token file) now depends on this literal matching the Go constant.
Verified no test enforces that: internal/security/hooks_test.go:175 only asserts SecretRedactPostToolHook is non-empty, and hooks.go (which already imports the sandbox package for SandboxHooksDir/SandboxHooksSettings) has no assertion against the embedded Python source. If SandboxWorkspace is ever renamed or changed in Go, this file's literal silently goes stale with no CI failure, and the checkout-boundary control mis-scopes.
Suggestion: add one line to hooks_test.go: assert.Contains(t, string(SecretRedactPostToolHook), \SANDBOX_WORKSPACE: str = "`+sandbox.SandboxWorkspace+`"`)` so a future rename of the Go constant fails CI instead of drifting silently.
There was a problem hiding this comment.
Fixed in 42ee4da. hooks_test.go asserts the embedded source assigns SANDBOX_WORKSPACE the value of sandbox.SandboxWorkspace, matching on the value rather than the source formatting.
…d forward cwd from codex Two findings from the automated review sweep on fullsend-ai#7009. The hook hardcodes the checkout boundary as a Python literal that must match internal/sandbox.SandboxWorkspace; nothing tied the two, so a rename or move of the Go constant would have left the checkout-scoped bare-JWT skip mis-scoped with no CI failure. hooks_test.go now asserts the embedded source assigns the constant's value. The codex adapter built its hook payloads without cwd although codex's own hook input carries it. It now forwards it on both phases, as Claude Code's hooks and the pi adapter do, so the wire shape matches the contract; a test records the payload each phase delivers and that a non-string value is dropped. Under codex that scopes nothing today: apply_patch reaches the hook as Edit with the patch text as its input and no file path for the skip to key on, codex reads are shell output, which is never skipped, and codex drops the chain's rewrite anyway, so no fixture was being rewritten. The sweep's corruption premise does not hold on codex; the parity hole in the contract doc did, and is closed. The hook's module comment, the wire-protocol row and the PR body say the same. Signed-off-by: Dharit Shah <dhshah@redhat.com>
|
Fixed in 42ee4da for the review-body finding (codex adapter never forwards cwd): the adapter forwards its hook input's cwd on both phases, with a test per phase and one that a non-string value is dropped. Under codex that scopes nothing today: apply_patch reaches the hook as Edit with the patch text as its input and no file path for the skip to key on, codex reads are shell output, which is never skipped, and codex drops the chain's rewrite, so no fixture was being rewritten. What this closes is the parity hole in the contract doc. |
42ee4da to
9bac703
Compare
…end cwd from pi Two review findings on the checkout-scoped bare-JWT skip, and a third gap the second fix exposed. The checkout root was the nearest .git ancestor of the resolved cwd, wherever that walk led: a .git an agent plants at /sandbox/workspace, reached through a symlink inside the checkout, made the runner's sibling OIDC token file 'inside' and skipped the mask. The root is now searched only strictly below SANDBOX_WORKSPACE, so a .git at or above the workspace, or a cwd outside it, yields no root and no skip. The boundary is the runner's constant; the hook takes an override only from its own command line, the seam the subprocess tests use, which the runner-owned hooks.json and the pi adapter build without it. It is not read from environment variables: under fullsend's plain launch, Claude Code (observed locally on 2.1.235; the sandbox image pins 2.1.260) applies a checkout's .claude/settings.json env block to hook processes over the launch environment, so an env-carried boundary could be moved by a committed settings file, and the sandbox .env every launch command sources lives in the sandbox user's workspace. Not covered, and pre-existing: under claude and pi the hook interpreter is a bare python3 resolved through a PATH with the agent-writable workspace bin first, with PYTHONPATH and the user site directory honoured (codex isolates its adapter); left for a follow-up. Residual, stated in the hook: a .git planted in another runner-owned workspace subdirectory (.env.d) can still be a root through a plain cd or a symlink, for a bare JWT no structural shape covers, and only for files under that subdirectory. pi's adapter sent no cwd, so under pi every file-content Read got no skip and the newly mirrored JWT pattern rewrote checkout fixtures — the edit-corruption the skip exists to prevent, and not prior behaviour, since the pattern is new. The adapter now sends its process working directory: pi is started in the checkout and its tools run in child shells, so the agent cannot move it. codex still sends no cwd; it cannot apply the rewrite at all, so nothing is corrupted there, only warned about. Sending cwd exposed a gap of its own under pi: pi strips a leading '@', expands '~' and turns a file:// URL into a path before opening, while the adapter forwards the raw argument, so a read of '@../.gcp-oidc-token' resolved under the checkout in the hook and the token came back unmasked. Those forms now never skip, for every file-content tool; any URL scheme is refused, broader than pi's rewrite on purpose. Tests pin a planted .git at and above the workspace (the at-workspace case direct and through a symlinked cwd), a checkout outside the workspace, the rewritten path forms for Read and Grep, a symlinked workspace path, the boundary ignoring the environment (standalone and through the chain) and honouring only an absolute command-line value (standalone), and cwd on every pi PostToolUse payload across events; the contract doc and the adapter header say the same. Raised by Qodo's review of fullsend-ai#7009. Signed-off-by: Dharit Shah <dhshah@redhat.com>
…d forward cwd from codex Two findings from the automated review sweep on fullsend-ai#7009. The hook hardcodes the checkout boundary as a Python literal that must match internal/sandbox.SandboxWorkspace; nothing tied the two, so a rename or move of the Go constant would have left the checkout-scoped bare-JWT skip mis-scoped with no CI failure. hooks_test.go now asserts the embedded source assigns the constant's value. The codex adapter built its hook payloads without cwd although codex's own hook input carries it. It now forwards it on both phases, as Claude Code's hooks and the pi adapter do, so the wire shape matches the contract; a test records the payload each phase delivers and that a non-string value is dropped. Under codex that scopes nothing today: apply_patch reaches the hook as Edit with the patch text as its input and no file path for the skip to key on, codex reads are shell output, which is never skipped, and codex drops the chain's rewrite anyway, so no fixture was being rewritten. The sweep's corruption premise does not hold on codex; the parity hole in the contract doc did, and is closed. The hook's module comment, the wire-protocol row and the PR body say the same. Signed-off-by: Dharit Shah <dhshah@redhat.com>
| ("sendgrid_key", re.compile(r"SG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}")), | ||
| ("gitlab_pat", re.compile(r"gl(?:pat|rt|ptt|dt|ft|soat|cs)-[A-Za-z0-9_-]{20,}")), | ||
| ("google_oauth_token", re.compile(r"ya29\.[A-Za-z0-9_-]{30,}")), | ||
| # Mirrors the Go redactor: the literal c. alternative covers |
There was a problem hiding this comment.
MEDIUM — "Mirrors the Go redactor" comment is false: the Go redactor has no jwt or ya29 pattern at all
This PR adds inline comments at lines 49-51 ("Mirrors the Go redactor: the literal c. alternative covers service-account tokens...") and 53-55 for the new/changed google_oauth_token (ya29) and jwt prefix patterns, claiming parity with the Go redactor.
Verified against internal/security/redactor.go's defaultPrefixPatterns() (untouched by this PR — confirmed no redactor.go hunk in the PR diff): it has no ya29/google_oauth_token pattern and no jwt pattern whatsoever. Only github_server_token (the pattern the earlier, accurate "mirrors" comment at line 32-35 refers to) exists on both sides.
Because codex-produced artifacts (rollouts, output.jsonl) are protected only by the Go redactor — this PR's own new comment notes "codex reads are shell output, which is never skipped, and codex drops the chain's rewrite anyway" — a live bare JWT or ya29.c.-prefixed OAuth token can land unredacted in codex artifacts even after this PR ships, contrary to what the comment implies.
Suggestion: either correct the comment wording so it no longer claims Go-redactor parity for the jwt/google_oauth_token patterns (e.g. note it mirrors #6603's proposed patterns, or that Go coverage is a follow-up), or add matching jwt and ya29 (ya29.c.) prefix patterns to defaultPrefixPatterns() in redactor.go so the claim is true and codex-produced artifacts get the same coverage.
…end cwd from pi Two review findings on the checkout-scoped bare-JWT skip, and a third gap the second fix exposed. The checkout root was the nearest .git ancestor of the resolved cwd, wherever that walk led: a .git an agent plants at /sandbox/workspace, reached through a symlink inside the checkout, made the runner's sibling OIDC token file 'inside' and skipped the mask. The root is now searched only strictly below SANDBOX_WORKSPACE, so a .git at or above the workspace, or a cwd outside it, yields no root and no skip. The boundary is the runner's constant; the hook takes an override only from its own command line, the seam the subprocess tests use, which the runner-owned hooks.json and the pi adapter build without it. It is not read from environment variables: under fullsend's plain launch, Claude Code (observed locally on 2.1.235; the sandbox image pins 2.1.260) applies a checkout's .claude/settings.json env block to hook processes over the launch environment, so an env-carried boundary could be moved by a committed settings file, and the sandbox .env every launch command sources lives in the sandbox user's workspace. Not covered, and pre-existing: under claude and pi the hook interpreter is a bare python3 resolved through a PATH with the agent-writable workspace bin first, with PYTHONPATH and the user site directory honoured (codex isolates its adapter); left for a follow-up. Residual, stated in the hook: a .git planted in another runner-owned workspace subdirectory (.env.d) can still be a root through a plain cd or a symlink, for a bare JWT no structural shape covers, and only for files under that subdirectory. pi's adapter sent no cwd, so under pi every file-content Read got no skip and the newly mirrored JWT pattern rewrote checkout fixtures — the edit-corruption the skip exists to prevent, and not prior behaviour, since the pattern is new. The adapter now sends its process working directory: pi is started in the checkout and its tools run in child shells, so the agent cannot move it. codex still sends no cwd; it cannot apply the rewrite at all, so nothing is corrupted there, only warned about. Sending cwd exposed a gap of its own under pi: pi strips a leading '@', expands '~' and turns a file:// URL into a path before opening, while the adapter forwards the raw argument, so a read of '@../.gcp-oidc-token' resolved under the checkout in the hook and the token came back unmasked. Those forms now never skip, for every file-content tool; any URL scheme is refused, broader than pi's rewrite on purpose. Tests pin a planted .git at and above the workspace (the at-workspace case direct and through a symlinked cwd), a checkout outside the workspace, the rewritten path forms for Read and Grep, a symlinked workspace path, the boundary ignoring the environment (standalone and through the chain) and honouring only an absolute command-line value (standalone), and cwd on every pi PostToolUse payload across events; the contract doc and the adapter header say the same. Raised by Qodo's review of fullsend-ai#7009. Signed-off-by: Dharit Shah <dhshah@redhat.com>
…d forward cwd from codex Two findings from the automated review sweep on fullsend-ai#7009. The hook hardcodes the checkout boundary as a Python literal that must match internal/sandbox.SandboxWorkspace; nothing tied the two, so a rename or move of the Go constant would have left the checkout-scoped bare-JWT skip mis-scoped with no CI failure. hooks_test.go now asserts the embedded source assigns the constant's value. The codex adapter built its hook payloads without cwd although codex's own hook input carries it. It now forwards it on both phases, as Claude Code's hooks and the pi adapter do, so the wire shape matches the contract; a test records the payload each phase delivers and that a non-string value is dropped. Under codex that scopes nothing today: apply_patch reaches the hook as Edit with the patch text as its input and no file path for the skip to key on, codex reads are shell output, which is never skipped, and codex drops the chain's rewrite anyway, so no fixture was being rewritten. The sweep's corruption premise does not hold on codex; the parity hole in the contract doc did, and is closed. The hook's module comment, the wire-protocol row and the PR body say the same. Signed-off-by: Dharit Shah <dhshah@redhat.com>
The hook's comments on the google_oauth_token and jwt prefix patterns claimed they mirror the Go redactor; main's redactor has neither shape. Both are added on the Go side by fullsend-ai#6603, which this PR was split from, so the comments now say that. The github_server_token comment stays: that pattern is on both sides today. Raised in review of fullsend-ai#7009. Signed-off-by: Dharit Shah <dhshah@redhat.com>
9bac703 to
11cb6b7
Compare
The Go redactor and the sandbox hook guard different boundaries — span content and the model's context window — but had drifted: the hook's ya29 pattern missed service-account tokens (one-char c segment defeats the length floor) and it had no bare-JWT shape at all, while its combined gh*_ pattern stopped at the first dot of the 2026 JWT-wrapped installation-token format, leaving payload and signature in the clear. Mirror the three patterns from the Go side (google_oauth_token with the c. alternative, jwt, github_server_token with dots in the class) so both inventories carry the same shapes. Raised by waynesun09's review; the ghs_ gap surfaced during pre-push verification of the mirror. Signed-off-by: Dharit Shah <dhshah@redhat.com>
The PostToolUse chain runs on every tool, and the bare-JWT prefix pattern a3fd5c0 mirrored into it has no fixture-shaped escape: a jwt.io example in a test file is byte-for-byte a valid token, so a Read of one came back as `eyJh...` and the agent edited against text that is not on disk — the failure mode 63a0a7d scoped the structural patterns to avoid. content_skips(hook_input) returns the pattern names redact_text skips: `jwt` when a file-content tool (Read, Grep, Edit, MultiEdit, Write, NotebookEdit, NotebookRead) is called with a path inside the checkout — the nearest `.git` ancestor of the hook input's `cwd`, which follows the agent's persisted `cd`; none means no skip. The path is normalized, then resolved. `..` segments, `~` paths, a non-string tool_name, a missing tool_input or no `cwd` (pi's adapter sends none) mean mask, not skip, so the runner's OIDC token file beside the checkout still masks on Read and Grep, and Bash, WebFetch and MCP output are unchanged. The chain passes the set on the success and failure paths; the standalone script does the same. Same sweep as 63a0a7d, here through redact_text: golang-jwt/jwt as Read goes from 15 rewritten files (48 bare-JWT hits in 9) to 8, all structural or private-key; this repo and fullsend-ai/agents have no tracked file matching the pattern (0/1154, 0/343). Raised by waynesun09's review. Signed-off-by: Dharit Shah <dhshah@redhat.com>
…end cwd from pi Two review findings on the checkout-scoped bare-JWT skip, and a third gap the second fix exposed. The checkout root was the nearest .git ancestor of the resolved cwd, wherever that walk led: a .git an agent plants at /sandbox/workspace, reached through a symlink inside the checkout, made the runner's sibling OIDC token file 'inside' and skipped the mask. The root is now searched only strictly below SANDBOX_WORKSPACE, so a .git at or above the workspace, or a cwd outside it, yields no root and no skip. The boundary is the runner's constant; the hook takes an override only from its own command line, the seam the subprocess tests use, which the runner-owned hooks.json and the pi adapter build without it. It is not read from environment variables: under fullsend's plain launch, Claude Code (observed locally on 2.1.235; the sandbox image pins 2.1.260) applies a checkout's .claude/settings.json env block to hook processes over the launch environment, so an env-carried boundary could be moved by a committed settings file, and the sandbox .env every launch command sources lives in the sandbox user's workspace. Not covered, and pre-existing: under claude and pi the hook interpreter is a bare python3 resolved through a PATH with the agent-writable workspace bin first, with PYTHONPATH and the user site directory honoured (codex isolates its adapter); left for a follow-up. Residual, stated in the hook: a .git planted in another runner-owned workspace subdirectory (.env.d) can still be a root through a plain cd or a symlink, for a bare JWT no structural shape covers, and only for files under that subdirectory. pi's adapter sent no cwd, so under pi every file-content Read got no skip and the newly mirrored JWT pattern rewrote checkout fixtures — the edit-corruption the skip exists to prevent, and not prior behaviour, since the pattern is new. The adapter now sends its process working directory: pi is started in the checkout and its tools run in child shells, so the agent cannot move it. codex still sends no cwd; it cannot apply the rewrite at all, so nothing is corrupted there, only warned about. Sending cwd exposed a gap of its own under pi: pi strips a leading '@', expands '~' and turns a file:// URL into a path before opening, while the adapter forwards the raw argument, so a read of '@../.gcp-oidc-token' resolved under the checkout in the hook and the token came back unmasked. Those forms now never skip, for every file-content tool; any URL scheme is refused, broader than pi's rewrite on purpose. Tests pin a planted .git at and above the workspace (the at-workspace case direct and through a symlinked cwd), a checkout outside the workspace, the rewritten path forms for Read and Grep, a symlinked workspace path, the boundary ignoring the environment (standalone and through the chain) and honouring only an absolute command-line value (standalone), and cwd on every pi PostToolUse payload across events; the contract doc and the adapter header say the same. Raised by Qodo's review of fullsend-ai#7009. Signed-off-by: Dharit Shah <dhshah@redhat.com>
…d forward cwd from codex Two findings from the automated review sweep on fullsend-ai#7009. The hook hardcodes the checkout boundary as a Python literal that must match internal/sandbox.SandboxWorkspace; nothing tied the two, so a rename or move of the Go constant would have left the checkout-scoped bare-JWT skip mis-scoped with no CI failure. hooks_test.go now asserts the embedded source assigns the constant's value. The codex adapter built its hook payloads without cwd although codex's own hook input carries it. It now forwards it on both phases, as Claude Code's hooks and the pi adapter do, so the wire shape matches the contract; a test records the payload each phase delivers and that a non-string value is dropped. Under codex that scopes nothing today: apply_patch reaches the hook as Edit with the patch text as its input and no file path for the skip to key on, codex reads are shell output, which is never skipped, and codex drops the chain's rewrite anyway, so no fixture was being rewritten. The sweep's corruption premise does not hold on codex; the parity hole in the contract doc did, and is closed. The hook's module comment, the wire-protocol row and the PR body say the same. Signed-off-by: Dharit Shah <dhshah@redhat.com>
The hook's comments on the google_oauth_token and jwt prefix patterns claimed they mirror the Go redactor; main's redactor has neither shape. Both are added on the Go side by fullsend-ai#6603, which this PR was split from, so the comments now say that. The github_server_token comment stays: that pattern is on both sides today. Raised in review of fullsend-ai#7009. Signed-off-by: Dharit Shah <dhshah@redhat.com>
11cb6b7 to
27446ee
Compare
waynesun09
left a comment
There was a problem hiding this comment.
Automated review sweep — 1 finding could not be attached inline because the file it concerns is outside this PR's diff.
MEDIUM — New security-hook Python test suites (435 added lines) run in no CI job
Makefile:203 (the script-test target)
Confirmed by inspection at this PR head. The PR adds 435 lines to internal/security/hooks/secret_redact_posttool_test.py (62 test functions total) and 108 lines to internal/security/hooks/posttool_chain_test.py — including the checkout-scope-boundary regression tests that are the entire correctness argument for this change — but nothing executes them.
- Makefile
script-test(lines 187-204) enumerates each suite it runs by name. Its only pytest invocation ispython3 -m pytest gitlint_rules_test.py -v(line 203), a single root-level file. Line 204 runsnode --test internal/runtime/pi_extension/*.test.mjs, so the pi adapter tests this PR touches DO run — the Python hooks suites are the gap. .github/workflows/lint.ymlis the only workflow that installs or invokes pytest; its job runsmake lint-all,go test, andmake script-test(line 58). None reachesinternal/security/hooks/.- No Go test shells out to the Python suites:
grep -nE 'exec.Command|python|pytest|_test\.py' internal/security/hooks_test.goreturns nothing, and the onlypython3reference ininternal/security/*.goishooks.go:197building the sandbox hook command string. internal/security/hooks/**appears in.github/workflows/e2e.yml(lines 23, 140, 240) only as a path filter that triggers e2e/behaviour jobs — not as a place these unit tests execute.
The Makefile is untouched by this PR (confirmed: empty diff), so the wiring gap is pre-existing — but this PR is what makes it consequential, since it lands a security boundary whose only verification is a suite CI never runs.
Suggestion: Add $(call run-timed,python3 -m pytest internal/security/hooks/ -v) to the script-test target in the Makefile, next to the existing gitlint pytest line. make script-test is already invoked by .github/workflows/lint.yml:58 and pytest is already installed there (lint.yml:31), so no workflow change is needed — the new checkout-boundary regression tests then gate every PR.
| path = cwd | ||
| if not isinstance(path, str) or not path: | ||
| return frozenset() | ||
| if path.startswith("~") or _REWRITTEN_PATH.match(path) or ".." in path.split("/"): |
There was a problem hiding this comment.
HIGH — pi's Unicode-space path normalization defeats the checkout skip's symlink coverage — a live OIDC JWT reaches the model unmasked
Confirmed by an end-to-end repro against the pinned pi 0.85.0 (images/sandbox/Containerfile:101, ARG PI_VERSION=0.85.0), not asserted. The in-diff comment (lines 96-99 and 115-118) and the docs paragraph (docs/contributing/runtime-implementation.md:264) present pi's pre-open rewrites as a complete list — strip a leading @, expand ~, convert file:// — and line 207 refuses exactly those three. pi 0.85.0 performs a fourth rewrite the hook does not know about.
Primary source, extracted from the published tarball @earendil-works/pi-coding-agent@0.85.0:
dist/utils/paths.js: aUNICODE_SPACEScharacter class (NBSP, the U+2000-range spaces, ideographic space) and, insidenormalizePath,if (options.normalizeUnicodeSpaces) normalized = normalized.replace(UNICODE_SPACES, " ");dist/core/tools/path-utils.js:resolveToCwd(filePath, cwd)callsresolvePath(filePath, cwd, { normalizeUnicodeSpaces: true, stripAtPrefix: true }), so the option is on for every caller.dist/core/tools/read.js:56usesresolveReadPathAsync(which wrapsresolveToCwd);dist/core/tools/grep.js:57usesresolveToCwddirectly. Both file-content tools the skip keys on go through it.
(The replacement is option-gated rather than unconditional, but read and grep both enable it, so the effect is identical.)
The pi adapter forwards the RAW argument: claudeToolInput (internal/runtime/pi_extension/fullsend-hooks.js:95-116) copies src.path verbatim into file_path, and the hook never normalizes it. So the hook resolves a different filesystem entry than pi opens.
Repro executed against this PR head's hook, with a checkout root holding .git, a symlink a b (ASCII space) pointing out of the checkout at the workspace, and the runner's OIDC token file beside the checkout:
- Read
a b/.gcp-oidc-token(ASCII space) → hook MASKS. This is the symlink-out-of-checkout case the PR tests and claims to cover. - Read
a b/.gcp-oidc-token(NBSP) → hook emits NO output, i.e. content unchanged, i.e. the token passes UNMASKED. Verified codepoints0x61, 0xa0, 0x62. - pi side, run through the pinned package's own
normalizePathwith the exact optionsresolveToCwdpasses: raw codepoints[61, a0, 62]→ normalized[61, 20, 62]→path.resolve→fs.realpathSyncreturns the token OUTSIDE the checkout, andreadFileSyncreturns the JWT.
Mechanism: os.path.realpath on the NBSP path finds no such entry, so it returns the path verbatim, still lexically inside the checkout root → inside is true → the jwt skip is granted. The agent does not even need to create the NBSP twin. This is precisely the file the comment says "must still mask".
A second, independent divergence sits in the same place: resolveReadPath / resolveReadPathAsync (dist/core/tools/path-utils.js) fall back to NFD, curly-quote (U+2019) and narrow-NBSP-before-AM/PM variants when the resolved path does not exist. On Linux, filenames are byte strings, so an NFD-named symlink pointing out of the checkout is reachable by passing the NFC name — same bypass shape, no Unicode space involved.
Suggestion: Two halves are needed — neither closes it alone.
(a) At line 207, alongside the ~ / scheme / .. refusals, refuse the skip for any path containing a code point in pi's UNICODE_SPACES class (or, more broadly, any non-ASCII whitespace). Without this, an agent who also plants a real NBSP-named directory would defeat an exists() check alone.
(b) Require os.path.exists(target) before granting the skip — the hook runs after the tool, so even a Write target exists by then. This is what closes the resolveReadPath not-found fallbacks (NFD, curly quote, narrow NBSP before AM/PM), which (a) does not touch.
Then correct both in-diff comments (lines 96-99 and 115-118) and docs/contributing/runtime-implementation.md:264, which currently name the @ / ~ / file:// list as complete, and add the named regression test: an NBSP-containing path whose ASCII-space twin is a symlink out of the checkout must NOT skip (its ASCII twin already masks, so the test pins the gap rather than the existing behaviour).
There was a problem hiding this comment.
Fixed in e7bf0b0. content_skips refuses any non-ASCII whitespace and grants the skip only when the resolved target exists, which also covers pi's NFD and curly-quote retries on a missing read; tests cover all 15 code points of pi 0.85.0's class with a real twin on disk, and a missing target. f9bd278 pins the '@', URL and '~' refusals the same way.
| # the pi adapter build without it. It is not read from environment variables: | ||
| # Claude Code applies a checkout's .claude/settings.json env block to hook | ||
| # processes over the launch environment (observed locally on 2.1.235; the | ||
| # sandbox image pins 2.1.260), and the sandbox .env every launch command |
There was a problem hiding this comment.
MEDIUM — In-diff comment states the sandbox image pins Claude Code 2.1.260; it pins 2.1.263 at every commit of this PR
The comment added by this PR reads "(observed locally on 2.1.235; the sandbox image pins 2.1.260)" — verified as an added line in the diff (+# processes over the launch environment (observed locally on 2.1.235; the / +# sandbox image pins 2.1.260), and the sandbox .env every launch command).
images/sandbox/Containerfile:59 has ARG CLAUDE_CODE_VERSION=2.1.263 both at the merge-base with origin/main and at this PR head 27446ee45 — checked both explicitly. The literal was never accurate on this branch.
The version is load-bearing rather than decorative: it is the bridge from a local observation to deployed behaviour ("observed locally on 2.1.235; the image pins X"), and that bridge is the stated reason the boundary override is argv-only instead of environment-driven — the security-relevant design decision at SANDBOX_WORKSPACE. A wrong literal weakens the rationale it exists to carry, and will go stale again on the next Renovate bump of that ARG.
Suggestion: Drop the version literal and point at the source of truth instead — e.g. "(observed locally on 2.1.235; the sandbox image pins the version in images/sandbox/Containerfile's CLAUDE_CODE_VERSION ARG)" — so Renovate bumping that ARG cannot leave the comment wrong again. If a concrete number is wanted for the record, it is 2.1.263.
There was a problem hiding this comment.
Fixed in e7bf0b0. The comment points at CLAUDE_CODE_VERSION in images/sandbox/Containerfile instead of a literal.
… run the hook suites in CI Three findings from review of fullsend-ai#7009. pi rewrites more than the hook knew. Besides stripping a leading '@', expanding '~' and converting file://, every pi 0.85.0 file tool replaces the Unicode spaces U+00A0, U+2000-U+200A, U+202F, U+205F and U+3000 with an ASCII space before opening (dist/utils/paths.js via resolveToCwd), and a pi Read of a missing name retries NFD, curly-quote and narrow-NBSP variants (dist/core/tools/path-utils.js). The adapter forwards the raw argument, so a Read of "a<NBSP>b/.gcp-oidc-token" opened the ASCII-space entry — a symlink out of the checkout — while the hook resolved a name that did not exist, still lexically inside the root, and granted the skip: the runner's token reached the model unmasked. content_skips now refuses any path with non-ASCII whitespace and grants the skip only when the resolved target exists; the hook runs after the tool, so the file pi opened exists by then, and a missing target means a Read may have opened a variant of the name. The comments and the contract doc name pi's rewrites as what 0.85.0 does and say what an unnamed rewrite would need to get through. The boundary comment cited a sandbox Claude Code version that was wrong for this branch; it now points at CLAUDE_CODE_VERSION in the Containerfile instead of a literal. Nothing ran the hook suites in CI: `make script-test` enumerates its suites by name and never reached internal/security/hooks/. It now runs them; lint.yml already installs pytest and invokes script-test. Verified on 3.12 and 3.14. Tests: for each of the 15 code points in pi's class, a twin that exists inside the checkout beside an out-of-checkout ASCII-space symlink never skips; a plain non-ASCII fixture name still skips; a missing target never skips while the real fixture still does; the two chain tests that read a fixture create it. Raised in review of fullsend-ai#7009. Signed-off-by: Dharit Shah <dhshah@redhat.com>
… again From a review-squad pass over the branch. The google_oauth_token pattern special-cased ya29.c. because a one-char first segment defeats the length floor; Google's workforce STS response carries ya29.dr., which defeated it the same way. The optional segment is now any one or two lowercase letters, matching the same change on the Go side in fullsend-ai#6603; tests cover ya29.dr. and ya29.d. Since the resolved-target check landed, the tests for the '@', URL and '~' refusals passed for the wrong reason: their fixtures named entries that did not exist, so the target check refused first and the refusals were unpinned (two mutants survived). The raw twins now exist on disk inside the checkout, so only the refusal decides. The environment tests also pin the module sources: the hook's only environ read is the trace id and the chain's are the trace id and the canary token, so a boundary read under any name fails them, not only one guessed variable. The contract doc and the PR body now state the class the skip does not guard against — a file copied, hard-linked, moved or raced into the checkout — and the codex docstring says pi sends cwd on PostToolUse only. Signed-off-by: Dharit Shah <dhshah@redhat.com>
…d tighten the span docs From a review-squad pass over the branch. The google_oauth_token pattern special-cased ya29.c. because a one-char first segment defeats the length floor; Google's workforce STS response carries ya29.dr., which defeated it the same way. The optional segment is now any one or two lowercase letters, and the same change is made to fullsend-ai#7009's hook pattern. Two parser edges gain tests: undecodable tool_result content reports Partial (the branch was unpinned), and a server_tool_use block on an assistant line — the path fullsend actually runs, since the launch never passes --include-partial-messages — produces no event of any kind while the tool_use beside it keeps its id. Comments say why the stream_event tool-id slot is single-slot and that the flat user-line shape is a defensive fallback rather than an observed version. Docs: fullsend.content.truncated also fires for a kept parser-side fragment; error.type=unanswered has a third cause (a call superseded by a second tool_use with the same id, ended at the reuse); server_tool_use blocks produce no event; the span cap assumes the default OTEL_BSP_MAX_QUEUE_SIZE and the unsourced call-count figure is replaced by the evidence run's; ADR 0108 says which pi lines the parser reads and records upstream's display-only caveat on the open timestamp item. Signed-off-by: Dharit Shah <dhshah@redhat.com>
|
Fixed in e7bf0b0 for the review-body finding (the hook suites ran in no CI job): |
Split out of #6603 at review request (waynesun09): this changes what the agent's PostToolUse hook does on every file-content tool call, which is a sandbox-side hardening change rather than a telemetry one, and it deserves its own review of the parity argument.
What this does
internal/security/hooks/secret_redact_posttool.py)google_oauth_token(ya29.with an optional one- or two-letter type segment:c.service-account tokens and STS-minted ones such as thedr.Google's workforce doc shows) and a bare three-segmentjwt— plusgithub_server_token(ghs_…with dots, ahead of the combinedgh*_pattern, so a JWT-wrapped installation token masks whole instead of stopping at the first dot). Both boundaries — span content and the model's context window — carry the same shapes.content_skips)Readcorrupts what the agent edits against (the failure mode 63a0a7d scoped the structural patterns to avoid). So thejwtpattern is skipped when a file-content tool (Read, Grep, Edit, MultiEdit, Write, NotebookEdit, NotebookRead) is called with a path inside the checkout — the nearest.gitancestor of the hook'scwd, searched only strictly below the sandbox workspace so a.gitan agent plants at or above it can never widen the root to the runner's files; none meaning no skip; the path is normalized, then resolved;..segments,~paths,@-prefixed paths and URL forms, paths with non-ASCII whitespace, paths whose resolved target does not exist (every pi file tool strips the@, expands~, convertsfile://and replaces Unicode spaces with an ASCII space before opening, and a pi Read of a missing name retries NFD, curly-quote and narrow-NBSP variants, while its adapter forwards the raw argument), malformed input and nocwdall mean mask. Claude Code sends its working directory; the pi adapter sends its process working directory — the checkout, which child shells cannot move; the codex adapter forwards thecwdof codex's own hook input, which scopes nothing there today (codex'sapply_patchinput carries no file path and its reads are shell output). The runner's own OIDC token file sits beside the checkout, not in it, and still masks on Read or Grep. Bash, WebFetch and MCP output are unaffected.internal/security/hooks/posttool_chain.py, tests)/sandbox/workspace. The hook takes an override only from its own command line (--sandbox-workspace=PATH, the seam the subprocess tests use), which the runner-ownedhooks.jsonand the pi adapter build without it. It is not read from environment variables: under fullsend's plain launch, Claude Code (observed locally on 2.1.235; the sandbox image pinsCLAUDE_CODE_VERSIONinimages/sandbox/Containerfile) applies a checkout's.claude/settings.jsonenv block to hook processes over the launch environment, so an env-carried boundary could be moved by a committed settings file. Not covered, and pre-existing: under claude and pi the hook interpreter is a barepython3resolved through a PATH with the agent-writable workspacebinfirst, withPYTHONPATHand the user site directory honoured (codex isolates its adapter); drafted as a follow-up.docs/contributing/runtime-implementation.md: the wire protocol gainscwd(Claude Code's working directory; pi's process working directory), the sanitizer-scope paragraph states the root is searched strictly below/sandbox/workspaceand how the boundary is pinned, and the adapter-contract line says pi sendscwd; the pi adapter's header comment says the same.Known residual: the boundary is the workspace, not the checkout, so a
.gitplanted in another runner-owned subdirectory (.env.d, which holdshost_filescredentials) can still become a root through a plaincdinto it or an in-checkout symlink; only a bare JWT that no structural shape covers would skip there. Pinning the checkout path the runner already knows would remove the.gitwalk entirely. Separately, the skip guards against accidental exposure of the runner's files, not a steered agent: one that copies, hard-links or moves such a file into the checkout, or races a symlink between the tool's open and the hook's check, defeats it, as a Bash transform of the token has always evaded the mask; that class is out of this hook's scope.Measured
redact_text): golang-jwt/jwt asReadgoes from 15 rewritten files (48 bare-JWT hits in 9) to 8, all structural or private-key — the class that sweep accepted; this repo and fullsend-ai/agents have no tracked file matching the jwt pattern (0/1154, 0/343). Structural masking of token-named assignments is unchanged; the skip covers the context-free pattern only.Tests
TDD: 29 new hook tests (chain-level Read-untouched / Bash-masked / failure-path flagged; checkout vs outside — token file,
.., symlink, symlink then.., Grep root,cdbelow the root through a symlink, a.git-less directory; a.gitplanted at the workspace reached through a symlinkedcdor directly, a.gitplanted above the workspace, a checkout outside the workspace; the rewritten path forms@…andfile://…for Read and Grep; a symlinked workspace path; malformed input; the skip is jwt-only), a node test that every pi PostToolUse payload carriescwd, across events on one instance, and the boundary ignoring the environment (standalone and through the chain) and honouring only an absolute command-line value (standalone). The@, URL and~refusals are pinned with the raw twin present on disk, so only the refusal decides; the environment tests also pin the source of the boundary functions against anyenviron/getenvread;ya29.dr.andya29.d.mask. Every policy branch killed by a named mutant. Full hook suite green.Raised in review of #6603 (rounds 4 and 6 by waynesun09); #6603 keeps the Go redactor patterns, which its span content needs.