feat(WEB-4974): Per-tool-call project attribution across all coding-agent hooks#214
feat(WEB-4974): Per-tool-call project attribution across all coding-agent hooks#214gowshik450526511 wants to merge 6 commits into
Conversation
Add cwd and project (GitHub org/repo) to PreToolUse, UserPromptSubmit, and StopEvent hook requests. Includes helper functions to resolve GitHub origin from git remotes. Enables better analytics, policy scoping, and debugging by tracking which repository and working context triggered each event. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
| if '://' in url: | ||
| # scheme://[user@]host/ORG/repo(.git) | ||
| after_scheme = url.split('://', 1)[1] | ||
| path = after_scheme.split('/', 1)[1] if '/' in after_scheme else '' | ||
| elif ':' in url: | ||
| # scp-like: [user@]host:ORG/repo(.git) | ||
| path = url.split(':', 1)[1] | ||
| else: | ||
| return None |
There was a problem hiding this comment.
Non-GitHub Remotes Become GitHub Projects
When origin points at GitLab, Bitbucket, or another host with an org/repo path, this parser still returns that path and _get_project sends it as the project value. A repo like git@gitlab.com:acme/private.git is reported as acme/private, so downstream analytics or project-scoped policy can treat it as the unrelated GitHub repo with the same owner and name.
| result = subprocess.run( | ||
| ['git', '-C', cwd, 'remote', 'get-url', 'origin'], | ||
| capture_output=True, text=True, timeout=10, |
There was a problem hiding this comment.
Every PreToolUse and UserPromptSubmit call now runs git -C <cwd> remote get-url origin before sending the hook request. If the worktree is on a slow network mount or the .git directory blocks, each interactive hook can wait up to 10 seconds before failing open, making normal tool use appear hung.
vigneshsubbiah16
left a comment
There was a problem hiding this comment.
🛡️ Automated Security Review (consensus)
4 findings — 2 high-confidence, 2 to triage. Reviewers: Cursor, Claude, Semgrep, Gitleaks.
🔴 HIGH — Unverified project identifier unsafe for policy scoping
- File:
claude-code/hooks/unbound.py:1476-1541 - Impact:
projectis derived client-side fromgit remote get-url originwithout verifyinggithub.com; non-GitHub remotes (GitLab/Bitbucket) and colon-containing local paths (e.g.C:/...) are parsed into GitHub-shapedorg/repovalues, enabling policy evasion viagit remote set-urlor cross-host name collisions. - Fix: Treat
projectas advisory analytics metadata on the server, not a policy principal; in_github_remote_path/_get_project, returnNoneunless the host is exactlygithub.com, and reject local/file:///drive-prefix remotes before SCP-style:splitting. - Flagged by: Claude, Greptile, Cursor
🔴 HIGH — Raw local filesystem paths sent on every hook request
- File:
claude-code/hooks/unbound.py:1390,1566,1657 - Impact: Full
cwdvalues (e.g./home/alice/clients/acme-secret) are attached to every PreToolUse, UserPromptSubmit, and Stop payload, continuously exposing usernames, client names, and internal project codenames to the external hook API. - Fix: If repository context suffices, send only normalized
project; otherwise redactcwd(basename, strip home prefix) and document collection in data-handling notes. - Flagged by: Claude, Greptile
🟡 TRIAGE — Synchronous git subprocess on interactive hook path
- File:
claude-code/hooks/unbound.py:1526-1529 - Impact: Every PreToolUse and UserPromptSubmit blocks on
git -C <cwd> remote get-url origin(10s timeout); slow or hung worktrees can stall tool use and silently drop project context on timeout. - Fix: Lower timeout to ~1–2s, cache
cwd → projectper session, and run git lookup off the critical path where possible. - Flagged by: Claude, Greptile
🟡 TRIAGE — Stop exchange uses first-seen cwd, not latest
- File:
claude-code/hooks/unbound.py:1595-1658 - Impact:
build_llm_exchangecaptures the first non-emptycwdand never updates it; if the working directory changes mid-session, Stop analytics andprojectattribution can target the wrong repository. - Fix: Prefer the
cwdfrom the Stop event (or the most recent event) when resolvingprojectfor the exchange payload. - Flagged by: Cursor
Semgrep reported pre-existing insecure-file-permissions hits at lines 1892/2192 (unchanged by this diff). Gitleaks: no secrets detected.
🤖 consensus review · reviewers: Cursor,Claude,Semgrep,Gitleaks · head 8e49fb58 · 2026-07-12T10:42Z
Use cwd captured at session start (_get_session_cwd) instead of
per-request event.get('cwd') for more stable project tracking across
the entire conversation.
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
| 'model': model, | ||
| 'event_name': 'tool_use', | ||
| 'cwd': session_cwd, | ||
| 'project': _get_project(session_cwd), |
There was a problem hiding this comment.
Project Lookup Still Blocks This still derives
project inside process_pre_tool_use, and _get_project runs git -C <cwd> remote get-url origin with a 10 second timeout. The new cache stores only the cwd string, not the derived project, so a slow or blocked worktree can still make every tool invocation wait on git before the hook request is sent. Cache the project value itself or move this lookup out of the blocking hook path.
| else: | ||
| return None | ||
| path = path.strip('/') | ||
| return path or None |
There was a problem hiding this comment.
Non-GitHub remotes mislabeled
Medium Severity
_github_remote_path and _get_project derive org/repo from any origin URL without checking that the host is GitHub. Remotes such as GitLab or Bitbucket can be sent as GitHub-shaped project values, skewing analytics and GitHub-scoped policy.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit a64168c. Configure here.
vigneshsubbiah16
left a comment
There was a problem hiding this comment.
🛡️ Automated Security Review (consensus)
5 findings — 4 high-confidence, 1 to triage. Reviewers: Cursor, Claude, Semgrep, Gitleaks.
🔴 HIGH — Client-spoofable project identifier for policy scoping
claude-code/hooks/unbound.py:1544
Impact: project is derived entirely client-side from git remote get-url origin with no host validation; any worktree can report an arbitrary org/repo (including non-GitHub remotes parsed as GitHub-shaped paths) and inherit or evade project-scoped gateway policy.
Fix: Never use client-reported project as a policy key; validate github.com (or enterprise host) in _github_remote_path and enforce scoping server-side with an authenticated allowlist.
Flagged by: Claude, Greptile, Cursor, Lead
🔴 HIGH — Session cwd/project silently absent on most hook invocations
claude-code/hooks/unbound.py:2242
Impact: _SESSION_CWD_CACHE is in-memory in a process spawned per hook event, so SessionStart writes are invisible to later PreToolUse/UserPromptSubmit/Stop runs; cwd/project are usually None, causing project-scoped controls to silently never match with no failure signal.
Fix: Persist session cwd and resolved project to disk (keyed by session_id, alongside existing session state); treat missing project as an explicit restrictive bucket; log resolution errors instead of except Exception: return None.
Flagged by: Claude, Cursor, Lead
🔴 HIGH — Raw local filesystem paths exfiltrated on every hook event
claude-code/hooks/unbound.py:1392
Impact: Full cwd values (e.g. /home/alice/clients/acme-secret) are sent to the external hook API on every tool use, prompt, and stop event, exposing usernames, customer names, and internal project codenames.
Fix: If project satisfies analytics/scoping needs, omit cwd from gateway payloads or send a redacted/hashed form only.
Flagged by: Greptile, Claude, Lead
🔴 HIGH — Synchronous git subprocess on interactive hook path (10s cap)
claude-code/hooks/unbound.py:1535
Impact: _get_project shells out to git -C <cwd> remote get-url origin on every PreToolUse/UserPromptSubmit/Stop call; slow or blocked worktrees can delay tool gating for up to 10s per invocation (local stall/DoS on the PreToolUse gate).
Fix: Resolve and cache project once at SessionStart (persisted across processes), or move lookup off the blocking hook path entirely.
Flagged by: Greptile, Cursor
🟡 TRIAGE — Stop events may attribute wrong repository after mid-session cwd change
claude-code/hooks/unbound.py:1661
Impact: build_llm_exchange uses session-start cwd only; if the working directory changes mid-conversation, stop-event project can mislabel the turn for analytics and policy attribution.
Fix: Prefer the Stop event's cwd or the latest audit-entry cwd when building the exchange payload.
Flagged by: Cursor
🤖 consensus review · reviewers: Cursor,Claude,Semgrep,Gitleaks · head a64168c4 · 2026-07-13T06:34Z
…gent hooks - claude-code/codex/copilot/cursor: resolve the git project (org/repo) per tool call (file path / shell-dir tracking) and ride it on each tool_use entry; send turn-level cwd + project on the Stop exchange - augment: add the same per-call resolver (_project_for_tool_use, .git walk, cached origin lookups) and cwd/project on the Stop exchange Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
# Conflicts: # copilot/hooks/unbound.py
The cd-target regex boundary lacked \n, so "git add .\ncd /repoB\nmake" never updated the tracked shell dir and later calls were attributed to the old repo. Applied to claude-code, codex, copilot, and augment hooks. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
vigneshsubbiah16
left a comment
There was a problem hiding this comment.
🛡️ Automated Security Review (consensus)
5 findings — 1 high-confidence, 4 to triage. Reviewers: Cursor, Claude, Semgrep, Gitleaks.
Client-derived project is spoofable — unsafe for policy scoping
Severity: Medium · Confidence: 🔴 HIGH
Location: claude-code/hooks/unbound.py:1537 (mirrored in augment/hooks/unbound.py, codex/hooks/unbound.py, copilot/hooks/unbound.py, cursor/unbound.py)
Impact: project is derived client-side from paths in model-controlled tool args/commands and whatever origin exists there; an injected agent or user can git remote set-url or plant a .git tree and have calls attributed to a trusted org/repo, bypassing project-scoped policy if the server trusts this field.
Fix: Treat project/cwd as untrusted telemetry server-side; on the client, require github.com (or your canonical host) before emitting org/repo.
Flagged by: Claude, Cursor, Greptile
git -C runs against model-influenced directories
Severity: Low · Confidence: 🟡 TRIAGE
Location: claude-code/hooks/unbound.py:1632 (_project_for_tool_use → _get_project; same pattern in all five hooks)
Impact: Absolute paths extracted from shell/MCP arguments can trigger git -C <path> remote get-url inside attacker-planted repos; remote get-url is read-only today, but this establishes a pattern of invoking git in untrusted trees each turn.
Fix: Restrict candidates to session/workspace roots, or read .git/config directly instead of shelling out to git.
Flagged by: Claude
Unbounded path candidates can stall Stop processing
Severity: Low · Confidence: 🟡 TRIAGE
Location: claude-code/hooks/unbound.py:1560 (_ABS_PATH_RE → _find_git_root / _get_project; mirrored in codex/copilot/cursor)
Impact: Commands or JSON args with many distinct absolute-path tokens can drive unbounded ancestor walks and up to one 10s git subprocess per distinct repo root on the Stop path, delaying audit forwarding (fail-open does not cap latency).
Fix: Cap candidates per tool call (e.g. 3–5), cap root_projects lookups per turn, and lower the git timeout for this best-effort path.
Flagged by: Claude
Cursor shell events do not track cd across the turn
Severity: Medium · Confidence: 🟡 TRIAGE
Location: cursor/unbound.py:1278
Impact: afterShellExecution uses per-event cwd, command absolute paths, or workspace_roots, but never updates a running shell directory after a cd-only command; later shell events without cwd can mis-attribute project to the workspace root.
Fix: Port _CD_TARGET_RE / _next_shell_dir shell-dir tracking used in the other hooks.
Flagged by: Cursor
Claude Code file tools ignore workspace-relative paths
Severity: Medium · Confidence: 🟡 TRIAGE
Location: claude-code/hooks/unbound.py:1614
Impact: Read/Write/Edit/Grep/Glob attribution only considers absolute paths, while Bash already tracks shell cd; relative file paths after cd leave per-call project unset and can fall back to the wrong turn-level repo.
Fix: Join relative file paths onto shell_dir (as Augment/Copilot already do) before _find_git_root.
Flagged by: Cursor
Previously acknowledged (not re-flagged)
file:/// colon-split remote parsing (e.g. WindowsC:/…→ junkorg/repo) — known follow-up in PR description (file://remotes parse to junk org/repo).- Raw local
cwdsent to the hook API — accepted design; per-toolcwd+projectattribution is the PR’s stated purpose. - Absolute-path candidates outside any repo suppress shell-dir fallback — intentional “report facts” behavior per PR review notes.
- Augment models launch-process as a persistent shell — known accuracy follow-up (non-blocking) per PR description.
🤖 consensus review · reviewers: Cursor,Claude,Semgrep,Gitleaks · head b94abcce · 2026-07-15T08:36Z
# Conflicts: # claude-code/hooks/unbound.py # cursor/unbound.py
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 3 potential issues.
There are 4 total unresolved issues (including 1 from previous review).
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 5a8efff. Configure here.
| _ABS_PATH_RE = re.compile(r'(?:^|[\s"\'=(])(/[^\s"\';|&<>()]+)') | ||
| # `cd <target>` occurrences — absolute, ~-rooted, or relative — used to track | ||
| # the persistent shell's working directory across the turn's Bash calls. | ||
| _CD_TARGET_RE = re.compile(r'(?:^|[;&|\n]\s*|\bthen\s+|\bdo\s+)cd\s+(["\']?)([^\s"\';|&]+)\1') |
There was a problem hiding this comment.
Chained cd after && missed
Medium Severity
The new _CD_TARGET_RE only treats cd as starting a segment after ;, a single |, &, newline, or shell keywords—not after && or ||. Commands like npm test && cd packages/app never update the tracked shell directory, so later Bash calls and fallbacks can be attributed to the wrong repo.
Additional Locations (2)
Reviewed by Cursor Bugbot for commit 5a8efff. Configure here.
| candidates.extend(_ABS_PATH_RE.findall(command)) | ||
| 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.
Relative Codex workdir mis-resolved
Medium Severity
For exec_command, a non-empty relative workdir is pushed onto candidates as-is, which prevents appending the tracked shell_dir. _find_git_root then resolves that path against the hook process cwd, not the session cwd, so attribution can point at the wrong repository or miss entirely.
Reviewed by Cursor Bugbot for commit 5a8efff. Configure here.
| 'tool_use_id': _resolve_tool_use_id(event), | ||
| 'project': _project_for_paths( | ||
| [os.path.dirname(file_path)] if isinstance(file_path, str) and file_path.startswith('/') else [], | ||
| root_projects) |
There was a problem hiding this comment.
Cursor skips relative file paths
Medium Severity
Per-call project resolution for beforeReadFile and afterFileEdit only runs when file_path is absolute. Workspace-relative paths produce no candidates, so those tool rows get no project even though workspace_cwd is already captured for the turn.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 5a8efff. Configure here.
| if not candidates and shell_dir: | ||
| candidates.append(shell_dir) | ||
| for candidate in candidates: | ||
| root = _find_git_root(candidate) |
There was a problem hiding this comment.
🔒 Agentic Security Review
Severity: MEDIUM
_project_for_tool_use accepts model/tool-controlled path candidates (from file_path, absolute command tokens, and tracked shell directory) and immediately resolves git roots before running git -C <root> remote get-url origin, without enforcing that candidates stay inside allowed workspace roots.
This can attribute and emit project values from repositories outside the intended workspace boundary when tool inputs reference external paths.
Reviewed by Cursor Security Reviewer for commit 5a8efff. Configure here.
vigneshsubbiah16
left a comment
There was a problem hiding this comment.
🛡️ Automated Security Review (consensus)
1 finding — 0 high-confidence, 1 to triage. Reviewers: Cursor, Claude, Semgrep, Gitleaks.
[MEDIUM] Per-call project spoofable via shell command path tokens
claude-code/hooks/unbound.py:1773 (same pattern: codex/hooks/unbound.py:1265, copilot/hooks/unbound.py:1510, augment/hooks/unbound.py:1478, cursor/unbound.py:1348)
🟡 TRIAGE — For Bash/shell tools, _ABS_PATH_RE.findall(command) is consulted before the tracked shell_dir fallback; the command string is agent-controlled. A prompt-injected or misbehaving agent can prepend a benign absolute path (e.g. : /home/u/benign-repo/x; …) so per-call project and downstream analytics/policy scoping record activity under the wrong repo.
Fix: Treat command-scraped paths as lowest-confidence — prefer Codex workdir, hook-event cwd, and tracked shell_dir over the first absolute token in the command; optionally attach a resolution-source field so the backend can down-rank command-token attributions in policy decisions.
Flagged by: Claude
Previously acknowledged (not re-raised)
- Raw local
cwdsent to the hook API — PR design: turn-levelcwd+projectare intentional attribution fields. file://, SCP-colon, and non-GitHuboriginURLs → junk or GitHub-shapedorg/repo— Known accuracy follow-up in PR description (non-blocking).- Augment models launch-process as a persistent shell — Known accuracy follow-up; per-event cwd preferred later.
- Absolute-path candidates outside any repo suppress
shell_dirfallback — By design (“report facts”). &&/||chainedcd, relative file/workdir paths, missing shell-dir tracking, Codex relativeworkdirresolution — Attribution accuracy gaps on the Stop-only path; already flagged by prior bot threads, not security regressions.- Semgrep
insecure-file-permissions(0o755/$BITS) andsqlalchemy-execute-raw-query(cursor/unbound.py:495) — Pre-existing patterns outside this PR’s diff (executable hook install + local DB helper); not introduced or worsened here. - Gitleaks — No secrets detected.
🤖 consensus review · reviewers: Cursor,Claude,Semgrep,Gitleaks · head 5a8efff2 · 2026-07-23T10:12Z


What
Every hook (claude-code, cursor, copilot, codex, augment) now resolves the git project ("org/repo") per tool call and sends turn-level
cwd+projecton the Stop exchange..git(worktree-aware, filesystem-only) →git remote get-url origin(10s-bounded, cached once per repo per turn) → lowercasedorg/repo. Fully fail-open —Noneon any failure, never raises/blocks.cdtracking (abs/relative/~, now including multi-line commands) and the tracked dir as fallback; Codex prefers its explicitworkdir.cd.UNBOUND_GATEWAY_URLdefaults verified ashttps://api.getunbound.aiin all five hooks.Review notes
Elite-pr-review pass: approved; fixed the multi-line
cdregex gap it found. Known follow-ups (accuracy, non-blocking): Augment models launch-process as a persistent shell (prefer per-event cwd later); abs-path candidates outside any repo suppress the shell-dir fallback by design ("report facts");file://remotes parse to junk org/repo.Tests
113 passed (copilot hooks + binary suites); all five hooks byte-compile.
🤖 Generated with Claude Code
Note
Medium Risk
Adds bounded
gitsubprocess calls and path heuristics on the Stop path only; failures are fail-open but wrong repo attribution is possible in edge cases (multi-repo turns, non-GitHub remotes).Overview
Adds per-tool-call
project(org/repofromgit origin) and turn-levelcwd+projecton Stop/audit payloads across augment, claude-code, codex, copilot, and cursor hooks.Resolution is shared in spirit: walk paths to a
.gitroot, cachegit remote get-url originper repo per turn, and fail open withNone. File tools use paths (relative paths join the tracked shell dir where needed); shell tools use absolute path tokens, optional Codexworkdir, andcdtracking (including multi-line commands). Claude Code splits read vs write tool sets; Copilot still advances shell dir on dedup-skipped shell calls; Augment gets attribution for the first time.Each tool entry in the exchange can carry its own
project; the exchange-levelprojectis a fallback for prompt-only or tool-less rows.Reviewed by Cursor Bugbot for commit 5a8efff. Bugbot is set up for automated code reviews on this repo. Configure here.
Greptile Summary
This PR adds per-tool-call project attribution across the coding-agent hooks. The main changes are:
cwdandprojectfields on Stop exchanges.Confidence Score: 5/5
This looks safe to merge.
Important Files Changed
Reviews (4): Last reviewed commit: "Merge remote-tracking branch 'origin/sta..." | Re-trigger Greptile
Context used:
Learned From
websentry-ai/ai-gateway-data#448