[WEB-5456] Repo gate: block writes and git commands outside the allowed GitHub org - #243
[WEB-5456] Repo gate: block writes and git commands outside the allowed GitHub org#243gowshik450526511 wants to merge 10 commits into
Conversation
…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>
| 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): |
There was a problem hiding this comment.
🔒 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.
Reviewed by Cursor Security Reviewer for commit 0fd5210. Configure here.
| 'tool_name': context.get('tool_name'), | ||
| 'session_id': context.get('session_id'), | ||
| 'turn': turn, | ||
| 'prompt_text': _repo_gate_clip(context.get('prompt_text')), |
There was a problem hiding this comment.
🔒 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.
Reviewed by Cursor Security Reviewer for commit 0fd5210. Configure here.
vigneshsubbiah16
left a comment
There was a problem hiding this comment.
🛡️ 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
| if isinstance(path, str) and path.startswith('/') \ | ||
| and not _is_system_checkout_path(path): | ||
| return [os.path.dirname(path)] | ||
| return [] |
There was a problem hiding this comment.
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.
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) |
There was a problem hiding this comment.
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)
Reviewed by Cursor Bugbot for commit 0fd5210. Configure here.
| or word in _COMMAND_PREFIX_WORDS): | ||
| continue | ||
| words.append(word) | ||
| return words |
There was a problem hiding this comment.
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)
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>
| word = word.strip('()`{}"\'') | ||
| if not words and (not word or word.startswith('-') | ||
| or _ENV_ASSIGNMENT_RE.match(word) | ||
| or word in _COMMAND_PREFIX_WORDS): |
There was a problem hiding this comment.
🔒 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.
Reviewed by Cursor Security Reviewer for commit b2558e8. Configure here.
There was a problem hiding this comment.
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).
❌ 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.
| ) | ||
| if not candidates and cwd: | ||
| candidates.append(cwd) | ||
| return candidates |
There was a problem hiding this comment.
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.
Reviewed by Cursor Bugbot for commit b2558e8. Configure here.
vigneshsubbiah16
left a comment
There was a problem hiding this comment.
🛡️ 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.lognot 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/$BITSpatterns) — 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
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
left a comment
There was a problem hiding this comment.
🛡️ 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.lognot 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.pyetc.) — Addressed in b2558e8; Greptile re-review reports no blocking failure remains. - Semgrep
insecure-file-permissions(0o755) — Pre-existing hook installchmodpattern 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
`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>
vigneshsubbiah16
left a comment
There was a problem hiding this comment.
🛡️ 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/ relativecdescapes (without chainedcd+-C) — addressed in this PR with path resolution,_git_path_opt_targets, and parity tests; not re-raised. - Semgrep
insecure-file-permissions/ SQL concatenation oncursor/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
There was a problem hiding this comment.
Stale comment
Agentic security review of the repo-scope gate. Two net-new issues: relative
GIT_DIR/GIT_WORK_TREEretargets (HIGH) and system-checkout skip reused on enforcement targets (MEDIUM). Existing threads covering Cursorcd,sudo -u/ path-qualified wrappers, absolute-path hiding of post-cddirs, Codexapply_patchrelatives, spoofable remote hosts, and telemetry payloads remain as previously reported.Sent by Cursor Security Agent: Security Reviewer
`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>
Bugbot needs on-demand usage enabledBugbot 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
left a comment
There was a problem hiding this comment.
🛡️ 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_URLishttp://localhost:8787instead ofhttps://api.getunbound.ai; installs without the env var miss gateway policy checks (fail-openallow), never loadrepo_policies, and may sendAuthorization: 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_commandgatesGIT_DIR=… git …/GIT_WORK_TREE=… git …, but_git_path_opt_targetsonly parses-C/--git-dir/--work-treeflags; 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_wordsdropssudoand leading flags but not their arguments, sosudo -u alice git push/sudo -u alice rm …classifiesaliceas 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_postpassesAuthorization: Bearer <key>via-Hargv; on shared hosts/containers,/proc/<pid>/cmdlineandpscan 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_allowsmatches onlygithub_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) andinclude_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>.tmpwith default umask beforeos.replace; in permissive$HOMElayouts, a local writer could race or tamper with grace counters. - Fix: Use
tempfile.mkstemp(..., dir=…)with mode0600andchmodthe final state file to0600. - 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_pathdrops/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-cdordering — addressed in this PR (_git_path_opt_targetssegment 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
`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>
Bugbot needs on-demand usage enabledBugbot 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
left a comment
There was a problem hiding this comment.
🛡️ 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 line — claude-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 candidates — claude-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 targets — claude-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 directories — claude-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
There was a problem hiding this comment.
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.
Sent by Cursor Security Agent: Security Reviewer
| candidates.extend( | ||
| _git_path_opt_targets(command, _repo_gate_workspace_dir(event)) | ||
| ) | ||
| if not candidates: |
There was a problem hiding this comment.
🔒 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.
Reviewed by Cursor Security Reviewer for commit c294036. Configure here.
| return out | ||
|
|
||
|
|
||
| def _git_path_opt_targets(command, shell_dir): |
There was a problem hiding this comment.
🔒 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.
Reviewed by Cursor Security Reviewer for commit c294036. Configure here.
| except (TypeError, ValueError): | ||
| blob = '' | ||
| candidates.extend( | ||
| p for p in _ABS_PATH_RE.findall(blob) |
There was a problem hiding this comment.
🔒 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.
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('-') |
There was a problem hiding this comment.
🔒 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.
Reviewed by Cursor Security Reviewer for commit c294036. Configure here.
| """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, |
There was a problem hiding this comment.
🔒 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.
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
left a comment
There was a problem hiding this comment.
🛡️ 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 incodex,copilot,cursor,augment)- Impact: A checkout whose
originishttps://attacker.example/<allowed-org>/repo.gitis 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_allowsreturns 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_commandvs_git_path_opt_targets; mirrored on all five hooks)- Impact: Commands like
GIT_DIR=../outside-repo git commitare 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 commitcombined with a decoy absolute token (e.g./tmp/x) is judged against the in-scope workspace because post-cddirs are only appended when the absolute-path list is empty. - Fix: Always union the resolved post-
cdworking 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 commitis 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>/cmdlineandpsfor the lifetime of each fire-and-forget report. - Fix: Match existing gateway calls: write the header to a
0600temp file and usecurl -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/filefrom 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
cwdbefore_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 pushparsesaliceas the command word, so_repo_gate_appliesskips 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 pushand similar forms leave a non-gitcommand 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 remainingsh -cgap 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 mainis allowed because scope is derived only fromorigin, not the remote actually pushed to. - Fix: For
git pushsegments, resolve the named remote URL (git remote get-url <name>, defaultorigin) 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
repositoriesis non-empty, requireorg/repomembership; 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_targetssegment walker; all hooks)- Impact:
cd ../outside | git statustreats the pipe-leftcdas 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
cdacross|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/$BITSpermissions on installed artifacts may expose hook binaries or cache dirs to other local users on shared machines. - Fix: Tighten install modes to
0o644/0o755only where executable is required; keep secrets/state at0600. - 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.lognot treated as a write redirect — same known gap.- First gated tool call in a cold session may run before
repo_policiesare cached — mitigated by prompt-path refresh; remaining race accepted (PR DESCRIPTION → Known gaps). - Relative write paths / relative
git -C/ basiccd && gitchains — 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
…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>
There was a problem hiding this comment.
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.
Sent by Cursor Security Agent: Security Reviewer
| if not candidates and shell_dir: | ||
| candidates.append(shell_dir) |
There was a problem hiding this comment.
🔒 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.
Reviewed by Cursor Security Reviewer for commit 23f9074. Configure here.
vigneshsubbiah16
left a comment
There was a problem hiding this comment.
🛡️ 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.lognot treated as a write — PR “Known gaps”.- Pipeline / background
cddoes 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 tohomebrew/brew. - Fail-open on missing git, corrupt cache/state, malformed policy — explicit product decision in PR description.
- Semgrep
sqlalchemy-execute-raw-queryoncursor/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
|
@greptile Update the confidence score. |




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-dataandunbound-fe.What is and isn't gated
gitcommandsrm,mv,sed -i,>)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
originremote — 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.The added fork/exec is below the noise floor; the gate's own
gitcall dominates.Per-hook behaviour
UserPromptSubmitprompt_idUserPromptSubmitturn_idbeforeSubmitPromptgeneration_idUserPromptSubmitAugment 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,envprefixes are stripped.rm auth.pyrmcd /repo && git commitsed -i s/a/b/ f.pyecho x > out.txtgrep -r "rm -rf" .echo "a && git push"sed -n '1,10p' f.pynpm test 2>&1Bias 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 whosewait()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
xargs git commit,sh -c "rm x",$(…), backticks"git" push2> err.logis not treated as a writeTests
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::TestMatcherParityAcrossTreesandcursor/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
gitinvocations, 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
originlocally, and never calls the gateway on a block. Policies come fromrepo_policiesin 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.
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
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 --> JReviews (10): Last reviewed commit: "[WEB-5456] Restore the production gatewa..." | Re-trigger Greptile
Context used (5)
unbound-hookbinary CLI (binary/)