feat(claude-code-hook): report Cowork sessions as unbound_app_label='cowork'#197
feat(claude-code-hook): report Cowork sessions as unbound_app_label='cowork'#197vigneshsubbiah16 wants to merge 1 commit into
Conversation
| try: | ||
| if os.environ.get('CLAUDE_CODE_IS_COWORK') == '1': | ||
| return 'claude-cowork' | ||
| if os.environ.get('CLAUDE_CODE_ENTRYPOINT') in ( | ||
| 'local-agent', 'local_agent', 'remote_cowork' | ||
| ): | ||
| return 'claude-cowork' | ||
| except Exception: | ||
| pass | ||
| return 'claude-code' |
There was a problem hiding this comment.
IS_COWORK=0 explicit denial overridden by entrypoint match
When CLAUDE_CODE_IS_COWORK=0 is set explicitly (meaning "this is not a Cowork session") but CLAUDE_CODE_ENTRYPOINT is simultaneously set to local-agent or remote_cowork, the function skips the IS_COWORK check (it's not '1') and then falls through to the entrypoint check, returning 'claude-cowork' anyway. The explicit negative signal is effectively ignored. The entrypoint fallback should only fire when CLAUDE_CODE_IS_COWORK is absent (None), not when it is explicitly set to '0'. If the desktop app ever sets both variables in a non-Cowork context, sessions will be silently mislabeled and routed against wrong gateway policy maps.
| try: | ||
| if os.environ.get('CLAUDE_CODE_IS_COWORK') == '1': | ||
| return 'claude-cowork' | ||
| if os.environ.get('CLAUDE_CODE_ENTRYPOINT') in ( | ||
| 'local-agent', 'local_agent', 'remote_cowork' | ||
| ): | ||
| return 'claude-cowork' | ||
| except Exception: | ||
| pass | ||
| return 'claude-code' |
There was a problem hiding this comment.
Silent exception swallow with no logging
os.environ.get() never raises in practice, so this except Exception: pass only matters if something truly unexpected happens. When it does fire, there is no log, no metric, and no way to reconstruct in production why a session was silently downgraded to the 'claude-code' label. Per the team's logging guidelines, caught exceptions should be logged with context rather than silently dropped.
| try: | |
| if os.environ.get('CLAUDE_CODE_IS_COWORK') == '1': | |
| return 'claude-cowork' | |
| if os.environ.get('CLAUDE_CODE_ENTRYPOINT') in ( | |
| 'local-agent', 'local_agent', 'remote_cowork' | |
| ): | |
| return 'claude-cowork' | |
| except Exception: | |
| pass | |
| return 'claude-code' | |
| try: | |
| if os.environ.get('CLAUDE_CODE_IS_COWORK') == '1': | |
| return 'claude-cowork' | |
| if os.environ.get('CLAUDE_CODE_ENTRYPOINT') in ( | |
| 'local-agent', 'local_agent', 'remote_cowork' | |
| ): | |
| return 'claude-cowork' | |
| except Exception as exc: # pragma: no cover | |
| import logging as _logging | |
| _logging.getLogger(__name__).warning('_unbound_app_label error, defaulting to claude-code: %s', exc) | |
| return 'claude-code' |
| if os.environ.get('CLAUDE_CODE_ENTRYPOINT') in ( | ||
| 'local-agent', 'local_agent', 'remote_cowork' | ||
| ): | ||
| return 'claude-cowork' |
There was a problem hiding this comment.
Exclusivity of
local-agent entrypoint not confirmed
The entrypoint value local-agent (and its underscore variant local_agent) is listed as a Cowork signal, but it's worth confirming: can a plain Claude Code session ever report CLAUDE_CODE_ENTRYPOINT=local-agent? If that entrypoint is used by both Cowork and non-Cowork on-device sessions, any regular Claude Code on-device user would be silently mislabeled as claude-cowork and subject to the wrong gateway policy set, potentially causing tool approvals to behave incorrectly. Is CLAUDE_CODE_ENTRYPOINT=local-agent guaranteed to be exclusive to Cowork sessions? Could a standard Claude Code on-device session also report this entrypoint value?
🛡️ Automated Security Review (consensus)4 findings — 1 high-confidence, 3 to triage. Reviewers: Cursor, Claude, Semgrep, Gitleaks. 🔴 HIGH — Client-controlled env vars can steer gateway policy surface
Impact: Fix: Treat the client-supplied label as analytics-only, or have the gateway derive/validate surface from a trusted signal (authenticated session/device attributes). At minimum, confirm 🟡 TRIAGE —
|
…cowork' Claude Desktop 1.17 routes Cowork sessions through the same device hooks as Claude Code, but the hook labeled everything 'claude-code', so Cowork usage was indistinguishable in policies, analytics, and inventory. The desktop app marks the surface in the hook environment: CLAUDE_CODE_IS_COWORK=1 and CLAUDE_CODE_ENTRYPOINT=local-agent (remote_cowork for cloud Cowork). Add _unbound_app_label() reading those (IS_COWORK primary, entrypoint fallback, fail-safe to 'claude-code') and use it at both request sites (pretool + user-prompt). 'cowork' matches the label the OTel ingestion path already normalizes Cowork traffic to (application_type COWORK), so hook events join the same application entity instead of forking a new label. Requires gateway support (ai-gateway#716) to be deployed first; old gateways drop 'cowork' out of their label-keyed native-tool and budget maps. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
eb3f800 to
a731b49
Compare
🛡️ Automated Security Review (consensus)3 findings — 1 high-confidence, 2 to triage. Reviewers: Cursor, Claude, Semgrep, Gitleaks. 🔴 HIGH — Client-controlled env vars can steer gateway policy/budget surface
🟡 TRIAGE —
|
…202) * WEB-5030: report Cowork hook sessions as unbound_app_label='cowork' 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 * WEB-5030: cover non-Cowork entrypoint / IS_COWORK values in tests 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 * WEB-5030: bump unbound-hook + discovery to 0.1.9 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 --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Linear: WEB-5027
What
Cowork sessions now report
unbound_app_label='cowork'instead of'claude-code'in both hook→gateway requests (PreToolUse + UserPromptSubmit).How
Claude Desktop 1.17 runs Cowork through the same CLI + device hooks as Claude Code and marks the surface in the hook environment (verified live on 1.17377.1):
CLAUDE_CODE_IS_COWORK=1— dedicated flag (primary signal)CLAUDE_CODE_ENTRYPOINT=local-agent(on-device) /remote_cowork(cloud) — fallbackNew
_unbound_app_label()helper reads these; any error or absence fails safe to'claude-code'(existing behavior).Why
coworkMatches the label the OTel ingestion path already normalizes Cowork traffic to (
otelLogsHandlerserviceName='cowork', data-planeapplication_type = COWORK) — hook events join the same application entity instead of forking a new label.Deploy order
Verification
py_compileclean{}→claude-code,IS_COWORK=1→cowork,local-agent/remote_cowork→cowork,cli/sdk-cli/IS_COWORK=0→claude-code🤖 Generated with Claude Code
Note
Medium Risk
Changes how hook events are classified for policy/budget routing; incorrect deploy order or env mis-detection could mis-attribute Cowork vs Claude Code until the gateway is updated.
Overview
Adds
_unbound_app_label()so hook→gateway requests no longer always sendunbound_app_label: 'claude-code'. Cowork sessions (same CLI/hooks as Claude Code) are labeledcoworkwhen the desktop setsCLAUDE_CODE_IS_COWORK=1orCLAUDE_CODE_ENTRYPOINTtolocal-agent,local_agent, orremote_cowork; anything else or any error still returnsclaude-code.PreToolUse and UserPromptSubmit payloads now use this helper instead of a hardcoded label, so policies and analytics can be scoped per surface and align with OTel/Cowork application typing.
Deploy order: gateway support for
cowork(ai-gateway#716) should land first; older gateways may ignore the new label in label-keyed maps.Reviewed by Cursor Bugbot for commit a731b49. Bugbot is set up for automated code reviews on this repo. Configure here.
Greptile Summary
This PR routes Cowork sessions to a dedicated
unbound_app_label='cowork'in the hook→gateway requests (PreToolUse+UserPromptSubmit) instead of the existing'claude-code'label. A new_unbound_app_label()helper reads two environment variables (CLAUDE_CODE_IS_COWORK,CLAUDE_CODE_ENTRYPOINT) set by Claude Desktop 1.17 and falls back safely to'claude-code'on any error or absence._unbound_app_label()helper reads env-var signals to distinguish Cowork from Claude Code sessions, with a safe fallback default.process_pre_tool_use,process_user_prompt_submit) are updated to call the helper instead of hardcoding'claude-code'.coworkwill fall out of the gateway's label-keyed policy maps.Confidence Score: 4/5
Safe to merge only after the gateway dependency (ai-gateway#716) is deployed; the label-routing logic itself fails safe to 'claude-code' but lacks any observability instrumentation to confirm the rollout is working.
The new
_unbound_app_label()function is called on every tool-use and user-prompt event without a single counter or histogram tracking which label branch fires. Given the explicit deploy-order dependency on a separate gateway PR, there is no production signal to confirm Cowork sessions are being routed correctly or to detect if the exception branch silently fires at scale.claude-code/hooks/unbound.py — specifically the new
_unbound_app_label()helper and its call sites in both event processors.Important Files Changed
_unbound_app_label()helper and wires it into both hook event handlers; the new label-routing decision path has no Prometheus instrumentation, and the entrypoint fallback fires even whenCLAUDE_CODE_IS_COWORKis explicitly set to'0'.Flowchart
%%{init: {'theme': 'neutral'}}%% flowchart TD A[Hook Event\nPreToolUse / UserPromptSubmit] --> B[_unbound_app_label] B --> C{CLAUDE_CODE_IS_COWORK\n== '1'?} C -- Yes --> D[return 'cowork'] C -- No --> E{CLAUDE_CODE_ENTRYPOINT\nin local-agent /\nlocal_agent /\nremote_cowork?} E -- Yes --> D E -- No --> F[return 'claude-code'] B -- Exception --> F D --> G[request_body\nunbound_app_label='cowork'] F --> H[request_body\nunbound_app_label='claude-code'] G --> I[send_to_hook_api\nGateway ai-gateway#716] H --> I%%{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\nPreToolUse / UserPromptSubmit] --> B[_unbound_app_label] B --> C{CLAUDE_CODE_IS_COWORK\n== '1'?} C -- Yes --> D[return 'cowork'] C -- No --> E{CLAUDE_CODE_ENTRYPOINT\nin local-agent /\nlocal_agent /\nremote_cowork?} E -- Yes --> D E -- No --> F[return 'claude-code'] B -- Exception --> F D --> G[request_body\nunbound_app_label='cowork'] F --> H[request_body\nunbound_app_label='claude-code'] G --> I[send_to_hook_api\nGateway ai-gateway#716] H --> IReviews (2): Last reviewed commit: "feat(claude-code-hook): report Cowork se..." | Re-trigger Greptile
Context used:
Django / Backend ... (source)