diff --git a/claude-code/hooks/unbound.py b/claude-code/hooks/unbound.py index 331abbf3..d3eb10c5 100644 --- a/claude-code/hooks/unbound.py +++ b/claude-code/hooks/unbound.py @@ -20,8 +20,12 @@ AUDIT_LOG = Path.home() / ".claude" / "hooks" / "agent-audit.log" ERROR_LOG = Path.home() / ".claude" / "hooks" / "error.log" LAST_REPORT_FILE = Path.home() / ".claude" / "hooks" / ".last_error_report" -ALLOWED_NON_MCP_HOOK_NAMES = ['Bash', 'Read', 'Write', 'Edit'] # MCP tools (mcp__*) are always checked separately -NATIVE_FILE_TOOLS = {'Read', 'Write', 'Edit'} +# Grep/Glob/LS are read-equivalent: they expose file CONTENTS (Grep) or +# enumerate paths (Glob/LS), so they could leak/discover a secret file that the +# equivalent `cat`/`ls` is blocked from. The gateway maps them to `read_file`. +READ_EQUIVALENT_FILE_TOOLS = {'Grep', 'Glob', 'LS'} +ALLOWED_NON_MCP_HOOK_NAMES = ['Bash', 'Read', 'Write', 'Edit', 'Grep', 'Glob', 'LS'] # MCP tools (mcp__*) are always checked separately +NATIVE_FILE_TOOLS = {'Read', 'Write', 'Edit'} | READ_EQUIVALENT_FILE_TOOLS MCP_TOOL_PREFIX = 'mcp__' CLAUDE_MCP_CONFIG_PATH = Path.home() / ".claude.json" POLICY_CACHE_FILE = Path.home() / ".claude" / "hooks" / ".policy_cache.json" @@ -863,6 +867,18 @@ def process_pre_tool_use(event: Dict, api_key: str) -> Dict: tool_input = event.get('tool_input') or {} if 'file_path' in tool_input: metadata['file_path'] = tool_input['file_path'] + elif tool_name in READ_EQUIVALENT_FILE_TOOLS: + # Grep/LS carry an optional `path`; Glob carries a `pattern` (itself a + # glob like `**/*.env`, which IS path-like). Only fall back to `pattern` + # for Glob — Grep's `pattern` is a regex (e.g. `SECRET_KEY.*=`), not a + # path, so forwarding it as file_path would make the gateway evaluate a + # regex as a filesystem path. + if tool_name == 'Glob': + derived_path = tool_input.get('path') or tool_input.get('pattern') + else: + derived_path = tool_input.get('path') + if derived_path: + metadata['file_path'] = derived_path if is_mcp: # Parse mcp____ to extract server and tool for gateway matching @@ -986,7 +1002,7 @@ def process_user_prompt_submit(event: Dict, api_key: str) -> Dict: return transform_response_for_claude_prompt(api_response) -def build_llm_exchange(events: List[Dict], stop_assistant_message: Optional[str] = None, transcript_assistant_messages: Optional[List[str]] = None, model: Optional[str] = None, usage: Optional[Dict] = None) -> Optional[Dict]: +def build_llm_exchange(events: List[Dict], stop_assistant_message: Optional[str] = None, transcript_assistant_messages: Optional[List[str]] = None, model: Optional[str] = None, usage: Optional[Dict] = None, request_initialized: Optional[str] = None, request_completed: Optional[str] = None) -> Optional[Dict]: messages = [] assistant_tool_uses = [] @@ -1067,6 +1083,11 @@ def build_llm_exchange(events: List[Dict], stop_assistant_message: Optional[str] if usage: exchange['usage'] = usage + if request_initialized: + exchange['requestInitialized'] = request_initialized + if request_completed: + exchange['requestCompleted'] = request_completed + return exchange @@ -1166,12 +1187,16 @@ def process_stop_event(event: Dict, api_key: str): # the cached session model is wrong). Fall back to the audit log otherwise. session_model = transcript_model or _extract_session_model(logs, session_id) or 'auto' + request_completed = datetime.utcnow().isoformat() + 'Z' + exchange = build_llm_exchange( session_events, stop_assistant_message=last_assistant_message, transcript_assistant_messages=transcript_assistant_messages, model=session_model, usage=transcript_usage, + request_initialized=user_prompt_timestamp, + request_completed=request_completed, ) if exchange: diff --git a/codex/hooks/unbound.py b/codex/hooks/unbound.py index a1c99b71..c6ffedef 100644 --- a/codex/hooks/unbound.py +++ b/codex/hooks/unbound.py @@ -1107,6 +1107,8 @@ def process_stop_event(event: Dict, api_key: str): assistant_msg['tool_use'] = assistant_tool_uses messages.append(assistant_msg) + request_completed = datetime.utcnow().isoformat() + 'Z' + exchange = { 'conversation_id': session_id or 'unknown', 'model': event.get('model', 'auto'), @@ -1118,6 +1120,11 @@ def process_stop_event(event: Dict, api_key: str): if usage: exchange['usage'] = usage + if user_prompt_timestamp: + exchange['requestInitialized'] = user_prompt_timestamp + if request_completed: + exchange['requestCompleted'] = request_completed + send_to_api(exchange, api_key) diff --git a/cursor/unbound.py b/cursor/unbound.py index 6a455186..44383859 100644 --- a/cursor/unbound.py +++ b/cursor/unbound.py @@ -954,6 +954,8 @@ def build_llm_exchange(events, api_key=None): conversation_id = None model = None user_email = None + request_initialized = None + request_completed = None for log_entry in events: event = log_entry.get('event', {}) @@ -967,10 +969,14 @@ def build_llm_exchange(events, api_key=None): if not user_email: user_email = event.get('user_email') - + if hook_event_name == 'beforeSubmitPrompt': user_prompt = event.get('prompt') - + request_initialized = log_entry.get('timestamp') + + elif hook_event_name == 'stop': + request_completed = log_entry.get('timestamp') + elif hook_event_name == 'beforeReadFile': assistant_tool_uses.append({ 'type': hook_event_name, @@ -1042,6 +1048,13 @@ def build_llm_exchange(events, api_key=None): 'account_identity': build_account_identity({'user_email': user_email}, probe=True) } + # The gateway only honors these when its handler sets useBodyTimestamps; + # omit when unknown so it falls back to ingest time. WEB-4850. + if request_initialized: + exchange['requestInitialized'] = request_initialized + if request_completed: + exchange['requestCompleted'] = request_completed + return exchange