Skip to content

[WEB-5456] Repo gate: block writes and git commands outside the allowed GitHub org - #243

Open
gowshik450526511 wants to merge 10 commits into
stagingfrom
WEB-5456
Open

[WEB-5456] Repo gate: block writes and git commands outside the allowed GitHub org#243
gowshik450526511 wants to merge 10 commits into
stagingfrom
WEB-5456

Conversation

@gowshik450526511

@gowshik450526511 gowshik450526511 commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

UI Loom Video: https://www.loom.com/share/c06873562f6b4ff9a5c84c47c8e22966

CLI Loom Video: https://www.loom.com/share/a16ccedea8a34dfc880f6b97abcc89b8

Adds a client-side repository-scope gate to all five agent hooks. A policy names an allowed GitHub organization; work outside it warns for a configurable number of turns, then blocks.

Part of WEB-5456 — see the companion PRs in ai-gateway, ai-gateway-data and unbound-fe.

What is and isn't gated

Action Inside allowed org Outside it No git repo at all
Conversation allow allow allow
Read / Grep / Glob allow allow allow
Edit / Write / NotebookEdit allow warn → block allow
git commands allow warn → block allow
Shell writes (rm, mv, sed -i, >) allow warn → block allow
Any other shell command allow allow allow

A directory under no git root has no origin to judge, so it is never gated. "Look but don't touch" outside your org.

Why the decision is local

Only the hook can resolve a path to a git root and read its origin remote — the gateway has no view of the developer's filesystem. So the verdict is computed from a policy list cached on disk and returns before any gateway call.

Median p90
Gate only 15.45 ms 17.82 ms
Gate + fire-and-forget report 14.92 ms 16.41 ms

The added fork/exec is below the noise floor; the gate's own git call dominates.

Per-hook behaviour

Hook Prompt event Turn identity Grace
claude-code UserPromptSubmit prompt_id warn → block
codex UserPromptSubmit turn_id warn → block
cursor beforeSubmitPrompt generation_id warn → block
copilot UserPromptSubmit audit-log timestamp degrades to per-call when the log has no prompt
augment none exists none blocks from the first gated call

Augment has no turn identifier, so a grace counter could never advance — a warning phase that cannot escalate would mean the policy never enforces. It denies immediately instead.

Command detection

Quoted runs are masked before splitting, then the command word of each segment is matched — never a substring. VAR=value, sudo, env prefixes are stripped.

Command Gated Why
rm auth.py command word is rm
cd /repo && git commit second segment invokes git
sed -i s/a/b/ f.py in-place flag
echo x > out.txt creating redirect
grep -r "rm -rf" . appears in a search pattern
echo "a && git push" quoted — masking prevents the split
sed -n '1,10p' f.py read-only
npm test 2>&1 stream dup, not a write

Bias is deliberate: a false block stops legitimate work in a repo the developer is entitled to use, while a miss loses one enforcement point the write tools already largely cover.

Reporting

A WARN or BLOCK is POSTed fire-and-forget to /v1/hooks/repo-gate. The verdict is already decided and never waits on it — tests install a curl whose wait() raises if touched, on all five hooks.

Fails open, always

Git missing, git timeout, corrupt state file, malformed policy, cold cache, missing API key — all resolve to allow. A broken gate is a missed inspection, never a block.

Known gaps

  • Indirect invocation: xargs git commit, sh -c "rm x", $(…), backticks
  • Quoted command word: "git" push
  • 2> err.log is not treated as a write
  • The first event of a genuinely cold cache is unenforced — the gate runs before the API call, so there is nothing on disk to evaluate yet

Tests

895 parity (all five hooks) + 83 unit, up from 595 at the start of this work.

Two pre-existing failures are unrelated and confirmed identical against a stashed tree: test_setup.py::TestMatcherParityAcrossTrees and cursor/test_identity.py::test_keys_limited_to_identity_fields.

🤖 Generated with Claude Code


Note

High Risk
This is org policy enforcement on local filesystem/git resolution across all agent hooks; mistakes fail open (missed enforcement) or false blocks, and hook-specific grace/workspace behavior is easy to get wrong.

Overview
Adds a client-side repository-scope gate to all five agent hooks so work in git repos outside an org’s allowed GitHub organization is limited to “look, don’t touch”: writes, direct git invocations, and shell mutations can warn then block (or block immediately on Augment), while conversation, reads, and non-mutating shell commands stay allowed.

The gate runs before gateway PreToolUse policy checks, resolves paths to origin locally, and never calls the gateway on a block. Policies come from repo_policies in the on-disk policy cache, refreshed on prompt submit (and from gateway responses) so the first gated tool call in a session can enforce. Grace is session-scoped with per-turn accounting where turn ids exist; Augment has no grace (no turn id) and uses workspace plus per-path gates with SessionStart advisories only.

Incident reporting POSTs WARN/BLOCK telemetry fire-and-forget to /v1/hooks/repo-gate. Shell scope uses conservative segment-based detection (quoted masking, command-word matching). Large cross-hook parity and Claude Code unit tests pin shared semantics, fail-open behavior, and reporting cardinality.

Reviewed by Cursor Bugbot for commit b2558e8. Bugbot is set up for automated code reviews on this repo. Configure here.

Greptile Summary

The PR adds a local repository-scope gate across the five supported agent hooks, with shared policy caching, repository resolution, grace handling, and asynchronous incident reporting.

  • Resolves relative write and Git target paths against the applicable cwd or workspace.
  • Tracks shell directory states across sequential, conditional, piped, and backgrounded segments.
  • Adds cross-hook parity and focused repository-gate regression coverage.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains; the previously reported relative-path and shell-directory resolution issues are addressed by the current implementation and focused regression coverage.

Important Files Changed

Filename Overview
claude-code/hooks/unbound.py Implements the repository gate and the fixes for previously reported relative-path and shell-cwd resolution failures.
codex/hooks/unbound.py Adds the Codex-specific gate integration while preserving shared repository and shell-target semantics.
copilot/hooks/unbound.py Adds repository gating for normalized Copilot tool events, including relative write-path resolution.
cursor/unbound.py Adds repository gating using Cursor cwd or workspace roots and corrected shell directory-state handling.
augment/hooks/unbound.py Adds immediate-deny repository gating for Augment, which lacks a stable turn identifier.
binary/tests/test_repo_gate_parity.py Provides cross-hook parity coverage for policy interpretation, path resolution, shell classification, grace behavior, and reporting.
claude-code/hooks/test_repo_gate.py Adds focused regression coverage for relative Git targets and conditional, pipeline, and chained-cd cwd behavior.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[PreToolUse or shell event] --> B{Write tool or direct mutating shell/Git command?}
  B -->|No| C[Allow normal hook processing]
  B -->|Yes| D[Resolve file, cwd, workspace, and Git target candidates]
  D --> E[Find enclosing Git roots and origin organizations]
  E --> F{Outside every allowed organization?}
  F -->|No| C
  F -->|Yes| G{Grace available?}
  G -->|Yes| H[Warn and record turn]
  G -->|No| I[Block locally]
  H --> J[Report asynchronously]
  I --> J
Loading

Reviews (10): Last reviewed commit: "[WEB-5456] Restore the production gatewa..." | Re-trigger Greptile

Context used (5)

…ed GitHub org

Adds a client-side repository-scope gate to all five agent hooks. A policy
names an allowed GitHub organization; work outside it warns for a configurable
number of turns, then blocks.

The gate is decided entirely on the developer's machine — only the hook can
resolve a path to a git root and read its origin remote — so a block returns
before any gateway call and adds no latency. Measured: median 14.9ms with
reporting vs 15.5ms without.

