Skip to content
Closed
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
31 changes: 28 additions & 3 deletions claude-code/hooks/unbound.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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__<server>__<tool> to extract server and tool for gateway matching
Expand Down Expand Up @@ -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 = []

Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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:
Expand Down
7 changes: 7 additions & 0 deletions codex/hooks/unbound.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand All @@ -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)


Expand Down
17 changes: 15 additions & 2 deletions cursor/unbound.py
Original file line number Diff line number Diff line change
Expand Up @@ -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', {})
Expand All @@ -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,
Expand Down Expand Up @@ -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


Expand Down