Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion binary/src/unbound_hook/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,4 @@
for fleets without python3 (WEB-4786).
"""

__version__ = "0.1.8"
__version__ = "0.1.9"
56 changes: 56 additions & 0 deletions claude-code/hooks/test_pretool_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -395,5 +395,61 @@ def test_config_file_server_takes_precedence_over_resolvers(self):
self.assertEqual(md.get("mcp_server_config"), {"url": "https://configfile.example/mcp"})


class TestUnboundAppLabel(unittest.TestCase):
"""_unbound_app_label reports Cowork via the desktop env markers, with the
local-agent-mode-sessions sandbox path as a fallback; everything else is
claude-code. Verified against real captured events from all three surfaces."""

def setUp(self):
# neutralize any ambient Cowork env so path-based cases are deterministic
self._env = patch.dict("os.environ", {}, clear=True)
self._env.start()
self.addCleanup(self._env.stop)

def test_claude_code_cli(self):
ev = {"cwd": "/Users/x/Documents/proj",
"transcript_path": "/Users/x/.claude/projects/-Users-x-Documents-proj/s.jsonl"}
self.assertEqual(unbound._unbound_app_label(ev), "claude-code")

def test_claude_code_desktop(self):
ev = {"cwd": "/Users/x/Downloads",
"transcript_path": "/Users/x/.claude/projects/-Users-x-Downloads/s.jsonl"}
self.assertEqual(unbound._unbound_app_label(ev), "claude-code")

def test_cowork_env_is_cowork_flag(self):
# env wins even when the path looks like plain Claude Code
with patch.dict("os.environ", {"CLAUDE_CODE_IS_COWORK": "1"}):
self.assertEqual(unbound._unbound_app_label({"cwd": "/Users/x/proj"}), "cowork")

def test_cowork_env_entrypoint_values(self):
for val in ("local-agent", "local_agent", "remote_cowork"):
with patch.dict("os.environ", {"CLAUDE_CODE_ENTRYPOINT": val}):
self.assertEqual(unbound._unbound_app_label({}), "cowork")

def test_unrecognized_entrypoint_is_claude_code(self):
for val in ("cli", "desktop", "vscode", ""):
with patch.dict("os.environ", {"CLAUDE_CODE_ENTRYPOINT": val}):
self.assertEqual(unbound._unbound_app_label({}), "claude-code")

def test_is_cowork_flag_non_1_is_claude_code(self):
for val in ("0", "true", ""):
with patch.dict("os.environ", {"CLAUDE_CODE_IS_COWORK": val}):
self.assertEqual(unbound._unbound_app_label({}), "claude-code")

def test_cowork_path_fallback_cwd(self):
ev = {"cwd": "/Users/x/Library/Application Support/Claude/local-agent-mode-sessions/a/b/local_c/outputs"}
self.assertEqual(unbound._unbound_app_label(ev), "cowork")
Comment thread
greptile-apps[bot] marked this conversation as resolved.

def test_cowork_path_fallback_transcript_only(self):
# cowork transcript lives in a tmpdir but the sanitized project name keeps the marker
ev = {"cwd": "/private/tmp",
"transcript_path": "/var/folders/x/T/claude-hostloop-plugins/h/projects/-Users-x-Library-Application-Support-Claude-local-agent-mode-sessions-a-b-local-c-outputs/s.jsonl"}
self.assertEqual(unbound._unbound_app_label(ev), "cowork")

def test_missing_fields_default_to_claude_code(self):
self.assertEqual(unbound._unbound_app_label({}), "claude-code")
self.assertEqual(unbound._unbound_app_label({"cwd": None, "transcript_path": None}), "claude-code")


if __name__ == "__main__":
unittest.main()
28 changes: 26 additions & 2 deletions claude-code/hooks/unbound.py
Original file line number Diff line number Diff line change
Expand Up @@ -1268,6 +1268,29 @@ def build_account_identity(probe: bool = False) -> Dict:
return identity


def _unbound_app_label(event: Dict) -> str:
"""This one hook script serves both Claude Code and Cowork. Report Cowork
under its own label so the gateway can scope policies/analytics per surface.
The Claude Desktop app marks Cowork in the hook environment; builds that
predate those env vars still get caught by the sandbox path marker
(cwd/transcript_path under local-agent-mode-sessions). Requires gateway
support for 'cowork' — old gateways drop the label from their label-keyed
maps."""
try:
if os.environ.get('CLAUDE_CODE_IS_COWORK') == '1':
return 'cowork'
if os.environ.get('CLAUDE_CODE_ENTRYPOINT') in (
'local-agent', 'local_agent', 'remote_cowork'
):
return 'cowork'
except Exception:
pass
for field in ('cwd', 'transcript_path'):
if 'local-agent-mode-sessions' in (event.get(field) or ''):
Comment thread
pugazhendhi-m marked this conversation as resolved.
return 'cowork'
return 'claude-code'
Comment thread
pugazhendhi-m marked this conversation as resolved.


def process_pre_tool_use(event: Dict, api_key: str) -> Dict:
"""Process PreToolUse event - DO NOT LOG."""
session_id = event.get('session_id')
Expand Down Expand Up @@ -1342,7 +1365,7 @@ def process_pre_tool_use(event: Dict, api_key: str) -> Dict:

request_body = {
'conversation_id': session_id,
'unbound_app_label': 'claude-code',
'unbound_app_label': _unbound_app_label(event),
'model': model,
'event_name': 'tool_use',
'pre_tool_use_data': {
Expand Down Expand Up @@ -1437,7 +1460,7 @@ def process_user_prompt_submit(event: Dict, api_key: str) -> Dict:

request_body = {
'conversation_id': session_id,
'unbound_app_label': 'claude-code',
'unbound_app_label': _unbound_app_label(event),
'model': model,
'event_name': 'user_prompt',
'account_identity': build_account_identity(),
Expand Down Expand Up @@ -1655,6 +1678,7 @@ def process_stop_event(event: Dict, api_key: str):
)

if exchange:
exchange['unbound_app_label'] = _unbound_app_label(event)
# prompt_id == Cowork's OTEL prompt.id; lets the backend de-dup a turn
# logged on both hooks and OTEL. Absent on Claude Code < v2.1.196.
prompt_id = event.get('prompt_id')
Expand Down
2 changes: 1 addition & 1 deletion packaging/unbound_discovery_entry.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
# TODO(WEB-4802): hook and discovery versions must be bumped in lockstep on
# every tag until build-time version injection lands (the workflow already
# notes this). Keep this equal to unbound_hook.__version__ until then.
__version__ = "0.1.8"
__version__ = "0.1.9"


def _missing_required_config(argv):
Expand Down