Skip to content

fix(security): mirror credential shapes into the PostToolUse hook, scoped to checkout content - #7009

Open
dhshah13 wants to merge 7 commits into
fullsend-ai:mainfrom
dhshah13:fix/posttool-hook-credential-shapes
Open

fix(security): mirror credential shapes into the PostToolUse hook, scoped to checkout content#7009
dhshah13 wants to merge 7 commits into
fullsend-ai:mainfrom
dhshah13:fix/posttool-hook-credential-shapes

Conversation

@dhshah13

@dhshah13 dhshah13 commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

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

Part Change
Hook patterns (internal/security/hooks/secret_redact_posttool.py) Mirrors the two credential shapes the Go redactor gained in #6603 into the PostToolUse hook — google_oauth_token (ya29. with an optional one- or two-letter type segment: c. service-account tokens and STS-minted ones such as the dr. Google's workforce doc shows) and a bare three-segment jwt — plus github_server_token (ghs_… with dots, ahead of the combined gh*_ 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.
Checkout-scoped skip (content_skips) A bare JWT has no fixture-shaped escape: a jwt.io-style example in a test file is byte-for-byte a valid token, and rewriting it in a Read corrupts what the agent edits against (the failure mode 63a0a7d scoped the structural patterns to avoid). So the jwt pattern is skipped 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's cwd, searched only strictly below the sandbox workspace so a .git an 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 ~, converts file:// 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 no cwd all 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 the cwd of codex's own hook input, which scopes nothing there today (codex's apply_patch input 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.
Boundary source (internal/security/hooks/posttool_chain.py, tests) The boundary is the runner's constant /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-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 CLAUDE_CODE_VERSION in images/sandbox/Containerfile) 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. 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); drafted as a follow-up.
Contract docs docs/contributing/runtime-implementation.md: the wire protocol gains cwd (Claude Code's working directory; pi's process working directory), the sanitizer-scope paragraph states the root is searched strictly below /sandbox/workspace and how the boundary is pinned, and the adapter-contract line says pi sends cwd; the pi adapter's header comment says the same.

Known residual: the boundary is the workspace, not the checkout, so a .git planted in another runner-owned subdirectory (.env.d, which holds host_files credentials) can still become a root through a plain cd into 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 .git walk 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

  • Redactor false positives (forge comments): 2.1 MB of this repo's recent review and issue comment bodies, zero hits for both patterns.
  • Hook, measured the way 63a0a7d did (files 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 — 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, cd below the root through a symlink, a .git-less directory; a .git planted at the workspace reached through a symlinked cd or directly, a .git planted above the workspace, a checkout outside the workspace; the rewritten path forms @… and file://… for Read and Grep; a symlinked workspace path; malformed input; the skip is jwt-only), a node test that every pi PostToolUse payload carries cwd, 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 any environ/getenv read; ya29.dr. and ya29.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.

@dhshah13
dhshah13 requested a review from a team as a code owner September 4, 2026 14:33
@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown

E2E tests did not run

E2E tests run automatically for org/repo members and collaborators on pull requests.

For other contributors, a maintainer must add the ok-to-test label after the latest push.

See E2E testing guide for details.

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Harden PostToolUse redaction with checkout-aware JWT handling

🐞 Bug fix 🧪 Tests 📝 Documentation 🕐 40+ Minutes

Grey Divider

AI Description

• Adds Google OAuth, bare JWT, and complete GitHub installation-token redaction.
• Preserves checkout JWT fixtures while masking tokens from external files and non-file tools.
• Documents cwd scoping and verifies path, symlink, malformed-input, and chain behavior.
Diagram

graph TD
  A["Hook Input"] --> B["PostTool Chain"] --> C["Scope Resolver"] --> D{"Checkout content?"}
  D -- "Yes" --> E["Skip bare JWT"] --> G["Sanitized Output"]
  D -- "No" --> F["Mask all shapes"] --> G
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Pass an explicit checkout root
  • ➕ Avoids discovering repository boundaries through filesystem traversal
  • ➕ Makes checkout provenance an explicit runtime contract
  • ➖ Requires coordinated changes across Claude Code and pi adapters
  • ➖ Expands the trusted protocol surface beyond this focused fix
2. Disable bare-JWT matching for all file tools
  • ➕ Simpler policy with fewer path-resolution edge cases
  • ➕ Always preserves JWT fixtures returned by file tools
  • ➖ Could expose live runner tokens read from files outside the checkout
  • ➖ Loses the fail-safe masking behavior for malformed or missing metadata

Recommendation: Keep the checkout-aware approach: it preserves repository fixtures without weakening masking for runner token files, shell output, or ambiguous inputs. Explicit checkout metadata would be cleaner long-term, but only after every runtime adapter can provide a trusted, consistent value.

Files changed (6) +483 / -16

Bug fix (2) +124 / -13
posttool_chain.pyApply content-specific skips across chain redaction paths +6/-4

Apply content-specific skips across chain redaction paths

• Computes redaction skips from each hook input and applies them to normal, normalized, and failed-call scans. This keeps success and failure behavior consistent for checkout file content.

internal/security/hooks/posttool_chain.py

secret_redact_posttool.pyAdd credential shapes and checkout-aware JWT scoping +118/-9

Add credential shapes and checkout-aware JWT scoping

• Adds service-account Google OAuth, bare JWT, and complete GitHub server-token patterns. Introduces fail-safe checkout detection using tool-specific paths, cwd, normalization, realpath resolution, and nearest '.git' ancestry, then allows named patterns to be skipped during redaction.

internal/security/hooks/secret_redact_posttool.py

Tests (2) +353 / -1
posttool_chain_test.pyVerify checkout JWT behavior through the complete hook chain +68/-1

Verify checkout JWT behavior through the complete hook chain

• Adds chain-level coverage proving checkout JWT fixtures remain unchanged while external token files and Bash failures remain protected. Extends helpers to provide 'cwd' and tolerate the redactor's new keyword argument.

internal/security/hooks/posttool_chain_test.py

secret_redact_posttool_test.pyCover credential parity and JWT scope policy +285/-0

Cover credential parity and JWT scope policy

• Tests the new credential shapes and the complete checkout-scoping policy across tools, relative paths, traversal, symlinks, submodules, missing repositories, malformed input, and JSON-encoded tool input. Confirms only context-free JWT matching is skipped while structural and other credential patterns remain active.

internal/security/hooks/secret_redact_posttool_test.py

Documentation (2) +6 / -2
runtime-implementation.mdDocument cwd-aware PostToolUse sanitizer scope +4/-2

Document cwd-aware PostToolUse sanitizer scope

• Documents the PostToolUse 'cwd' field and the checkout-scoped bare-JWT exception. Clarifies that pi omits 'cwd', so its hook continues masking JWTs without the exception.

docs/contributing/runtime-implementation.md

fullsend-hooks.jsClarify pi adapter JWT-scoping behavior +2/-0

Clarify pi adapter JWT-scoping behavior

• Adds contract commentary explaining that pi does not send 'cwd', leaving checkout-scoped JWT skipping inactive.

internal/runtime/pi_extension/fullsend-hooks.js

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown

Site preview

Preview: https://55d8235f-site.fullsend-ai.workers.dev

Commit: 11cb6b782b84303f62176d1a69e79bdae283f17d

@qodo-code-review

qodo-code-review Bot commented Sep 4, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Forged checkout bypasses JWT masking ✓ Resolved 🐞 Bug ⛨ Security
Description
_checkout_root treats any lexisting .git entry as a trusted checkout boundary, so an agent can
create /sandbox/workspace/.git, persistently cd to that directory, then Read the sibling runner
OIDC token. The token path is then considered inside the forged root and the new JWT skip exposes
the unmasked credential to the model.
Code

internal/security/hooks/secret_redact_posttool.py[R115-116]

+        if os.path.lexists(os.path.join(probe, ".git")):
+            return probe
Relevance

●●● Strong

Security fail-open findings receive acceptance; forged trust boundaries closely match prior accepted
bypass hardening.

PR-#239
PR-#6536

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The new implementation returns the JWT skip whenever the target is within the nearest ancestor
containing any .git entry. The PR documentation explicitly establishes both that cwd tracks
persisted agent directory changes and that the runner token is a sibling of the real checkout, which
makes a forged parent boundary include that token.

internal/security/hooks/secret_redact_posttool.py[80-96]
internal/security/hooks/secret_redact_posttool.py[110-161]
docs/contributing/runtime-implementation.md[244-244]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Do not derive the JWT-redaction exception from an agent-writable `.git` sentinel. A created `.git` directory above the actual checkout expands the trusted root to include the runner token file, so a subsequent file-content Read bypasses the bare-JWT mask.

## Issue Context
The hook documentation states that `cwd` follows the agent's persisted `cd`, and that the runner OIDC token is beside the checkout. The current implementation accepts any `.git` filesystem entry as the checkout root; an agent can create one in the workspace parent and change into it before reading the token.

## Fix Focus Areas
- internal/security/hooks/secret_redact_posttool.py[110-161]
- internal/security/hooks/posttool_chain.py[397-431]
- internal/security/hooks/secret_redact_posttool_test.py[413-640]

Pass or derive a runtime-owned, immutable checkout root rather than trusting a `.git` entry discoverable from mutable `cwd`. Keep the skip limited to targets contained by that trusted root, and add a regression test that creates a parent `.git`, changes cwd there, and verifies the sibling token still redacts.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Pi corrupts JWT fixtures ✓ Resolved 🐞 Bug ≡ Correctness
Description
content_skips requires an absolute cwd, but pi's PostToolUse payload never sends one, so every
pi Read receives no skip and the newly added JWT pattern rewrites checkout fixtures. This returns
content different from disk—the exact edit-corruption path the checkout exemption is intended to
prevent.
Code

internal/security/hooks/secret_redact_posttool.py[R132-133]

+    if not isinstance(cwd, str) or not os.path.isabs(cwd):
+        return frozenset()
Relevance

●● Moderate

Pi omission is explicitly documented as intentional, but fixture corruption remains a concrete
correctness concern.

PR-#1178

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The new JWT prefix pattern is active unless content_skips returns jwt, but that function
immediately declines the exemption when cwd is absent. The pi adapter constructs PostToolUse
payloads without cwd, and the runtime contract explicitly confirms this omission, so pi checkout
reads are always passed through the new JWT redaction instead of the intended fixture-preserving
path.

internal/security/hooks/secret_redact_posttool.py[53-56]
internal/security/hooks/secret_redact_posttool.py[128-133]
internal/runtime/pi_extension/fullsend-hooks.js[226-234]
docs/contributing/runtime-implementation.md[581-584]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The checkout-scoped JWT exemption requires an absolute `cwd`, but the pi PostToolUse adapter omits it. Consequently, JWT-shaped fixtures read from checkout files are masked and the agent receives content different from disk.

## Issue Context
The new bare-JWT pattern did not exist previously, so omitting `cwd` does not preserve prior pi behavior. Supply a trustworthy checkout/current working directory in pi's hook payload and add adapter-level coverage proving a checkout `Read` preserves a JWT fixture while an outside-checkout read remains masked.

## Fix Focus Areas
- internal/runtime/pi_extension/fullsend-hooks.js[226-234]
- internal/security/hooks/secret_redact_posttool.py[128-133]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Context sources
✅ Compliance rules (platform): 67 rules
Review mode: 🧠 Deep: This is a security-sensitive hook change with substantial new credential-detection and path-resolution logic across multiple independent runtime paths, creating a dense set of easy-to-miss masking and bypass defects.

Grey Divider

Tip of the day
💡 Did you know, you can turn these tips off under Display preferences

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

@codecov

codecov Bot commented Sep 4, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Comment thread internal/security/hooks/secret_redact_posttool.py
Comment thread internal/security/hooks/secret_redact_posttool.py
dhshah13 added a commit to dhshah13/fullsend that referenced this pull request Sep 4, 2026
…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>
dhshah13 added a commit to dhshah13/fullsend that referenced this pull request Sep 4, 2026
…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>
@dhshah13
dhshah13 force-pushed the fix/posttool-hook-credential-shapes branch from 096d375 to d571888 Compare September 4, 2026 16:53

@waynesun09 waynesun09 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_resultcwd 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"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

dhshah13 added a commit to dhshah13/fullsend that referenced this pull request Sep 8, 2026
…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>
@dhshah13

dhshah13 commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

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.

@dhshah13
dhshah13 force-pushed the fix/posttool-hook-credential-shapes branch from 42ee4da to 9bac703 Compare September 8, 2026 12:48
dhshah13 added a commit to dhshah13/fullsend that referenced this pull request Sep 8, 2026
…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>
dhshah13 added a commit to dhshah13/fullsend that referenced this pull request Sep 8, 2026
…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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 11cb6b7. The comments now say the two patterns mirror what #6603 adds to the Go redactor (redactor.go there carries google_oauth_token and jwt; main has only github_server_token), so codex artifacts get the Go side when #6603 merges.

dhshah13 added a commit to dhshah13/fullsend that referenced this pull request Sep 8, 2026
…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>
dhshah13 added a commit to dhshah13/fullsend that referenced this pull request Sep 8, 2026
…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>
dhshah13 added a commit to dhshah13/fullsend that referenced this pull request Sep 8, 2026
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>
@dhshah13
dhshah13 force-pushed the fix/posttool-hook-credential-shapes branch from 9bac703 to 11cb6b7 Compare September 8, 2026 15:12
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>
@dhshah13
dhshah13 force-pushed the fix/posttool-hook-credential-shapes branch from 11cb6b7 to 27446ee Compare September 10, 2026 14:22

@waynesun09 waynesun09 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 is python3 -m pytest gitlint_rules_test.py -v (line 203), a single root-level file. Line 204 runs node --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.yml is the only workflow that installs or invokes pytest; its job runs make lint-all, go test, and make script-test (line 58). None reaches internal/security/hooks/.
  • No Go test shells out to the Python suites: grep -nE 'exec.Command|python|pytest|_test\.py' internal/security/hooks_test.go returns nothing, and the only python3 reference in internal/security/*.go is hooks.go:197 building 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("/"):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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: a UNICODE_SPACES character class (NBSP, the U+2000-range spaces, ideographic space) and, inside normalizePath, if (options.normalizeUnicodeSpaces) normalized = normalized.replace(UNICODE_SPACES, " ");
  • dist/core/tools/path-utils.js: resolveToCwd(filePath, cwd) calls resolvePath(filePath, cwd, { normalizeUnicodeSpaces: true, stripAtPrefix: true }), so the option is on for every caller.
  • dist/core/tools/read.js:56 uses resolveReadPathAsync (which wraps resolveToCwd); dist/core/tools/grep.js:57 uses resolveToCwd directly. 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 codepoints 0x61, 0xa0, 0x62.
  • pi side, run through the pinned package's own normalizePath with the exact options resolveToCwd passes: raw codepoints [61, a0, 62] → normalized [61, 20, 62]path.resolvefs.realpathSync returns the token OUTSIDE the checkout, and readFileSync returns 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).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
dhshah13 added a commit to dhshah13/fullsend that referenced this pull request Sep 10, 2026
…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>
@dhshah13

Copy link
Copy Markdown
Contributor Author

Fixed in e7bf0b0 for the review-body finding (the hook suites ran in no CI job): make script-test now runs python3 -m pytest internal/security/hooks/ -q; lint.yml already installs pytest and invokes script-test. Verified on 3.12 and on the 3.14 the job uses.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants