Skip to content

WEB-4871: forward Grep/Glob/LS reads from the claude-code hook#161

Open
vigneshsubbiah16 wants to merge 3 commits into
stagingfrom
web-4871-forward-grep-glob-ls
Open

WEB-4871: forward Grep/Glob/LS reads from the claude-code hook#161
vigneshsubbiah16 wants to merge 3 commits into
stagingfrom
web-4871-forward-grep-glob-ls

Conversation

@vigneshsubbiah16

@vigneshsubbiah16 vigneshsubbiah16 commented Jun 17, 2026

Copy link
Copy Markdown
Collaborator

What

Forwards Grep / Glob / LS from 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 native Read instead 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 onto staging containing 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)

  • W2 (cwd case): when path is omitted, Grep/Glob/LS scan cwd but forward no path; the broadest search may bypass. Consider falling back to event.cwd.
  • W1 (LS): extract_command_for_pretool not updated for LS; command becomes literal "LS" and the approval-key collapses.
  • W3 (Glob): forwards the glob pattern as file_path; confirm the gateway matches that vs a path root.
  • No hook tests yet.

Generated with Claude Code

Greptile Summary

This PR wires Grep, Glob, and LS into the Claude Code PreToolUse gateway flow so that read-equivalent file-inspection tools are policy-checked like native Read calls, 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 as file_path.

  • W1 + W2 resolved: extract_command_for_pretool now returns path|cwd|\"LS\" for LS, and _derive_read_equivalent_path falls back to event.cwd for all three tools when no explicit path is given, ensuring the gateway always receives path context for whole-directory scans.
  • New approval-key gap for Grep: extract_command_for_pretool still returns only the regex pattern for Grep (e.g. \"AWS_SECRET\"), making the approval key path-agnostic. If a Grep for that pattern on /work triggers a pending admin approval, a concurrent Grep for the same pattern on /etc will match the same marker, poll the same decision, and be auto-allowed — the gateway's /etc path policy is never evaluated.
  • W3 still open: Glob with no explicit path forwards its glob pattern (e.g. **/*.pem) as file_path to the gateway instead of cwd; 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 /work can 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_pretool for the Grep branch (lines 499-501) and the resulting approval key construction at line 924.

Security Review

  • Cross-path approval bypass via pattern-keyed Grep approvals (claude-code/hooks/unbound.py lines 499-501, 924): extract_command_for_pretool returns only the regex pattern for Grep, so the approval marker hash is path-agnostic. A pending admin approval for Grep(pattern=X, path=/work) can be consumed by a concurrent Grep(pattern=X, path=/etc), allowing the sensitive-path call through without the gateway evaluating /etc.

Important Files Changed

Filename Overview
claude-code/hooks/unbound.py Adds Grep/Glob/LS to the PreToolUse gateway flow via _derive_read_equivalent_path and updated extract_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.
claude-code/hooks/test_read_equivalent_tools.py New unit tests covering _derive_read_equivalent_path and extract_command_for_pretool for Grep/Glob/LS, pinning the W1/W2/W3 behaviors; no test for Grep(pattern=X, path=/dir) through extract_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
Loading
%%{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
    end
Loading

Comments Outside Diff (2)

  1. claude-code/hooks/unbound.py, line 497-515 (link)

    P1 LS falls through to return tool_name, collapsing all approval keys to "LS:LS"

    extract_command_for_pretool has dedicated branches for Grep and Glob (both return their pattern) but no branch for LS, so LS hits the default return tool_name path. The resulting approval_key is f"LS:{tool_name}""LS:LS" for every LS invocation. Because _is_approval_retry hashes the command with hashlib.sha256(command.encode()).hexdigest()[:16], all LS calls share one approval marker. If any LS triggers an approval and the user approves, every subsequent LS (on any path) is auto-approved for the full 4-hour APPROVAL_TIMEOUT window, bypassing per-path policy checks. Adding if tool_name == 'LS': return tool_input.get('path') or tool_name before the default return fixes this (mirrors the pattern already used for Grep/Glob/Bash).

  2. claude-code/hooks/unbound.py, line 499-501 (link)

    P1 security Grep approval key is pattern-only, enabling cross-path approval bypass

    extract_command_for_pretool returns only the regex pattern for Grep (e.g. "AWS_SECRET"), so approval_key becomes "Grep:AWS_SECRET" regardless of the target directory. When a Grep(pattern="AWS_SECRET", path="/work") triggers an admin Slack notification, the approval marker is written with the hash of "Grep:AWS_SECRET". If a second Grep(pattern="AWS_SECRET", path="/etc") arrives while the first is still pending, _is_approval_retry("Grep:AWS_SECRET") returns True, it polls the same pending approval, and when the admin approves (having only seen /work), the /etc call is auto-allowed without the gateway ever evaluating the /etc path. By contrast, Read/Write/Edit use file_path as 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): return f"{tool_input['path']}:{tool_input['pattern']}" when an explicit path is present, otherwise tool_input['pattern'].

Reviews (2): Last reviewed commit: "fix(hook): close Grep/Glob/LS path-polic..." | Re-trigger Greptile

MohamedAklamaash and others added 2 commits June 17, 2026 14:25
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>
Comment thread claude-code/hooks/unbound.py
Comment thread claude-code/hooks/unbound.py Outdated
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>
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