Scope: write tools (Edit/Write/NotebookEdit), git commands, and shell writes
(rm, mv, cp, sed -i, redirects). Conversation, reads, and every other shell
command are ungated. A directory under no git root is never gated.

WARN and BLOCK are reported fire-and-forget to /v1/hooks/repo-gate so the
control plane can record them; the verdict never waits on that call.

Fails open at every layer: git missing, git timeout, corrupt state, malformed
policy or cold cache all resolve to allow.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@gowshik450526511
gowshik450526511 requested a review from a team August 14, 2026 04:19
Comment thread codex/hooks/unbound.py
def _get_git_origin_org_repo(cwd: str) -> tuple:
"""Lowercased (org, repo) of `cwd`'s origin; git failure propagates upward."""
url = _git_origin_url(cwd)
if not url or not _remote_host(url):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Agentic Security Review
Severity: HIGH
The repo-scope gate accepts any remote host and only checks the parsed org segment, so a non-GitHub remote like https://attacker-host/<allowed-org>/repo.git is treated as in-scope.
This weakens the intended "allowed GitHub org" boundary and can permit out-of-scope write/git actions.

Fix in Cursor Fix in Web

Reviewed by Cursor Security Reviewer for commit 0fd5210. Configure here.

Comment thread codex/hooks/unbound.py
Comment thread codex/hooks/unbound.py Outdated
'tool_name': context.get('tool_name'),
'session_id': context.get('session_id'),
'turn': turn,
'prompt_text': _repo_gate_clip(context.get('prompt_text')),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Agentic Security Review
Severity: MEDIUM
Repo-gate telemetry posts clipped but raw prompt_text and tool_input for WARN/BLOCK events.
Blocked operations can still transmit sensitive command/path/prompt content to the telemetry endpoint, creating avoidable privacy exposure in denial flows.

Fix in Cursor Fix in Web

Reviewed by Cursor Security Reviewer for commit 0fd5210. Configure here.

Comment thread claude-code/hooks/unbound.py Outdated

@vigneshsubbiah16 vigneshsubbiah16 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🛡️ Automated Security Review (consensus)

4 findings — 3 high-confidence, 1 to triage. Reviewers: Lead, Claude, Cursor, Semgrep, Gitleaks.

🔴 Host-blind github_org matching allows non-GitHub remotes in scope

*/hooks/unbound.py (_get_git_origin_org_repo, _repo_gate_scope_allows) · 🔴 HIGH

Impact: Any origin with a host and a first path segment matching policy['github_org'] is treated as in-scope (e.g. https://attacker.example/unboundsec/repo.git), so gated writes/git in attacker-controlled remotes are never blocked.

Fix: Require an allowed host set (github.com plus explicit enterprise hosts from policy); treat unknown hosts as out-of-scope or unresolvable (fail-open), not as a matching org.

Flagged by: Lead, Claude, Cursor


🔴 Shell repo-scope resolution misses relative and mixed-path writes

*/hooks/unbound.py (_tool_use_path_candidates / _repo_gate_candidates) · 🔴 HIGH

Impact: Candidate extraction only follows absolute paths and defers to cwd when none are found; relative targets (../other-repo, git -C ../other-repo …) can be judged against the wrong repo, and a lone absolute arg with no git root (e.g. cp /etc/hostname ./x in an out-of-scope cwd) drops cwd entirely and skips enforcement.

Fix: Always include the resolved shell working directory as a candidate; resolve relative write/git targets against it; do not let unrelated absolute paths suppress cwd evaluation.

Flagged by: Lead, Claude, Cursor


🔴 API key passed on curl argv (visible to local processes)

*/hooks/unbound.py (_repo_gate_post, ~2580 claude-code / mirrored in all five hooks) · 🔴 HIGH

Impact: Fire-and-forget telemetry spawns curl with Authorization: Bearer <key> in argv; on Linux/macOS other local users/processes can read it via ps or /proc/<pid>/cmdline for the child lifetime (up to --max-time 10).

Fix: Keep the bearer token out of argv—POST via the existing in-process HTTP path, or pass the header through a mode-0600 config file (curl -K) and delete it immediately.

Flagged by: Lead, Claude


🟡 WARN/BLOCK telemetry may exfiltrate sensitive prompt/command content

*/hooks/unbound.py (_repo_gate_report) · 🟡 TRIAGE

Impact: Blocked/denied operations still POST clipped but raw prompt_text and tool_input to /v1/hooks/repo-gate, which can carry secrets, tokens, or private paths in denial flows.

Fix: Redact or hash high-risk fields, report metadata only (tool name, repo, decision), or gate payload content behind an explicit policy flag.

Flagged by: Lead, Cursor


🤖 consensus review · reviewers: Cursor,Claude,Semgrep,Gitleaks · head 0fd52104 · 2026-08-14T04:25Z

Comment thread cursor/unbound.py
if isinstance(path, str) and path.startswith('/') \
and not _is_system_checkout_path(path):
return [os.path.dirname(path)]
return []

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor misses relative cd escapes

High Severity

Cursor shell candidates only collect absolute paths from the command, then fall back to the workspace root. There is no _next_shell_dir follow of a relative cd, so cd ../personal && git commit from an in-scope workspace is judged against the workspace and allowed. The other hooks follow relative cd targets, and catching that personal-repo bypass is a stated goal of this change.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 0fd5210. Configure here.

)
shell_dir = _next_shell_dir(command, shell_dir)
if not candidates and shell_dir:
candidates.append(shell_dir)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Abs paths hide post-cd repo

Medium Severity

Shell candidate collection always prefers absolute paths from the command line and only appends the post-cd working directory when that list is empty. A non-repo absolute path (for example under /tmp) therefore prevents the resolved cd target from being evaluated, so a following relative cd into an out-of-scope repo plus git or a write can be allowed.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 0fd5210. Configure here.

or word in _COMMAND_PREFIX_WORDS):
continue
words.append(word)
return words

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

sudo -u hides gated commands

Medium Severity

_segment_words strips sudo and then any leading dash tokens, but not arguments those flags take. For sudo -u alice git push (or sudo -u alice rm …), alice becomes the command word, so _is_git_command / _is_shell_write_command return false and the gate never applies even when the call runs in an out-of-scope repo.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 0fd5210. Configure here.

A Write/Edit/NotebookEdit call naming a relative path (src/main.py) yielded
no repository candidate on claude-code, cursor and copilot, so the gate had
nothing to judge and allowed the write out of an out-of-scope repo. Codex
already fell back to cwd and Augment already joined relative paths to it;
this brings the other three onto Augment's mechanism.

Unresolvable paths still allow: with no cwd the path names no repository and
the gate allows rather than blocks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Stale comment

Comment thread codex/hooks/unbound.py
word = word.strip('()`{}"\'')
if not words and (not word or word.startswith('-')
or _ENV_ASSIGNMENT_RE.match(word)
or word in _COMMAND_PREFIX_WORDS):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Agentic Security Review
Severity: HIGH
_segment_words only strips wrapper tokens when they appear as exact words (env, sudo, command). Path-qualified or indirect invocation patterns (for example /usr/bin/env git push or sh -c "git push") can leave a non-git command word, causing _repo_gate_applies to skip repo gating for operations that still execute git/write behavior.
Impact: Out-of-scope repository policy can be bypassed for mutating shell/git actions.

Fix in Cursor Fix in Web

Reviewed by Cursor Security Reviewer for commit b2558e8. Configure here.

Comment thread codex/hooks/unbound.py

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

