WEB-5030: report Cowork hook sessions as unbound_app_label='cowork'#202
Conversation
One hook script serves both Claude Code and Cowork. Detect Cowork via the desktop env markers (CLAUDE_CODE_IS_COWORK=1 / CLAUDE_CODE_ENTRYPOINT in local-agent|local_agent|remote_cowork) with the local-agent-mode-sessions sandbox path (cwd/transcript_path) as a fallback for builds predating those env vars. Send the label on pre_tool, user_prompt, and the end-of-turn exchange (the exchange previously sent no app label). Falls back to 'claude-code'; fail-open. Consolidates and supersedes #197 (adds the env-var + path detection, and the end-of-turn exchange label that #197 omitted). Verified against real captured events from CLI, desktop, and Cowork. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RjaRWNPjy4N7KRQUykQape
vigneshsubbiah16
left a comment
There was a problem hiding this comment.
🛡️ Automated Security Review (consensus)
3 findings — 2 high-confidence, 1 to triage. Reviewers: Cursor, Claude, Semgrep, Gitleaks.
Client-controlled unbound_app_label can skew gateway policy
Severity: MEDIUM · Confidence: 🔴 HIGH
Location: claude-code/hooks/unbound.py:1271
Impact: cowork vs claude-code on the pre-tool policy path is derived from process env (CLAUDE_CODE_IS_COWORK, CLAUDE_CODE_ENTRYPOINT) and a raw substring match on cwd/transcript_path; a user can influence those inputs (e.g. export env vars or work under a path containing local-agent-mode-sessions) and may receive laxer label-scoped policy if the gateway treats the label as authoritative.
Fix: Server-side, treat unbound_app_label as a hint—apply the stricter policy when labels diverge or corroborate with server-observable signals; client-side, prefer anchored path-segment matching over substring checks and document that env markers are authoritative only when set by the desktop host.
Flagged by: Cursor, Claude
Deploying hook before gateway support can silently weaken enforcement
Severity: MEDIUM · Confidence: 🟡 TRIAGE
Location: claude-code/hooks/unbound.py:1368
Impact: Cowork sessions previously evaluated under claude-code are now sent as cowork; older gateways drop unknown labels from label-keyed policy maps, so label-scoped controls stop matching with no client-visible error—a silent downgrade rather than a hard failure.
Fix: Roll out gateway support first (or gate the new label on a capability check); alternatively have the gateway map unknown labels to claude-code instead of dropping them.
Flagged by: Claude
Silent fail-open in label detection obscures misclassification
Severity: LOW · Confidence: 🔴 HIGH
Location: claude-code/hooks/unbound.py:1284
Impact: Env-marker reads are wrapped in a broad except Exception: pass and no branch logs which label was chosen; regressions (typo'd env name, unexpected errors) would misclassify Cowork as claude-code with no field trace, complicating policy-incident investigation.
Fix: Narrow the exception scope (or remove it if env reads cannot raise) and emit a debug-level log stating which branch produced the label.
Flagged by: Claude, Greptile
Clean scans: Gitleaks reported no secrets. Semgrep insecure-file-permissions hits at unbound.py:1783 and :2083 are outside this diff (pre-existing) and were not re-flagged.
🤖 consensus review · reviewers: Cursor,Claude,Semgrep,Gitleaks · head 55d5e27e · 2026-07-03T10:03Z
Add negative assertions that unrecognized CLAUDE_CODE_ENTRYPOINT values and a non-'1' CLAUDE_CODE_IS_COWORK still resolve to 'claude-code', so a future broadening of the Cowork membership check fails loudly. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RjaRWNPjy4N7KRQUykQape
Cut a new binary version so the cowork-aware hook ships in the signed macOS runtime. hook (binary/src/unbound_hook/__init__.py) and discovery (packaging/unbound_discovery_entry.py) versions stay in lockstep per the release workflow's --version assert. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RjaRWNPjy4N7KRQUykQape
🛡️ Automated Security Review (consensus)0 findings — 0 high-confidence, 0 to triage. Reviewers: Cursor, Claude, Semgrep, Gitleaks. No actionable security issues in this diff. The change adds fail-open Cowork detection ( Previously acknowledged (not re-flagged)
🤖 consensus review · reviewers: Cursor,Claude,Semgrep,Gitleaks · head |
Linear: WEB-5030
What
One hook script (
claude-code/hooks/unbound.py) serves both Claude Code and Cowork, and it hardcodedunbound_app_label='claude-code', so Cowork activity was counted as Claude Code. This makes it report Cowork under its own label.Detection
_unbound_app_label(event):CLAUDE_CODE_IS_COWORK=1, orCLAUDE_CODE_ENTRYPOINTinlocal-agent | local_agent | remote_cowork.cwd/transcript_pathunderlocal-agent-mode-sessions.claude-code. Fail-open (env read wrapped; path check can't raise).Applied on
pre_tool,user_prompt, and the end-of-turn exchange (the exchange previously carried no app label).Consolidation
Supersedes #197 (WEB-5027) — folds in its env-var detection, adds the path fallback and the missing end-of-turn label. Close #197 in favor of this.
Verification
_unbound_app_labelvalidated against real captured events from CLI, desktop Code, and Cowork (all 3 surfaces). 7 unit tests intest_pretool_mcp.py.Requires the gateway to accept
cowork(companion ai-gateway PR) — old gateways drop unknown labels from their label-keyed maps.🤖 Generated with Claude Code
https://claude.ai/code/session_01RjaRWNPjy4N7KRQUykQape
Note
Low Risk
Additive telemetry labeling with fail-open fallback to
claude-code; misdetection only affects analytics/policy scoping until the gateway acceptscowork.Overview
Fixes misclassification where the shared Claude hook always sent
unbound_app_label: claude-code, so Cowork activity was lumped in with Claude Code.Adds
_unbound_app_label(event)with fail-open detection: Cowork env markers (CLAUDE_CODE_IS_COWORK=1, orCLAUDE_CODE_ENTRYPOINTinlocal-agent/local_agent/remote_cowork), then a path fallback whencwdortranscript_pathcontainslocal-agent-mode-sessions, otherwiseclaude-code.Wires that helper into pre-tool, user-prompt, and end-of-turn gateway payloads (the stop exchange previously had no app label).
unbound-hookandunbound-discoveryversions bump to 0.1.9; unit tests cover the detection branches.Reviewed by Cursor Bugbot for commit 85d7649. Bugbot is set up for automated code reviews on this repo. Configure here.
Greptile Summary
This PR fixes Cowork hook sessions being incorrectly reported as
claude-codeby replacing the hardcodedunbound_app_label='claude-code'with a new_unbound_app_label(event)helper that uses a layered detection strategy (env vars → path fallback → default)._unbound_app_label: ChecksCLAUDE_CODE_IS_COWORK=1, thenCLAUDE_CODE_ENTRYPOINTmembership, then alocal-agent-mode-sessionssubstring incwd/transcript_path, falling back to'claude-code'. The env checks are wrapped in a fail-opentry/except.process_pre_tool_use,process_user_prompt_submit, andprocess_stop_event(the stop-event exchange was previously unlabeled entirely).TestUnboundAppLabelcovering CLI, desktop, env markers, non-Cowork entrypoints, path fallback, and missing-field defaults. Version bumped to 0.1.9 in both__init__.pyand the discovery entry.Confidence Score: 5/5
Additive label-detection change with fail-open fallback; misdetection degrades to the existing 'claude-code' behavior, and deploying before the gateway accepts 'cowork' simply loses the new label without breaking hook correctness.
The detection logic is straightforward and well-tested across all three surfaces. The stop-event labeling gap is properly closed. No existing behavior is changed for non-Cowork sessions, and the path-fallback only fires when no env markers are present.
No files require special attention.
Important Files Changed
_unbound_app_label(event)helper with layered Cowork detection; wired into all three call sites including the previously unlabeled stop-event exchange. Logic is correct and fail-open.clear=True.Flowchart
%%{init: {'theme': 'neutral'}}%% flowchart TD A[Hook Event Received] --> B[_unbound_app_label] B --> C{CLAUDE_CODE_IS_COWORK == '1'?} C -- Yes --> D[return 'cowork'] C -- No --> E{CLAUDE_CODE_ENTRYPOINT in local-agent / local_agent / remote_cowork?} E -- Yes --> D E -- No --> F{except Exception → pass} F --> G{cwd contains local-agent-mode-sessions?} G -- Yes --> D G -- No --> H{transcript_path contains local-agent-mode-sessions?} H -- Yes --> D H -- No --> I[return 'claude-code'] D --> J[Set unbound_app_label on payload] I --> J J --> K{Which hook?} K -- process_pre_tool_use --> L[request_body.unbound_app_label] K -- process_user_prompt_submit --> M[request_body.unbound_app_label] K -- process_stop_event --> N[exchange.unbound_app_label] L --> O[send_to_api] M --> O N --> O%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%% flowchart TD A[Hook Event Received] --> B[_unbound_app_label] B --> C{CLAUDE_CODE_IS_COWORK == '1'?} C -- Yes --> D[return 'cowork'] C -- No --> E{CLAUDE_CODE_ENTRYPOINT in local-agent / local_agent / remote_cowork?} E -- Yes --> D E -- No --> F{except Exception → pass} F --> G{cwd contains local-agent-mode-sessions?} G -- Yes --> D G -- No --> H{transcript_path contains local-agent-mode-sessions?} H -- Yes --> D H -- No --> I[return 'claude-code'] D --> J[Set unbound_app_label on payload] I --> J J --> K{Which hook?} K -- process_pre_tool_use --> L[request_body.unbound_app_label] K -- process_user_prompt_submit --> M[request_body.unbound_app_label] K -- process_stop_event --> N[exchange.unbound_app_label] L --> O[send_to_api] M --> O N --> OReviews (2): Last reviewed commit: "WEB-5030: bump unbound-hook + discovery ..." | Re-trigger Greptile