Skip to content

feat(claude-code-hook): report Cowork sessions as unbound_app_label='cowork'#197

Closed
vigneshsubbiah16 wants to merge 1 commit into
mainfrom
vis/cowork-app-label
Closed

feat(claude-code-hook): report Cowork sessions as unbound_app_label='cowork'#197
vigneshsubbiah16 wants to merge 1 commit into
mainfrom
vis/cowork-app-label

Conversation

@vigneshsubbiah16

@vigneshsubbiah16 vigneshsubbiah16 commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator

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) — fallback

New _unbound_app_label() helper reads these; any error or absence fails safe to 'claude-code' (existing behavior).

Why cowork

Matches the label the OTel ingestion path already normalizes Cowork traffic to (otelLogsHandler serviceName='cowork', data-plane application_type = COWORK) — hook events join the same application entity instead of forking a new label.

Deploy order

⚠️ Gateway first: websentry-ai/ai-gateway#716 must be deployed before this rolls out, otherwise the label falls out of the gateway's label-keyed native-tool/budget maps. Hooks pick this change up via self-update once merged.

Verification

  • py_compile clean
  • 7 behavior cases pass: {}→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 send unbound_app_label: 'claude-code'. Cowork sessions (same CLI/hooks as Claude Code) are labeled cowork when the desktop sets CLAUDE_CODE_IS_COWORK=1 or CLAUDE_CODE_ENTRYPOINT to local-agent, local_agent, or remote_cowork; anything else or any error still returns claude-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.

  • New _unbound_app_label() helper reads env-var signals to distinguish Cowork from Claude Code sessions, with a safe fallback default.
  • Both hook event processors (process_pre_tool_use, process_user_prompt_submit) are updated to call the helper instead of hardcoding 'claude-code'.
  • Deploy ordering constraint: gateway PR ai-gateway#716 must be deployed first, or sessions labeled cowork will 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