There are 4 total unresolved issues (including 3 from previous reviews).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit b2558e8. Configure here.

Comment thread codex/hooks/unbound.py
)
if not candidates and cwd:
candidates.append(cwd)
return candidates

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Codex relative write path bypass

High Severity

Codex apply_patch candidate extraction only scans for absolute paths in the serialized input, then falls back to cwd. Relative targets such as ../other-repo/file never join cwd, so a patch that writes outside the allowed org from an in-scope working directory is allowed.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b2558e8. Configure here.

@vigneshsubbiah16 vigneshsubbiah16 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🛡️ Automated Security Review (consensus)

7 findings — 3 high-confidence, 4 to triage. Reviewers: Cursor, Claude, Semgrep, Gitleaks.

🔴 HIGH — Remote host not enforced; org segment alone satisfies policy

claude-code/hooks/unbound.py:2640 (mirrored in codex/, cursor/, copilot/, augment/)
Impact: A repo whose origin is https://evil.example/unboundsec/repo.git or git@gitlab.com:unboundsec/x.git is treated in-scope; writes/git/shell-writes there are allowed with no warn/block.
Fix: Pin allowed host(s) in policy (default github.com, configurable for GHE); reject remotes whose host is not on the allowlist before comparing org.
Flagged by: Cursor, Claude

🔴 HIGH — Codex apply_patch ignores relative paths outside cwd

codex/hooks/unbound.py:1486-1510
Impact: Patches targeting ../other-repo/file from an in-scope cwd produce no absolute candidate and fall back to cwd, so out-of-org writes via apply_patch can bypass the gate.
Fix: Join relative paths extracted from patch bodies against event cwd (same as Write/Edit fixes elsewhere).
Flagged by: Cursor

🔴 HIGH — Cursor shell gate does not follow relative cd

cursor/unbound.py:1538-1559
Impact: cd ../personal && git commit from an in-scope workspace is judged against the workspace root, not the post-cd repo, allowing git/write in an out-of-scope sibling repo.
Fix: Apply _next_shell_dir (or equivalent) for relative cd targets before collecting shell candidates, matching the other hooks.
Flagged by: Cursor

🟡 TRIAGE — sudo -u strips sudo but not its flag argument

claude-code/hooks/unbound.py:2477-2489 (augment/hooks/unbound.py:1887-1899, shared _segment_words)
Impact: sudo -u alice git push / sudo -u alice rm … classify alice as the command word, so gated git/write detection never runs.
Fix: After stripping sudo/env/command, also skip flag tokens that take arguments (-u, -E, etc.) or peel one following word per flag.
Flagged by: Cursor

🟡 TRIAGE — Absolute paths can hide the post-cd working directory

claude-code/hooks/unbound.py:2547-2556 (augment/hooks/unbound.py:2064-2071)
Impact: When any absolute path is present, the resolved post-cd directory is not added to candidates, so sequences like cd ../out-of-scope && git commit combined with an unrelated absolute path may be allowed.
Fix: Always append the post-cd directory to candidates (or evaluate it when the gated segment runs after cd), not only when the absolute-path list is empty.
Flagged by: Cursor

🟡 TRIAGE — Gateway API key passed on curl argv

