From df1eed950448c487c30523354097a3ecc5bda6c4 Mon Sep 17 00:00:00 2001 From: MohamedAklamaash Date: Wed, 17 Jun 2026 11:22:47 +0530 Subject: [PATCH 1/3] feat: longest handoff calc --- claude-code/hooks/unbound.py | 11 ++++++++++- codex/hooks/unbound.py | 7 +++++++ cursor/unbound.py | 17 +++++++++++++++-- 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/claude-code/hooks/unbound.py b/claude-code/hooks/unbound.py index 331abbf3..e55745bd 100644 --- a/claude-code/hooks/unbound.py +++ b/claude-code/hooks/unbound.py @@ -986,7 +986,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 +1067,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 +1171,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 From bda4d20a62fefe58a837bae16b8291d4ce0a6092 Mon Sep 17 00:00:00 2001 From: MohamedAklamaash Date: Wed, 17 Jun 2026 21:10:27 +0530 Subject: [PATCH 2/3] WEB-4871: forward Grep/Glob/LS reads from the claude-code hook Grep/Glob/LS are read-equivalent (they expose file contents or enumerate paths), so the hook now sends them with their target path, letting read / secret policies evaluate them like a native Read instead of bypassing. Co-Authored-By: Claude Opus 4.8 (1M context) --- claude-code/hooks/unbound.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/claude-code/hooks/unbound.py b/claude-code/hooks/unbound.py index e55745bd..2aa1d150 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,13 @@ 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 a `path`; Glob carries a `pattern` (itself a glob like + # `**/*.env`). Forward the most specific available so the gateway can + # evaluate read/secret policies against it. + derived_path = tool_input.get('path') or tool_input.get('pattern') + if derived_path: + metadata['file_path'] = derived_path if is_mcp: # Parse mcp____ to extract server and tool for gateway matching From 1d4bc973f299cfd097ed8f3becfdb9d8efae26cf Mon Sep 17 00:00:00 2001 From: MohamedAklamaash Date: Wed, 17 Jun 2026 22:36:07 +0530 Subject: [PATCH 3/3] WEB-4871: only fall back to pattern for Glob, not Grep (review) Grep's `pattern` is a regex, not a path; forwarding it as file_path made the gateway evaluate a regex as a filesystem path. Glob's `pattern` is path-like (`**/*.env`) so it keeps the fallback. Grep/LS now use `path` only. Co-Authored-By: Claude Opus 4.8 (1M context) --- claude-code/hooks/unbound.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/claude-code/hooks/unbound.py b/claude-code/hooks/unbound.py index 2aa1d150..d3eb10c5 100644 --- a/claude-code/hooks/unbound.py +++ b/claude-code/hooks/unbound.py @@ -868,10 +868,15 @@ def process_pre_tool_use(event: Dict, api_key: str) -> Dict: if 'file_path' in tool_input: metadata['file_path'] = tool_input['file_path'] elif tool_name in READ_EQUIVALENT_FILE_TOOLS: - # Grep/LS carry a `path`; Glob carries a `pattern` (itself a glob like - # `**/*.env`). Forward the most specific available so the gateway can - # evaluate read/secret policies against it. - derived_path = tool_input.get('path') or tool_input.get('pattern') + # 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