WEB-4871: forward Grep/Glob/LS reads from the claude-code hook#161
Open
vigneshsubbiah16 wants to merge 3 commits into
Open
WEB-4871: forward Grep/Glob/LS reads from the claude-code hook#161vigneshsubbiah16 wants to merge 3 commits into
vigneshsubbiah16 wants to merge 3 commits into
Conversation
Grep/Glob/LS are read-equivalent (they expose file contents or enumerate paths), so the hook now sends them with their target path, letting read / secret policies evaluate them like a native Read instead of bypassing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Grep's `pattern` is a regex, not a path; forwarding it as file_path made the gateway evaluate a regex as a filesystem path. Glob's `pattern` is path-like (`**/*.env`) so it keeps the fallback. Grep/LS now use `path` only. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review fixes on top of the WEB-4871 hook change: - W2/W3 (cwd bypass): Grep/Glob/LS with no `path` scan the whole working tree but forwarded no file_path, so path-scoped policies could not fire on the broadest (most dangerous) search. New _derive_read_equivalent_path falls back to the session cwd. Glob keeps its path-like `pattern` as a fallback for filename/glob policies; Grep's regex pattern is still never sent as a path. - W1 (LS approval-key collapse): extract_command_for_pretool had no LS branch, so every LS sent command "LS" and the approval key collapsed to "LS:LS". It now returns the listed path (or cwd). - Adds test_read_equivalent_tools.py (15 cases) pinning all three. Non-breaking and backward/forward compatible: .get() throughout, no new raises, fail-open preserved (no file_path => gateway evaluates as before / falls open). Read/Write/Edit and explicit-path Grep/Glob behavior is unchanged. An old gateway that does not know Grep/Glob/LS still returns allow; a new gateway that receives an old hook's path-less event still classifies the command and fails open. (The pre-existing, unrelated staging failure in test_identity::test_keys_limited_to_identity_fields is not touched here.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Forwards
Grep/Glob/LSfrom the Claude Code PreToolUse hook to the gateway with their target path, so these read-equivalent tools (Grep exposes file contents; Glob/LS enumerate paths) are evaluated like a nativeReadinstead of bypassing policy. Gateway-side mapping (Grep/Glob/LS to read_file) landed in ai-gateway #683.Why this PR (supersedes #160)
#160 was based on
web-4850(an unrelated in-flight feature, "longest handoff calc"), so its diff dragged in changes across codex/cursor hooks. This is a clean re-base ontostagingcontaining ONLY the WEB-4871 hook change (claude-code/hooks/unbound.py). The two commits are aklamaash's, cherry-picked unchanged.Open review items (from cross-repo review)
pathis omitted, Grep/Glob/LS scan cwd but forward no path; the broadest search may bypass. Consider falling back toevent.cwd.extract_command_for_pretoolnot updated for LS; command becomes literal "LS" and the approval-key collapses.file_path; confirm the gateway matches that vs a path root.Generated with Claude Code
Greptile Summary
This PR wires
Grep,Glob, andLSinto the Claude Code PreToolUse gateway flow so that read-equivalent file-inspection tools are policy-checked like nativeReadcalls, rather than bypassing the hook entirely. It addresses three previously-identified gaps: the LS approval-key collapse (W1, now fixed), the missing cwd fallback for path-less whole-tree scans (W2, now fixed via_derive_read_equivalent_path), and the acknowledged W3 open item where Glob forwards its pattern asfile_path.extract_command_for_pretoolnow returnspath|cwd|\"LS\"for LS, and_derive_read_equivalent_pathfalls back toevent.cwdfor all three tools when no explicit path is given, ensuring the gateway always receives path context for whole-directory scans.extract_command_for_pretoolstill returns only the regex pattern for Grep (e.g.\"AWS_SECRET\"), making the approval key path-agnostic. If a Grep for that pattern on/worktriggers a pending admin approval, a concurrent Grep for the same pattern on/etcwill match the same marker, poll the same decision, and be auto-allowed — the gateway's/etcpath policy is never evaluated.pathforwards its glob pattern (e.g.**/*.pem) asfile_pathto the gateway instead ofcwd; the tests explicitly pin this behavior, but the previous review thread flagged it as a concern pending gateway-side confirmation.Confidence Score: 3/5
The change to include Grep in the gateway approval flow introduces a new cross-path approval bypass that did not exist before, as Grep calls were not routed through this mechanism prior to this PR.
The Grep approval key uses the regex pattern rather than the target path. An admin presented with a Grep approval for
/workcan inadvertently authorize a concurrent same-pattern Grep against/etc, because both share the same marker hash and the gateway's path evaluation is skipped on retry. This is a newly introduced gap — Grep was not in the approval flow before this PR — and it affects the path-isolation property that the whole hook chain is designed to enforce.claude-code/hooks/unbound.py — specifically
extract_command_for_pretoolfor the Grep branch (lines 499-501) and the resulting approval key construction at line 924.Security Review
claude-code/hooks/unbound.pylines 499-501, 924):extract_command_for_pretoolreturns only the regex pattern forGrep, so the approval marker hash is path-agnostic. A pending admin approval forGrep(pattern=X, path=/work)can be consumed by a concurrentGrep(pattern=X, path=/etc), allowing the sensitive-path call through without the gateway evaluating/etc.Important Files Changed
_derive_read_equivalent_pathand updatedextract_command_for_pretool; W1 (LS approval key) and W2 (cwd fallback) are addressed, but Grep's approval key remains pattern-only rather than path-specific, creating a cross-path approval bypass in the retry mechanism._derive_read_equivalent_pathandextract_command_for_pretoolfor Grep/Glob/LS, pinning the W1/W2/W3 behaviors; no test forGrep(pattern=X, path=/dir)throughextract_command_for_pretool, which is the combination that exposes the cross-path approval key issue.Sequence Diagram
%%{init: {'theme': 'neutral'}}%% sequenceDiagram participant Claude as Claude Code participant Hook as unbound.py hook participant GW as Gateway Note over Hook: ALLOWED_NON_MCP_HOOK_NAMES now includes Grep/Glob/LS Claude->>Hook: PreToolUse (Grep/Glob/LS) Hook->>Hook: "extract_command_for_pretool()<br/>Grep→pattern, Glob→pattern, LS→path|cwd|LS" Hook->>Hook: "_derive_read_equivalent_path()<br/>explicit path > Glob pattern > cwd" Hook->>Hook: "metadata[file_path] = derived_path" Hook->>Hook: "approval_key = Tool:command" Note over Hook: ⚠ Grep key = Grep:{pattern} (path-agnostic) Hook->>Hook: _is_approval_retry(approval_key)? alt Approval marker present (same pattern, different path) Hook->>GW: poll_approval_status() — skips path re-check GW-->>Hook: approved (from prior /work approval) Hook-->>Claude: allow ⚠ /etc path never evaluated else No marker Hook->>GW: POST /v1/hooks/pretool with metadata.file_path GW-->>Hook: allow / deny / needs-approval alt needs-approval Hook->>Hook: "_set_approval_marker(Grep:{pattern})" Hook-->>Claude: block (pending admin) end end%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%% sequenceDiagram participant Claude as Claude Code participant Hook as unbound.py hook participant GW as Gateway Note over Hook: ALLOWED_NON_MCP_HOOK_NAMES now includes Grep/Glob/LS Claude->>Hook: PreToolUse (Grep/Glob/LS) Hook->>Hook: "extract_command_for_pretool()<br/>Grep→pattern, Glob→pattern, LS→path|cwd|LS" Hook->>Hook: "_derive_read_equivalent_path()<br/>explicit path > Glob pattern > cwd" Hook->>Hook: "metadata[file_path] = derived_path" Hook->>Hook: "approval_key = Tool:command" Note over Hook: ⚠ Grep key = Grep:{pattern} (path-agnostic) Hook->>Hook: _is_approval_retry(approval_key)? alt Approval marker present (same pattern, different path) Hook->>GW: poll_approval_status() — skips path re-check GW-->>Hook: approved (from prior /work approval) Hook-->>Claude: allow ⚠ /etc path never evaluated else No marker Hook->>GW: POST /v1/hooks/pretool with metadata.file_path GW-->>Hook: allow / deny / needs-approval alt needs-approval Hook->>Hook: "_set_approval_marker(Grep:{pattern})" Hook-->>Claude: block (pending admin) end endComments Outside Diff (2)
claude-code/hooks/unbound.py, line 497-515 (link)return tool_name, collapsing all approval keys to"LS:LS"extract_command_for_pretoolhas dedicated branches forGrepandGlob(both return theirpattern) but no branch forLS, soLShits the defaultreturn tool_namepath. The resultingapproval_keyisf"LS:{tool_name}"→"LS:LS"for everyLSinvocation. Because_is_approval_retryhashes the command withhashlib.sha256(command.encode()).hexdigest()[:16], allLScalls share one approval marker. If anyLStriggers an approval and the user approves, every subsequentLS(on any path) is auto-approved for the full 4-hourAPPROVAL_TIMEOUTwindow, bypassing per-path policy checks. Addingif tool_name == 'LS': return tool_input.get('path') or tool_namebefore the default return fixes this (mirrors the pattern already used forGrep/Glob/Bash).claude-code/hooks/unbound.py, line 499-501 (link)extract_command_for_pretoolreturns only the regex pattern forGrep(e.g."AWS_SECRET"), soapproval_keybecomes"Grep:AWS_SECRET"regardless of the target directory. When aGrep(pattern="AWS_SECRET", path="/work")triggers an admin Slack notification, the approval marker is written with the hash of"Grep:AWS_SECRET". If a secondGrep(pattern="AWS_SECRET", path="/etc")arrives while the first is still pending,_is_approval_retry("Grep:AWS_SECRET")returnsTrue, it polls the same pending approval, and when the admin approves (having only seen/work), the/etccall is auto-allowed without the gateway ever evaluating the/etcpath. By contrast,Read/Write/Editusefile_pathas the command, giving per-path approval isolation. The fix is to incorporate the resolved path into Grep's command return (mirroring how LS was fixed in this PR): returnf"{tool_input['path']}:{tool_input['pattern']}"when an explicit path is present, otherwisetool_input['pattern'].Reviews (2): Last reviewed commit: "fix(hook): close Grep/Glob/LS path-polic..." | Re-trigger Greptile