claude-code/hooks/unbound.py:~2760 (_repo_gate_post in all five hooks)
Impact: Authorization: Bearer … is visible in the child process command line (ps, /proc/*/cmdline) for up to --max-time 10 on shared/CI hosts.
Fix: Pass the header via a 0600 curl config file, -H @file, or reuse the in-process HTTP path used by send_to_hook_api.
Flagged by: Claude

🟡 TRIAGE — Block/warn telemetry ships clipped prompt and tool input

codex/hooks/unbound.py:~1664 (_repo_gate_report in all hooks)
Impact: Denied operations still POST raw (truncated) prompt_text and tool_input to /v1/hooks/repo-gate, creating avoidable secret/path leakage in denial telemetry.
Fix: Report metadata only (repo, tool name, decision) or redact/hash sensitive fields server-side; keep full content off the client POST unless strictly required.
Flagged by: Cursor

Previously acknowledged (not re-flagged)

  • Indirect shell invocation (xargs git, sh -c "…", $(…), backticks) — documented known gap; conservative miss accepted by design.
  • Quoted command word ("git" push) — documented known gap.
  • 2> err.log not treated as a write — documented known gap.
  • Relative write-path bypass (Write/Edit/NotebookEdit) — fixed in latest commit; Greptile confirmed resolution against cwd/workspace.
  • Fail-open on git/cache/state errors — explicit product choice: a broken gate misses enforcement rather than blocking legitimate work.
  • Semgrep SQL / file-permission hits (cursor/unbound.py:536, 0o755/$BITS patterns) — pre-existing code outside this change’s scope; not re-flagged as new regressions.

🤖 consensus review · reviewers: Cursor,Claude,Semgrep,Gitleaks · head b2558e84 · 2026-08-14T05:18Z

Gowshik T and others added 2 commits August 15, 2026 09:32
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The gate report now posts to /v1/hooks/pretool, the route the gateway
actually serves. /v1/hooks/repo-gate was deleted gateway-side when the
ingest moved onto the shared pretool logging path, and the hook was never
moved with it, so every report has been posting to a dead endpoint.

The body is the envelope the gateway already understands: event_name
discriminates the post, unbound_app_label and conversation_id sit at the
top level where resolveAppLabel and the logging path read them, and the
verdict rides under repo_gate. Each hook sends the same app label literal
it already hardcodes on its other posts, so augment reports as
augment_code and a Cowork session reports as cowork instead of both being
flattened to the tool name. That retires the gateway's agent-to-slug map
rather than teaching it another entry.

agent stays inside the verdict because RepoPolicyAnalytics stores it in
its own metadata blob and the incidents page filters on it; the app label
lands on the GatewayMetrics row instead, and the two tables have no FK
between them, so neither value can be recovered from the other. It now
carries the app label rather than a separate agent vocabulary.

surface and policy_name are dropped. Django derives surface from
tool_name as 'tool' if tool_name else 'prompt', and every report a hook
can file names a tool, so the derived value is byte-identical to what was
being sent and the event_key digest is unchanged. policy_name is read
nowhere.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@vigneshsubbiah16 vigneshsubbiah16 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🛡️ Automated Security Review (consensus)

5 findings — 2 high-confidence, 3 to triage. Reviewers: Lead, Semgrep, Gitleaks (Claude unavailable).

🔴 HIGH — Arbitrary remote host accepted as in-scope GitHub org

claude-code/hooks/unbound.py:2295 (same logic in codex/, cursor/, copilot/, augment/)
Impact: _repo_gate_scope_allows matches only the parsed org segment; a remote like https://attacker.example/unboundsec/repo.git is treated in-scope, allowing writes/git outside the real GitHub org boundary.
Fix: Require an allowed host set (e.g. github.com, configured GHE hosts) before org comparison, or reject remotes whose host is not a trusted GitHub endpoint.
Reviewers: Cursor Security, Lead

🔴 HIGH — sudo -u shifts command-word detection

claude-code/hooks/unbound.py:2448 (also augment/hooks/unbound.py:1850)
Impact: _segment_words strips sudo and leading - tokens but not their arguments, so sudo -u alice git push classifies alice as the command word and skips repo gating for git/shell-write detection.
Fix: After stripping sudo/env/command, also consume known option arguments (e.g. -u, -g, -C) before taking the command word.
Reviewers: Cursor Bugbot, Lead

🟡 TRIAGE — Relative git -C targets wrong repo

codex/hooks/unbound.py:1447 (shared shell-candidate pattern)
Impact: git -C ../other-repo commit may leave no absolute candidate and fall back to workspace cwd, judging an in-scope repo while mutating an out-of-scope one.
Fix: Resolve git -C / -C directory arguments (including relative paths) against cwd and add them to candidate paths before scope lookup.
Reviewers: Cursor Security

🟡 TRIAGE — Absolute path in command hides post-cd repo

claude-code/hooks/unbound.py:2520
Impact: Shell candidate collection prefers absolute paths and only appends the post-cd directory when the list is empty; a non-repo absolute path (e.g. /tmp/foo) can block evaluation of a later cd into an out-of-scope repo plus git/write.
Fix: Always include the resolved post-cd working directory alongside absolute-path candidates, then evaluate all against policy.
Reviewers: Cursor Bugbot, Lead

🟡 TRIAGE — WARN/BLOCK telemetry may exfiltrate sensitive content

claude-code/hooks/unbound.py:2720 (all five hooks via _repo_gate_report)
Impact: Fire-and-forget incident posts include clipped but raw prompt_text and tool_input on deny/warn paths, sending potentially sensitive prompts, paths, and commands to the gateway.
Fix: Redact or hash sensitive fields server-side, or report only structured metadata (repo, tool name, policy id) unless explicitly opted in.
Reviewers: Cursor Security

Previously acknowledged (not re-flagged)

  • Indirect shell invocation (xargs git, sh -c "…", $(), backticks) — PR “Known gaps”; deliberate conservative miss, write tools still cover ordinary cases.
  • Quoted command word ("git" push) — PR “Known gaps”.
  • 2> err.log not classified as a write — PR “Known gaps”.
  • First gated call on a cold policy cache is unenforced — PR “Known gaps”; gate runs before network and fails open by design.
  • Fail-open on git/policy/cache errors — PR design choice: a broken gate must not block legitimate work.
  • Relative write-path bypass (Edit src/main.py etc.) — Addressed in b2558e8; Greptile re-review reports no blocking failure remains.
  • Semgrep insecure-file-permissions (0o755) — Pre-existing hook install chmod pattern across trees; not introduced by repo-gate logic.
  • Semgrep SQL injection (cursor/unbound.py:534) — Not part of this PR’s repo-gate diff; likely pre-existing / false positive pending separate triage.

🤖 consensus review · reviewers: Cursor,Claude,Semgrep,Gitleaks · head 0b6975bb · 2026-08-17T14:28Z

Comment thread claude-code/hooks/unbound.py
`git -C ../other-repo commit` escaped the repo gate. Candidate extraction
scanned the command for absolute paths only, so a relative -C target was
invisible and the gate fell back to the (allowed) cwd, letting git write to
an out-of-scope checkout without a warning or a block.

_git_path_opt_targets pulls the directory out of -C, --git-dir and
--work-tree, resolving a relative target against the directory the command
starts in. Wired into every Bash candidate extractor in all five hooks, on
both the gate path and the attribution path.

The gate still judges the repo being written, so pointing -C at an in-scope
repo stays allowed wherever it was launched from, and a non-git target stays
exempt. A `cd` earlier in the same command line is not modelled; those keep
falling back to the absolute-path scan and the cwd candidate.

Ten tests in test_repo_gate.py, four of which fail without the fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread claude-code/hooks/unbound.py

@vigneshsubbiah16 vigneshsubbiah16 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🛡️ Automated Security Review (consensus)

6 findings — 2 high-confidence, 4 to triage. Reviewers: Lead, Claude, Cursor, Semgrep, Gitleaks.

🔴 HIGH — Chained cd + relative git -C evaluates the wrong repository

claude-code/hooks/unbound.py:2357 (same pattern in codex/hooks/unbound.py, copilot/hooks/unbound.py, cursor/unbound.py, augment/hooks/unbound.py)

Impact: cd subdir && git -C ../outside commit resolves the -C target against the pre-cd cwd, so the gate can allow git mutations in an out-of-scope repo while judging an in-scope path.

Fix: Walk the command left-to-right: apply _next_shell_dir (or equivalent) before calling _git_path_opt_targets, and add parity tests for cd … && git -C ….

Reviewers: Greptile, Lead


🔴 HIGH — API bearer token exposed in curl argv (visible in /proc)

claude-code/hooks/unbound.py:2746 (duplicated in all five hooks’ _repo_gate_post)

Impact: Every WARN/BLOCK spawns curl with Authorization: Bearer … on the command line; on Linux any local user or EDR agent can read the hook API key from /proc/<pid>/cmdline and impersonate the hook.

Fix: Keep the token out of argv — POST via Python (send_to_hook_api), curl --config - on stdin, or a 0600 header file with -K.

Reviewers: Claude, Lead


🟡 TRIAGE — Org match ignores remote host (non-GitHub URL can satisfy policy)

claude-code/hooks/unbound.py:2654 (_repo_gate_scope_allows; shared across hooks)

Impact: Origin parsing checks only the org segment; a remote like https://attacker-host/<allowed-org>/repo.git can be treated in-scope and skip warn/block on writes and git.

Fix: Require an allowed host set (e.g. github.com, configured GHE hosts) in addition to org matching, or deny when the host is not on an allowlist.

Reviewers: Cursor


🟡 TRIAGE — sudo -u <user> … loses the real command word

claude-code/hooks/unbound.py:2486 (_segment_words; shared across hooks)

Impact: sudo -u alice git push / sudo -u alice rm … leaves alice as the command word, so git/write gating never applies even in an out-of-scope repo.

Fix: After stripping sudo, skip -u/-g and their arguments (or recurse into the remainder) before classifying git/write commands.

Reviewers: Cursor


🟡 TRIAGE — Absolute path in command suppresses post-cd repo candidate

claude-code/hooks/unbound.py:2567 (_tool_use_path_candidates; shared across hooks)

Impact: When the line contains any absolute path (e.g. under /tmp), the post-cd directory is not added as a candidate, so cd ../outside && git commit can be allowed if the absolute path is not in a git root.

Fix: Always union post-cd shell_dir into candidates for gated shell commands, not only when the absolute-path list is empty.

Reviewers: Cursor


🟡 TRIAGE — Denied-action telemetry sends clipped but raw prompt_text / tool_input

claude-code/hooks/unbound.py:2795 (_repo_gate_report; shared across hooks)

Impact: WARN/BLOCK reports can ship secrets, paths, and command contents to the gateway even when the operation was blocked — avoidable privacy exposure on denial flows.

Fix: Redact or hash sensitive fields, report metadata only (tool name, repo, decision), or omit user content unless explicitly opted in.

Reviewers: Cursor


Previously acknowledged (not re-flagged)

  • Indirect shell invocation (xargs git, sh -c "…", $(…), backticks) — PR “Known gaps”: deliberate conservative miss; write tools cover the common case.
  • Quoted command word ("git" push) — PR “Known gaps”: documented parser limitation.
  • 2> stderr redirect not treated as a write — PR “Known gaps”: accepted scope boundary.
  • First gated call on a cold policy cache — PR “Known gaps” + prompt-path cache refresh: first tool call in a session can run before policies exist on disk; fail-open by design.
  • Fail-open on git/cache/state errors — PR design: broken gate must not block legitimate work.
  • Relative write paths / relative git -C / relative cd escapes (without chained cd+-C) — addressed in this PR with path resolution, _git_path_opt_targets, and parity tests; not re-raised.
  • Semgrep insecure-file-permissions / SQL concatenation on cursor/unbound.py:534 — pre-existing hook code paths, not introduced by the repo-gate change; treated as scanner noise for this PR.

🤖 consensus review · reviewers: Cursor,Claude,Semgrep,Gitleaks · head 90d59d63 · 2026-08-20T14:52Z

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Stale comment

Agentic security review of the repo-scope gate. Two net-new issues: relative GIT_DIR/GIT_WORK_TREE retargets (HIGH) and system-checkout skip reused on enforcement targets (MEDIUM). Existing threads covering Cursor cd, sudo -u / path-qualified wrappers, absolute-path hiding of post-cd dirs, Codex apply_patch relatives, spoofable remote hosts, and telemetry payloads remain as previously reported.

Open in Web View Automation 

Sent by Cursor Security Agent: Security Reviewer

Comment thread claude-code/hooks/unbound.py Outdated
Comment thread claude-code/hooks/unbound.py Outdated
`cd /elsewhere && git -C ../widgets commit` still escaped: the target was
resolved against the directory the command started in, while git ran it
against the post-cd directory. The gate checked one repo and git wrote to
another.

_git_path_opt_targets now walks the command's segments in order, applying each
segment's `cd` before moving on, so a target resolves against the directory in
effect where it appears. A `cd` after the git call no longer shifts it.

Two things this turned up:

- _CD_TARGET_RE anchors on ^ or a separator, so a segment carrying a leading
  space hid its cd entirely. Segments are stripped now, which is what made
  chained cds work.
- Extraction is scoped to segments whose command word is git, so `grep -C 3`
  no longer reads 3 as a directory. _segment_words already drops sudo/env
  wrappers, so `sudo git -C ...` still resolves.

cursor had no cd tracking at all; _CD_TARGET_RE and _next_shell_dir are ported
in, bringing it to parity with the other four.

Five chained-cd tests, three of which fail without the segment walk. 79 in
test_repo_gate, and all five hooks agree on the shared probe cases.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@cursor

cursor Bot commented Aug 20, 2026

Copy link
Copy Markdown

Bugbot needs on-demand usage enabled

Bugbot uses usage-based billing for this team and requires on-demand usage to be enabled.

A team admin can enable on-demand usage in the Cursor dashboard.

@vigneshsubbiah16 vigneshsubbiah16 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🛡️ Automated Security Review (consensus)

6 findings — 3 high-confidence, 3 to triage. Reviewers: Claude, Semgrep, Gitleaks, Lead.

🔴 HIGH — Development gateway default disables production policy enforcement

claude-code/hooks/unbound.py:20

  • Impact: Default UNBOUND_GATEWAY_URL is http://localhost:8787 instead of https://api.getunbound.ai; installs without the env var miss gateway policy checks (fail-open allow), never load repo_policies, and may send Authorization: Bearer … over cleartext HTTP to whatever binds that port.
  • Fix: Restore the production HTTPS default and add a CI guard that rejects non-production defaults.
  • Reviewers: Claude, Lead

🔴 HIGH — GIT_DIR / GIT_WORK_TREE env retargeting not reflected in repo candidates

claude-code/hooks/unbound.py:2524 (same pattern in all five hooks)

  • Impact: _is_git_command gates GIT_DIR=… git … / GIT_WORK_TREE=… git …, but _git_path_opt_targets only parses -C / --git-dir / --work-tree flags; relative env values never become candidates, so the gate judges the allowed cwd while git mutates an out-of-scope checkout.
  • Fix: Parse leading GIT_DIR= / GIT_WORK_TREE= assignments (resolve relative values against segment cwd) and add them to shell candidates before scope lookup.
  • Reviewers: Cursor, Lead

🔴 HIGH — sudo -u strips the real gated command word

claude-code/hooks/unbound.py:2499 (same pattern in augment/hooks/unbound.py:1887)

  • Impact: _segment_words drops sudo and leading flags but not their arguments, so sudo -u alice git push / sudo -u alice rm … classifies alice as the command word and skips repo gating entirely.
  • Fix: After removing sudo/env/command, also skip the next token when a flag takes an argument (e.g. -u, -g, --).
  • Reviewers: Cursor, Lead

🟡 TRIAGE — Org API key visible in curl process arguments

claude-code/hooks/unbound.py:2768 (augment/hooks/unbound.py:2108)

  • Impact: _repo_gate_post passes Authorization: Bearer <key> via -H argv; on shared hosts/containers, /proc/<pid>/cmdline and ps can expose the org API key for every WARN/BLOCK report.
  • Fix: Pass the header via stdin (curl -H @-), a --config - fd, or an env var — never put the token in argv.
  • Reviewers: Claude

🟡 TRIAGE — repositories / include_forks policy fields are ignored client-side

claude-code/hooks/unbound.py:2648 (identical helper in all five hooks)

  • Impact: _repo_gate_scope_allows matches only github_org; if the gateway ever narrows scope to specific repos or excludes forks, the client widens enforcement to the whole org with no server re-check.
  • Fix: Honor repositories (when non-empty) and include_forks, or remove those fields from the policy contract and gateway responses.
  • Reviewers: Claude

🟡 TRIAGE — Repo-gate grace state written with predictable temp path / default mode

claude-code/hooks/unbound.py:2710

  • Impact: State is written to .repo_gate.<pid>.tmp with default umask before os.replace; in permissive $HOME layouts, a local writer could race or tamper with grace counters.
  • Fix: Use tempfile.mkstemp(..., dir=…) with mode 0600 and chmod the final state file to 0600.
  • Reviewers: Claude, Semgrep

Previously acknowledged (not re-flagged)

  • Non-GitHub / arbitrary-host remotes matched by org path segment only — accepted scope model; GHE and host parsing are covered by tests, not restricted to github.com.
  • Indirect shell invocation (xargs git, sh -c "…", $(), backticks) — documented known gap in the PR (“bias is deliberate”).
  • Quoted command word ("git" push) — documented known gap.
  • Cold-cache first gated call unenforced — documented; prompt path exists specifically to warm the cache.
  • Fail-open on git/cache/state errors — explicit product decision (“A broken gate is a missed inspection, never a block”).
  • _is_system_checkout_path drops /opt/homebrew / system paths — intentional WEB-5433 false-positive avoidance; resolution falls back to cwd.
  • WARN/BLOCK telemetry includes clipped prompt_text / tool_input — accepted reporting shape for incident analytics.
  • Relative git -C / chained-cd ordering — addressed in this PR (_git_path_opt_targets segment walk + parity/unit tests); prior Greptile report appears stale on latest commits.

🤖 consensus review · reviewers: Cursor,Claude,Semgrep,Gitleaks · head 2b2e1215 · 2026-08-20T15:27Z

Comment thread claude-code/hooks/unbound.py Outdated
`cd /nonexistent || git -C ../widgets commit` still escaped: the walker
applied every cd unconditionally, so it resolved the target under a directory
the shell never entered, got a non-repository, and allowed the write that git
actually made in the original cwd.

The separator carries the answer. `&&` runs the next segment only on success,
so the cd took effect; `||` runs it only on failure, so it did not; `;`, `|`
and `&` run either way, so both cwds stay reachable and a target is resolved
against each. The set is deduped and capped at 8.

Three tests, all three failing without the separator check. 82 in
test_repo_gate; all five hooks agree on the shared probe cases.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@cursor

cursor Bot commented Aug 20, 2026

Copy link
Copy Markdown

Bugbot needs on-demand usage enabled

Bugbot uses usage-based billing for this team and requires on-demand usage to be enabled.

A team admin can enable on-demand usage in the Cursor dashboard.

@vigneshsubbiah16 vigneshsubbiah16 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🛡️ Automated Security Review (consensus)

6 findings — 1 high-confidence, 5 to triage. Reviewers: Lead, Claude, Semgrep, Gitleaks, Cursor.

🔴 HIGH

Shipped default gateway is cleartext localhost (fail-open + key exposure)claude-code/hooks/unbound.py:20
Impact: When UNBOUND_GATEWAY_URL is unset, hooks post to http://localhost:8787; a dead endpoint plus fail-open disables gateway PreToolUse checks and cold-cache enforcement, and sends the org API key over cleartext HTTP to loopback (bindable by any local process).
Fix: Restore https://api.getunbound.ai as the default and add a CI guard that production defaults use https://.
Reviewers: Claude, Lead

🟡 TRIAGE

API key passed to curl on the command lineclaude-code/hooks/unbound.py:~2770 (mirrored in augment/hooks/unbound.py, codex/hooks/unbound.py, copilot/hooks/unbound.py, cursor/unbound.py)
Impact: _repo_gate_post puts Authorization: Bearer <key> in Popen argv; on Linux/macOS the key is visible in /proc/*/cmdline and ps to other local users during fire-and-forget reporting.
Fix: Pass the header via child env or curl stdin/config (curl --config - / -H @-), never argv.
Reviewers: Claude, Lead

GIT_DIR / GIT_WORK_TREE env retargeting not resolved for candidatesclaude-code/hooks/unbound.py:~2357 (all five hooks)
Impact: _is_git_command treats GIT_DIR=… git … as gated, but _git_path_opt_targets only parses -C / --git-dir / --work-tree flags; relative env values never become candidates, so enforcement can judge the allowed cwd while git mutates an out-of-scope checkout.
Fix: Parse leading VAR=value assignments for GIT_DIR / GIT_WORK_TREE (and join relative values to reachable cwds), same as flag parsing.
Reviewers: Cursor

Org match ignores remote host (non-GitHub URL accepted)claude-code/hooks/unbound.py:~2270 (_remote_host / _repo_gate_scope_allows; all five hooks)
Impact: Any host with a path like https://attacker.example/<allowed-org>/repo.git resolves to the allowed org segment only, so out-of-scope writes/git can pass the “allowed GitHub org” boundary.
Fix: Require github.com, Enterprise host allowlist, or git@github.com: / git@github.com/ patterns before comparing org.
Reviewers: Cursor, Lead

System-checkout filter can drop real gated targetsclaude-code/hooks/unbound.py:~2377 (_is_system_checkout_path; all five hooks)
Impact: Paths under /opt/homebrew, /usr, etc. are skipped before repo resolution; a deliberate git -C /opt/homebrew/Cellar/... or write there falls back to the in-scope cwd and may never be judged.
Fix: Apply the Homebrew/nix skip only to incidental binary references, not to explicit -C / write targets the gate is evaluating.
Reviewers: Cursor

Gate runs git in model-controlled directoriesclaude-code/hooks/unbound.py:~2285 (_git_origin_url / _git_path_opt_targets; all five hooks)
Impact: Origin lookup executes git -C <parsed-path> remote get-url origin for paths taken from tool input; untrusted .git/config can expand git’s attack surface beyond the session cwd (severity depends on git version/config keys).
Fix: Harden subprocess (GIT_CONFIG_NOSYSTEM=1, -c core.fsmonitor=false, etc.) or read .git/config directly like _find_git_root.
Reviewers: Claude

Previously acknowledged (not re-flagged)

  • Indirect shell invocation (xargs git, sh -c "…", $(), backticks) — PR “Known gaps”; deliberate conservative miss.
  • sudo -u / path-qualified wrappers (/usr/bin/env git, quoted "git") — PR “Known gaps” / “Bias is deliberate”.
  • Cold-cache first gated call unenforced — PR “Known gaps”; prompt path refresh is the intended mitigation.
  • WARN/BLOCK telemetry sends clipped prompt_text / tool_input — PR reporting design; telemetry is intentional on deny flows.

🤖 consensus review · reviewers: Cursor,Claude,Semgrep,Gitleaks · head c294036f · 2026-08-20T15:37Z

Comment thread claude-code/hooks/unbound.py Outdated

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Stale comment

Agentic security review for this repo-gate change found several ways an agent can still write or run git outside the allowed GitHub org, plus an Augment credential leak on process argv. The existing origin-host spoof thread still applies.

Open in Web View Automation 

Sent by Cursor Security Agent: Security Reviewer

Comment thread cursor/unbound.py
candidates.extend(
_git_path_opt_targets(command, _repo_gate_workspace_dir(event))
)
if not candidates:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Agentic Security Review
Severity: HIGH
Cursor shell candidate collection never follows cd, and the other hooks only append the post-cd working directory when the absolute-path list is empty.
A relative cd ../out-of-scope && git commit (Cursor) or the same command plus a decoy absolute token such as /tmp/x (Claude Code/Codex/Copilot/Augment) is judged against the in-scope workspace and allowed.

Fix in Cursor Fix in Web

Reviewed by Cursor Security Reviewer for commit c294036. Configure here.

return out


def _git_path_opt_targets(command, shell_dir):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Agentic Security Review
Severity: HIGH
_is_git_command treats GIT_DIR=… git … / GIT_WORK_TREE=… git … as gated git, but _git_path_opt_targets only parses -C, --git-dir, and --work-tree.
Relative env values are invisible to _ABS_PATH_RE, so candidates fall back to the allowed cwd while git mutates the out-of-scope checkout. Same retarget class as relative git -C, present on all five hooks.

Fix in Cursor Fix in Web

Reviewed by Cursor Security Reviewer for commit c294036. Configure here.

Comment thread codex/hooks/unbound.py
except (TypeError, ValueError):
blob = ''
candidates.extend(
p for p in _ABS_PATH_RE.findall(blob)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Agentic Security Review
Severity: HIGH
Codex apply_patch candidate extraction only scans serialized tool input for absolute paths, then falls back to cwd.
A patch that writes ../other-repo/file from an in-scope working directory is evaluated as the allowed repo and permitted.

Fix in Cursor Fix in Web

Reviewed by Cursor Security Reviewer for commit c294036. Configure here.

words = []
for word in segment.split():
word = word.strip('()`{}"\'')
if not words and (not word or word.startswith('-')

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Agentic Security Review
Severity: MEDIUM
_segment_words strips sudo/env/command and then any leading dash token, but not the argument those flags take.
sudo -u alice git push is parsed with alice as the command word, so _repo_gate_applies is false and out-of-scope git/writes skip the gate entirely.

Fix in Cursor Fix in Web

Reviewed by Cursor Security Reviewer for commit c294036. Configure here.

Comment thread augment/hooks/unbound.py
"""Never waited on, so the blocking path stays free of synchronous work."""
proc = subprocess.Popen(
['curl', '-fsSL', '--max-time', '10', '-X', 'POST',
'-H', 'Authorization: Bearer %s' % api_key,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Agentic Security Review
Severity: MEDIUM
New _repo_gate_post puts Authorization: Bearer <api_key> on the curl argv. The same file already documents that /proc/<pid>/cmdline and ps are world-readable on shared/MDM hosts, and other Augment gateway calls use curl_with_auth to keep the header in a 0600 temp file.
This path reintroduces API-key exposure through the process list.

Fix in Cursor Fix in Web

Reviewed by Cursor Security Reviewer for commit c294036. Configure here.

claude-code's hook had a local dev default of http://localhost:8787 committed
to the branch, which would point every install at the developer's own machine.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@vigneshsubbiah16 vigneshsubbiah16 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🛡️ Automated Security Review (consensus)

14 findings — 5 high-confidence, 9 to triage. Reviewers: Lead, Claude, Semgrep, Gitleaks.

🔴 HIGH

Non-GitHub remotes pass org-only scope check

  • claude-code/hooks/unbound.py:~2295 (same in codex, copilot, cursor, augment)
  • Impact: A checkout whose origin is https://attacker.example/<allowed-org>/repo.git is treated as in-scope because only the path org segment is compared, not the host.
  • Fix: Require an allowed host (e.g. github.com / configured GHE host) before _repo_gate_scope_allows returns true; treat unknown hosts as out-of-scope or unjudged (fail-open only when git truly cannot resolve).
  • Reviewers: Cursor, Lead

Relative GIT_DIR / GIT_WORK_TREE env retargeting bypasses candidate resolution

  • claude-code/hooks/unbound.py:~2367 (_is_git_command vs _git_path_opt_targets; mirrored on all five hooks)
  • Impact: Commands like GIT_DIR=../outside-repo git commit are classified as gated git but candidates fall back to the allowed cwd, so out-of-scope mutations are allowed.
  • Fix: Parse GIT_DIR= / GIT_WORK_TREE= assignments in each segment (resolve relative values against segment cwd) and feed them into the same candidate list as -C / --git-dir.
  • Reviewers: Cursor, Lead

Post-cd cwd dropped when any absolute token is present (decoy-path bypass)

  • claude-code/hooks/unbound.py:~2586 (_tool_use_path_candidates / shell candidate collection; cursor/unbound.py:~1573)
  • Impact: cd ../out-of-scope && git commit combined with a decoy absolute token (e.g. /tmp/x) is judged against the in-scope workspace because post-cd dirs are only appended when the absolute-path list is empty.
  • Fix: Always union the resolved post-cd working directory into candidates for gated segments, regardless of other absolute paths in the command.
  • Reviewers: Cursor, Greptile, Lead

Cursor hook never tracks relative cd before gated shell/git

  • cursor/unbound.py:~1573
  • Impact: From an allowed workspace, cd ../personal && git commit is evaluated against the workspace root and allowed — the personal-repo bypass this PR targets.
  • Fix: Port the shared _next_shell_dir / segment-walker logic used by the other hooks into Cursor shell candidate extraction.
  • Reviewers: Cursor, Lead

Gateway API key visible in curl argv on every WARN/BLOCK report

  • claude-code/hooks/unbound.py:~2745, codex/hooks/unbound.py, copilot/hooks/unbound.py, cursor/unbound.py, augment/hooks/unbound.py:~2127
  • Impact: Authorization: Bearer … in process argv is readable via /proc/<pid>/cmdline and ps for the lifetime of each fire-and-forget report.
  • Fix: Match existing gateway calls: write the header to a 0600 temp file and use curl -H @file / curl_with_auth, not an inline -H 'Authorization: …'.
  • Reviewers: Claude, Cursor

🟡 TRIAGE

Codex apply_patch ignores relative write targets

  • codex/hooks/unbound.py:~1510
  • Impact: Patches naming ../other-repo/file from an in-scope cwd produce no absolute candidate and fall back to cwd, allowing out-of-org writes.
  • Fix: Join relative paths from the patch body to cwd before _find_git_root, same as other write tools.
  • Reviewers: Cursor, Greptile

sudo -u / flag arguments hide the real command word

  • claude-code/hooks/unbound.py:~2510 (_segment_words; mirrored on all hooks)
  • Impact: sudo -u alice git push parses alice as the command word, so _repo_gate_applies skips gating entirely.
  • Fix: After stripping sudo/env/command, also skip option arguments (-u, -E, etc.) before selecting the command word.
  • Reviewers: Cursor

Path-qualified wrappers bypass command-word detection

  • codex/hooks/unbound.py:~1357 (same pattern on all hooks)
  • Impact: /usr/bin/env git push and similar forms leave a non-git command word, so gated git mutations are not in scope for the gate.
  • Fix: Normalize basename of argv0 (env, command, sudo) and unwrap one level before classifying git/write commands (document remaining sh -c gap separately).
  • Reviewers: Cursor

Repository identity uses origin only — alternate remotes on push not judged

  • claude-code/hooks/unbound.py:~2281 (_git_origin_url; all hooks)
  • Impact: From an allowed checkout, git push personal main is allowed because scope is derived only from origin, not the remote actually pushed to.
  • Fix: For git push segments, resolve the named remote URL (git remote get-url <name>, default origin) and judge that org/host.
  • Reviewers: Claude

Policy repositories / include_forks never enforced client-side

  • claude-code/hooks/unbound.py:~2665 (_repo_gate_scope_allows; all hooks)
  • Impact: A policy scoped to specific repos silently grants the whole org; admins may believe narrower scope is enforced.
  • Fix: When repositories is non-empty, require org/repo membership; otherwise drop such policies client-side and refuse to publish unenforceable rows server-side.
  • Reviewers: Claude

WARN/BLOCK telemetry includes clipped raw prompt_text / tool_input

  • claude-code/hooks/unbound.py:~2800 (_repo_gate_report; all hooks)
  • Impact: Blocked commands and paths (potentially secrets) are POSTed to the gateway even when the operation is denied.
  • Fix: Redact or hash sensitive fields; report metadata only (repo, tool, decision, policy id) unless explicitly opted in.
  • Reviewers: Cursor

System-checkout filter can drop real gated targets

  • claude-code/hooks/unbound.py:~2377 (_is_system_checkout_path; all hooks)
  • Impact: Intentional git -C /opt/homebrew/… or writes under filtered roots are skipped as candidates and fall back to allowed cwd.
  • Fix: Apply the Homebrew/nix skip only to incidental path tokens in attribution, not to explicit -C / write targets the command names.
  • Reviewers: Cursor

Pipeline cd misattributes working directory for following git

  • claude-code/hooks/unbound.py:~2390 (_git_path_opt_targets segment walker; all hooks)
  • Impact: cd ../outside | git status treats the pipe-left cd as cwd for the right-hand git command, causing false WARN/BLOCK of work that still runs in the original repo (availability / policy-noise issue).
  • Fix: Do not propagate cd across | segments; only &&, ;, and newline chains change cwd for downstream resolution.
  • Reviewers: Greptile

Semgrep: widely permissive file modes in hook install paths

  • */hooks/unbound.py (e.g. augment/hooks/unbound.py:~2841, claude-code/hooks/unbound.py:~4459)
  • Impact: 0o755 / $BITS permissions on installed artifacts may expose hook binaries or cache dirs to other local users on shared machines.
  • Fix: Tighten install modes to 0o644/0o755 only where executable is required; keep secrets/state at 0600.
  • Reviewers: Semgrep

Previously acknowledged (not re-flagged)

  • Indirect invocation (xargs git, sh -c "…", $(…), backticks) — documented known gap; bias is deliberate miss vs false block (PR DESCRIPTION → Known gaps).
  • Quoted command word ("git" push) — same known gap.
  • 2> err.log not treated as a write redirect — same known gap.
  • First gated tool call in a cold session may run before repo_policies are cached — mitigated by prompt-path refresh; remaining race accepted (PR DESCRIPTION → Known gaps).
  • Relative write paths / relative git -C / basic cd && git chains — addressed in this PR with parity tests (test_a_relative_write_path_is_judged…, test_relative_dash_c_into_an_out_of_scope_repo_is_gated, test_cd_into_an_out_of_scope_repo_is_caught); prior bot threads on those specific bypasses are not re-raised here.

🤖 consensus review · reviewers: Cursor,Claude,Semgrep,Gitleaks · head 64e2c535 · 2026-08-20T15:45Z

Comment thread claude-code/hooks/unbound.py
Gowshik T and others added 2 commits August 20, 2026 21:31
…cds crossing a subshell

Two holes in the separator walk.

`cd /nonexistent && true || git -C ../widgets commit` still escaped: the &&
branch replaced the cwd set with the moved path, so once the chain
short-circuited there was nothing left to resolve against and the target
landed under a directory the shell never entered. A fallback set now carries
what an && chain can fall back to, and || merges it in.

`cd ../outside | git status` blocked work that was never out of scope: a
pipeline (and a background &) runs each side in its own subshell, so the cd
cannot reach the right-hand command. Those separators leave the cwd set alone.

`&&` still narrows to the moved path, so the ordinary chained case stays
precise rather than warning on both.

85 tests. The pipe and background cases fail without this; the &&-chain case
passes either way because the fallback set is seeded with the starting cwd, so
it stands as a regression guard rather than proof.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
df1dba5 swept a local dev value back in via a broad git add. The branch must
not ship a hook defaulting every install to http://localhost:8787.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread claude-code/hooks/unbound.py

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Agentic security review found one net-new repo-gate bypass on naive last-cd attribution. Prior open findings on this PR (host-unconstrained org matching, Cursor cd miss, GIT_DIR retarget, Codex relative apply_patch, sudo flag parsing, and telemetry argv API-key exposure) remain unaddressed and were not duplicated.

Open in Web View Automation 

Sent by Cursor Security Agent: Security Reviewer

Comment on lines +2607 to +2608
if not candidates and shell_dir:
candidates.append(shell_dir)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Agentic Security Review
Severity: MEDIUM
When a shell git/write has no absolute paths and no git -C/--git-dir targets, the gate attributes the call to _next_shell_dir, which takes the last cd in the command and ignores &&/|| short-circuit.
A command such as false && cd /in-scope-org-repo || git commit never actually changes directory, but is judged against the in-scope checkout and allowed while git mutates the original out-of-scope repo. Codex, Copilot, and Augment copy the same fallback; Cursor uses the workspace dir instead.

Fix in Cursor Fix in Web

Reviewed by Cursor Security Reviewer for commit 23f9074. Configure here.

@vigneshsubbiah16 vigneshsubbiah16 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🛡️ Automated Security Review (consensus)

8 findings — 4 high-confidence, 4 to triage. Reviewers: Cursor, Claude, Semgrep, Gitleaks.


🔴 HIGH — Bearer token visible in curl argv

augment/hooks/unbound.py:2149, claude-code/hooks/unbound.py:~2820
Impact: WARN/BLOCK telemetry passes Authorization: Bearer … on the curl command line; on shared hosts /proc/<pid>/cmdline can steal the gateway API key during exactly the calls an attacker cares about.
Fix: Reuse the existing auth-file pattern (curl_with_auth / -H @0600-file or --config); never put the bearer token in argv.
Reviewers: Claude, Cursor


🔴 HIGH — Any remote host can satisfy org scope

claude-code/hooks/unbound.py:2290 (_get_git_origin_org_repo; mirrored on all hooks)
Impact: Once a URL has a host, only the first path segment is compared to github_org, so https://attacker.example/unboundsec/repo.git is treated as in-scope; during WARN, git remote set-url can permanently widen scope.
Fix: Require _remote_host(url) to match an allowlist on the policy (e.g. github.com + configured GHE hostname) before org comparison; include host in incident reports.
Reviewers: Claude, Cursor


🔴 HIGH — Gate policy/state files are outside gate scope

claude-code/hooks/unbound.py:51, claude-code/hooks/unbound.py:216 (REPO_GATE_STATE_FILE, POLICY_CACHE_FILE)
Impact: Paths under ~/.claude/hooks/ (and equivalents) have no git root, so writes/rm against .policy_cache.json or .repo_gate_state.json stay allowed even while the session is blocked — emptying repo_policies or resetting grace disables enforcement.
Fix: Explicitly deny candidates under the hook config directory in _repo_gate_applies, independent of git-root resolution; optionally authenticate cached policies.
Reviewers: Claude


🔴 HIGH — GIT_DIR / GIT_WORK_TREE env retargeting not resolved

claude-code/hooks/unbound.py:2357 (_git_path_opt_targets; all five hooks)
Impact: _is_git_command gates GIT_DIR=… git …, but candidate extraction only parses -C / --git-dir / --work-tree flags; relative env values miss _ABS_PATH_RE, so enforcement falls back to the allowed cwd while git mutates an out-of-scope checkout.
Fix: Parse leading VAR=value assignments for git directory env vars and join relative values against segment cwd(s), same as -C handling.
Reviewers: Cursor


🟡 TRIAGE — sudo -u hides the real gated command

claude-code/hooks/unbound.py:2532 (_segment_words; all hooks)
Impact: sudo -u alice git push / sudo -u alice rm … leaves alice as the command word, so _repo_gate_applies is false and out-of-scope git/writes skip the gate.
Fix: When stripping sudo/env/command, also skip flag arguments (-u, -E, etc.) until the actual command word.
Reviewers: Cursor


🟡 TRIAGE — Codex apply_patch ignores relative write paths

codex/hooks/unbound.py:1532
Impact: Candidate extraction scans serialized patch input for absolute paths only, then falls back to cwd, so ../other-repo/file from an in-scope directory is judged in-scope and allowed.
Fix: Join relative patch paths to cwd before repo resolution (parity with Claude Code / Augment write handling).
Reviewers: Cursor


🟡 TRIAGE — Shell cwd resolution: decoy absolute path / Cursor cd gap

cursor/unbound.py:1595, claude-code/hooks/unbound.py:2608
Impact: Collectors prefer absolute tokens in the command line and only append post-cd cwd when the list is empty; /tmp/x && cd ../out-of-scope && git commit (or Cursor not following relative cd) can be judged against the in-scope workspace.
Fix: Always union post-cd resolved directories with absolute-path candidates; ensure Cursor follows relative cd like the other hooks.
Reviewers: Cursor, Greptile


🟡 TRIAGE — WARN/BLOCK telemetry carries sensitive command/prompt text

claude-code/hooks/unbound.py:~2820 (_repo_gate_report; all hooks)
Impact: Blocked operations still POST clipped prompt_text and tool_input to /v1/hooks/pretool, exposing paths/commands/secrets in denial telemetry.
Fix: Report metadata only (tool name, repo, policy id, decision); redact or omit raw prompt/command bodies on BLOCK.
Reviewers: Cursor


Previously acknowledged (not re-flagged)

  • Indirect invocation (xargs git, sh -c "…", $(), backticks) — PR “Known gaps”; deliberate conservative miss.
  • Quoted command word ("git" push) — PR “Known gaps”.
  • Cold-cache first gated call — PR “Known gaps”; fail-open by design.
  • 2> err.log not treated as a write — PR “Known gaps”.
  • Pipeline / background cd does not propagate cwd — covered by tests as intentional conservative behavior (cd … | git … allowed).
  • System checkout roots (/opt/homebrew, etc.) skipped — WEB-5433 design choice to avoid attributing incidental paths to homebrew/brew.
  • Fail-open on missing git, corrupt cache/state, malformed policy — explicit product decision in PR description.
  • Semgrep sqlalchemy-execute-raw-query on cursor/unbound.py:534 — pre-existing/unrelated to this diff; treated as scanner false positive.

🤖 consensus review · reviewers: Cursor,Claude,Semgrep,Gitleaks · head 23f9074b · 2026-08-20T16:13Z

@gowshik450526511

Copy link
Copy Markdown
Contributor Author

@greptile Update the confidence score.

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