Filename Overview
claude-code/hooks/unbound.py Adds _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 when CLAUDE_CODE_IS_COWORK is 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
Loading
%%{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 --> I
Loading

Reviews (2): Last reviewed commit: "feat(claude-code-hook): report Cowork se..." | Re-trigger Greptile

Context used:

  • Context used - P0 — Critical (must block merge)
    Django / Backend ... (source)

@vigneshsubbiah16 vigneshsubbiah16 requested a review from a team July 1, 2026 23:15

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Stale comment

Comment thread claude-code/hooks/unbound.py
Comment on lines +1187 to +1196
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'

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 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.

Comment on lines +1187 to +1196
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'

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 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.

Suggested change
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'

Comment thread claude-code/hooks/unbound.py Outdated
Comment on lines +1190 to +1193
if os.environ.get('CLAUDE_CODE_ENTRYPOINT') in (
'local-agent', 'local_agent', 'remote_cowork'
):
return 'claude-cowork'

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 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?

@vigneshsubbiah16

Copy link
Copy Markdown
Collaborator Author

🛡️ 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

claude-code/hooks/unbound.py:1187-1194 (used at :1269, :1361) · Reviewers: Cursor, Claude

Impact: _unbound_app_label() reads CLAUDE_CODE_IS_COWORK / CLAUDE_CODE_ENTRYPOINT from the hook process environment; a launcher can set these before invoking the CLI and force unbound_app_label='claude-cowork'. If gateway #716 keys native-tool allow-lists or budgets on this label, enforcement can be shifted onto a different policy surface without forging the request body.

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 claude-cowork and claude-code are equivalent for all security-relevant policy and budget decisions in #716.


🟡 TRIAGE — IS_COWORK=0 explicit denial overridden by entrypoint fallback

claude-code/hooks/unbound.py:1190-1194 · Reviewers: Greptile (compounds finding above per Claude)

Impact: When CLAUDE_CODE_IS_COWORK='0' but CLAUDE_CODE_ENTRYPOINT matches a Cowork pattern, the function still returns 'claude-cowork', routing the session against the wrong gateway policy map.

Fix: Apply the entrypoint fallback only when CLAUDE_CODE_IS_COWORK is unset (None), not when explicitly '0'.


🟡 TRIAGE — local-agent entrypoint exclusivity unconfirmed

claude-code/hooks/unbound.py:1191-1193 · Reviewers: Greptile

Impact: If standard on-device Claude Code sessions can also report CLAUDE_CODE_ENTRYPOINT=local-agent, they would be permanently mislabeled as claude-cowork and evaluated under the wrong policies.

Fix: Confirm exclusivity with Anthropic/desktop behavior, or rely solely on CLAUDE_CODE_IS_COWORK=1 as the positive signal.


🟡 TRIAGE — Silent exception swallow in label detection

claude-code/hooks/unbound.py:1195-1196 · Reviewers: Greptile

Impact: Any unexpected error in _unbound_app_label() silently falls back to 'claude-code' with no log or metric, making production mislabeling undiagnosable.

Fix: Log a warning with exception context before returning the fail-safe default.


🤖 consensus review · reviewers: Cursor,Claude,Semgrep,Gitleaks · head eb3f800a · 2026-07-01T23:22Z

…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>
@vigneshsubbiah16 vigneshsubbiah16 changed the title feat(claude-code-hook): report Cowork sessions as unbound_app_label='claude-cowork' feat(claude-code-hook): report Cowork sessions as unbound_app_label='cowork' Jul 1, 2026
@vigneshsubbiah16

Copy link
Copy Markdown
Collaborator Author

🛡️ 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

  • File: claude-code/hooks/unbound.py:1181 (helper); call sites :1269, :1364
  • Impact: _unbound_app_label() reads CLAUDE_CODE_IS_COWORK / CLAUDE_CODE_ENTRYPOINT from the hook process environment; a caller can set these to force unbound_app_label='cowork' and route enforcement onto a different gateway policy/budget map than claude-code.
  • Fix: Treat unbound_app_label as an untrusted hint on the gateway — derive or authorize the surface server-side from the authenticated credential (or constrain which labels a key may assert); client-side env checks alone cannot establish trust.
  • Flagged by: Cursor, Claude, Lead

🟡 TRIAGE — IS_COWORK=0 explicit denial overridden by entrypoint fallback

  • File: claude-code/hooks/unbound.py:1193-1196
  • Impact: When CLAUDE_CODE_IS_COWORK='0' but CLAUDE_CODE_ENTRYPOINT matches a Cowork value, the function still returns 'cowork', silently misrouting sessions to the wrong policy surface.
  • Fix: Only apply the entrypoint fallback when CLAUDE_CODE_IS_COWORK is unset (None), not when it is explicitly '0'.
  • Flagged by: Greptile

🟡 TRIAGE — Cowork exclusivity of local-agent entrypoint unconfirmed

  • File: claude-code/hooks/unbound.py:1193-1196
  • Impact: If standard Claude Code on-device sessions can also report CLAUDE_CODE_ENTRYPOINT=local-agent / local_agent, non-Cowork users would be mislabeled and subject to incorrect tool-approval or budget rules.
  • Fix: Confirm with Claude Desktop that these entrypoint values are Cowork-exclusive before relying on them as a fallback signal; otherwise require additional corroborating signals.
  • Flagged by: Greptile

🤖 consensus review · reviewers: Cursor,Claude,Semgrep,Gitleaks · head a731b494 · 2026-07-01T23:32Z

@pugazhendhi-m

Copy link
Copy Markdown
Contributor

Consolidated into WEB-5030 (#202), which keeps this env-var detection and adds a local-agent-mode-sessions path fallback plus the end-of-turn exchange label this PR omitted. Closing in favor of #202.

pugazhendhi-m added a commit that referenced this pull request Jul 3, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants