diff --git a/binary/src/unbound_hook/__init__.py b/binary/src/unbound_hook/__init__.py index 57786aa..8cb13c7 100644 --- a/binary/src/unbound_hook/__init__.py +++ b/binary/src/unbound_hook/__init__.py @@ -5,4 +5,4 @@ for fleets without python3 (WEB-4786). """ -__version__ = "0.1.8" +__version__ = "0.1.9" diff --git a/claude-code/hooks/test_pretool_mcp.py b/claude-code/hooks/test_pretool_mcp.py index a14e46f..8a7c36c 100644 --- a/claude-code/hooks/test_pretool_mcp.py +++ b/claude-code/hooks/test_pretool_mcp.py @@ -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") + + 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() diff --git a/claude-code/hooks/unbound.py b/claude-code/hooks/unbound.py index 8117e73..f8d72d4 100644 --- a/claude-code/hooks/unbound.py +++ b/claude-code/hooks/unbound.py @@ -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 ''): + return 'cowork' + return 'claude-code' + + def process_pre_tool_use(event: Dict, api_key: str) -> Dict: """Process PreToolUse event - DO NOT LOG.""" session_id = event.get('session_id') @@ -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': { @@ -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(), @@ -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') diff --git a/packaging/unbound_discovery_entry.py b/packaging/unbound_discovery_entry.py index ad40a9e..8ddeec9 100644 --- a/packaging/unbound_discovery_entry.py +++ b/packaging/unbound_discovery_entry.py @@ -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):