custodex 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/hooks/hook.sh ADDED
@@ -0,0 +1,724 @@
1
+ #!/usr/bin/env bash
2
+ # ============================================================
3
+ # Custodex Universal Governance Hook
4
+ # Version: 1.0.0
5
+ #
6
+ # Supported IDEs (detected in priority order):
7
+ # 1. Gemini CLI — GEMINI_SESSION_ID env var is set
8
+ # 2. Cursor — input JSON has `conversation_id` but no `session_id`
9
+ # 3. Claude Code — input JSON has `session_id` (default fallback)
10
+ #
11
+ # Configuration (in priority order):
12
+ # ~/.custodex/config.json → { apiKey, baseUrl }
13
+ # Env vars → CUSTODEX_API_KEY, CUSTODEX_BASE_URL
14
+ #
15
+ # Deny response formats per IDE:
16
+ # Claude Code — {"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"Custodex: <reason>"}}
17
+ # Cursor — {"permission":"deny","user_message":"Custodex: <reason>"}
18
+ # Gemini CLI — exit 2 (no JSON required)
19
+ # ============================================================
20
+
21
+ set -euo pipefail
22
+
23
+ # ─── 1. LOAD CONFIG ────────────────────────────────────────────────────────────
24
+ # Read from ~/.custodex/config.json first, then fall back to env vars.
25
+
26
+ CUSTODEX_API_KEY="${CUSTODEX_API_KEY:-}"
27
+ CUSTODEX_BASE_URL="${CUSTODEX_BASE_URL:-}"
28
+ CUSTODEX_PROJECT_FILTER="${CUSTODEX_PROJECT_FILTER:-}"
29
+
30
+ _config_file="${HOME}/.custodex/config.json"
31
+ if [[ -f "$_config_file" ]]; then
32
+ _cfg_key=$(/usr/bin/python3 -c "
33
+ import json, sys
34
+ try:
35
+ d = json.load(open('${_config_file}'))
36
+ print(d.get('apiKey', ''))
37
+ except Exception:
38
+ print('')
39
+ " 2>/dev/null || echo "")
40
+ _cfg_url=$(/usr/bin/python3 -c "
41
+ import json, sys
42
+ try:
43
+ d = json.load(open('${_config_file}'))
44
+ print(d.get('baseUrl', '').rstrip('/'))
45
+ except Exception:
46
+ print('')
47
+ " 2>/dev/null || echo "")
48
+
49
+ [[ -z "$CUSTODEX_API_KEY" && -n "$_cfg_key" ]] && CUSTODEX_API_KEY="$_cfg_key"
50
+ [[ -z "$CUSTODEX_BASE_URL" && -n "$_cfg_url" ]] && CUSTODEX_BASE_URL="$_cfg_url"
51
+ fi
52
+
53
+ # Bail out silently if not configured — don't break the IDE.
54
+ [[ -z "$CUSTODEX_API_KEY" || -z "$CUSTODEX_BASE_URL" ]] && exit 0
55
+
56
+ # Normalize trailing slash
57
+ CUSTODEX_BASE_URL="${CUSTODEX_BASE_URL%/}"
58
+
59
+ # ─── 2. STATE DIRECTORY ────────────────────────────────────────────────────────
60
+ CUSTODEX_STATE_DIR="${TMPDIR:-/tmp}/custodex-hooks"
61
+ mkdir -p "$CUSTODEX_STATE_DIR"
62
+
63
+ # ─── 3. READ STDIN ─────────────────────────────────────────────────────────────
64
+ INPUT=$(cat)
65
+
66
+ # ─── 4. IDE DETECTION & FIELD NORMALISATION ────────────────────────────────────
67
+ # All fields are normalised into a flat set of shell variables so the rest of
68
+ # the script can be IDE-agnostic. IDE-specific response formatting is handled
69
+ # only at the output layer.
70
+ #
71
+ # Normalised variables (guaranteed non-empty defaults):
72
+ # IDE — "gemini" | "cursor" | "claude"
73
+ # EVENT — canonical event name (see routing table below)
74
+ # SESSION_ID — unified session/conversation identifier
75
+ # TOOL_NAME — tool being used (may be empty for non-tool events)
76
+ # CWD — working directory
77
+ # SUBAGENT_ID — subagent identifier (may be empty)
78
+ # SUBAGENT_TYPE — subagent type string (may be empty)
79
+ # USER_PROMPT — user prompt text (may be empty)
80
+ # TOOL_OUTPUT — tool output text, truncated (may be empty)
81
+ # RAW_EVENT — original hook_event_name from input
82
+
83
+ eval "$(/usr/bin/python3 -c "
84
+ import sys, json, os
85
+
86
+ raw = '''${INPUT}'''
87
+ try:
88
+ d = json.loads(raw)
89
+ except Exception:
90
+ d = {}
91
+
92
+ # ── IDE Detection ──────────────────────────────────────────────────────────────
93
+ gemini_session = os.environ.get('GEMINI_SESSION_ID', '')
94
+ if gemini_session:
95
+ ide = 'gemini'
96
+ elif 'conversation_id' in d and 'session_id' not in d:
97
+ ide = 'cursor'
98
+ else:
99
+ ide = 'claude'
100
+
101
+ # ── Raw event name ─────────────────────────────────────────────────────────────
102
+ raw_event = d.get('hook_event_name', '')
103
+
104
+ # ── Canonical event mapping ────────────────────────────────────────────────────
105
+ # Normalise IDE-specific event name variants to a shared canonical name so the
106
+ # routing case statement below does not need IDE awareness.
107
+ event_map = {
108
+ # Session start
109
+ 'SessionStart': 'SessionStart',
110
+ 'sessionStart': 'SessionStart',
111
+ # Subagent start
112
+ 'SubagentStart': 'SubagentStart',
113
+ 'subagentStart': 'SubagentStart',
114
+ # Pre-tool use
115
+ 'PreToolUse': 'PreToolUse',
116
+ 'preToolUse': 'PreToolUse',
117
+ 'beforeShellExecution': 'PreToolUse', # Cursor shell variant
118
+ 'beforeMCPExecution': 'PreToolUse', # Cursor MCP variant
119
+ 'BeforeTool': 'PreToolUse', # Gemini
120
+ # Post-tool use
121
+ 'PostToolUse': 'PostToolUse',
122
+ 'postToolUse': 'PostToolUse',
123
+ 'afterShellExecution': 'PostToolUse', # Cursor shell variant
124
+ 'afterFileEdit': 'PostToolUse', # Cursor file-edit variant
125
+ 'AfterTool': 'PostToolUse', # Gemini
126
+ # User prompt
127
+ 'UserPromptSubmit': 'UserPromptSubmit',
128
+ 'beforeSubmitPrompt': 'UserPromptSubmit', # Cursor
129
+ 'BeforeAgent': 'UserPromptSubmit', # Gemini
130
+ }
131
+ event = event_map.get(raw_event, raw_event)
132
+
133
+ # ── Session ID ────────────────────────────────────────────────────────────────
134
+ if ide == 'gemini':
135
+ session_id = gemini_session
136
+ elif ide == 'cursor':
137
+ session_id = d.get('conversation_id', d.get('session_id', ''))
138
+ else:
139
+ session_id = d.get('session_id', '')
140
+
141
+ # ── Tool name ─────────────────────────────────────────────────────────────────
142
+ # For Cursor shell/MCP events the tool name is embedded differently.
143
+ if raw_event == 'beforeShellExecution':
144
+ tool_name = 'Bash'
145
+ elif raw_event == 'afterShellExecution':
146
+ tool_name = 'Bash'
147
+ elif raw_event == 'afterFileEdit':
148
+ tool_name = 'Edit'
149
+ else:
150
+ tool_name = d.get('tool_name', '')
151
+
152
+ # ── CWD ───────────────────────────────────────────────────────────────────────
153
+ cwd = d.get('cwd', os.environ.get('PWD', ''))
154
+
155
+ # ── Subagent fields ───────────────────────────────────────────────────────────
156
+ if ide == 'cursor':
157
+ subagent_id = d.get('subagent_id', d.get('agent_id', ''))
158
+ subagent_type = d.get('subagent_type', d.get('agent_type', ''))
159
+ else:
160
+ subagent_id = d.get('agent_id', '')
161
+ subagent_type = d.get('agent_type', '')
162
+
163
+ # ── User prompt ───────────────────────────────────────────────────────────────
164
+ user_prompt = (
165
+ d.get('user_prompt')
166
+ or d.get('prompt')
167
+ or ''
168
+ )[:4096]
169
+
170
+ # ── Tool output (truncated) ───────────────────────────────────────────────────
171
+ raw_output = d.get('tool_output', d.get('output', ''))
172
+ if isinstance(raw_output, dict):
173
+ raw_output = json.dumps(raw_output)[:2048]
174
+ elif raw_output:
175
+ raw_output = str(raw_output)[:2048]
176
+ else:
177
+ raw_output = ''
178
+
179
+ # ── Cursor-specific extra fields ──────────────────────────────────────────────
180
+ # composer_mode / is_background_agent for SessionStart metadata
181
+ composer_mode = str(d.get('composer_mode', '')).lower()
182
+ is_background_agent = str(d.get('is_background_agent', '')).lower()
183
+ # tool_use_id for Cursor PreToolUse
184
+ tool_use_id = d.get('tool_use_id', '')
185
+
186
+ def esc(v):
187
+ return str(v).replace(\"'\", '').replace('\"', '').replace('\n', ' ').replace('\\\$', '')
188
+
189
+ fields = {
190
+ 'IDE': ide,
191
+ 'EVENT': event,
192
+ 'RAW_EVENT': raw_event,
193
+ 'SESSION_ID': esc(session_id),
194
+ 'TOOL_NAME': esc(tool_name),
195
+ 'CWD': esc(cwd),
196
+ 'SUBAGENT_ID': esc(subagent_id),
197
+ 'SUBAGENT_TYPE': esc(subagent_type),
198
+ 'USER_PROMPT': esc(user_prompt),
199
+ 'TOOL_OUTPUT': esc(raw_output),
200
+ 'COMPOSER_MODE': esc(composer_mode),
201
+ 'IS_BACKGROUND_AGENT': esc(is_background_agent),
202
+ 'TOOL_USE_ID': esc(tool_use_id),
203
+ }
204
+ for k, v in fields.items():
205
+ print(f\"{k}='{v}'\")
206
+ " <<< "$INPUT" 2>/dev/null)" || exit 0
207
+
208
+ # ─── 5. PROJECT FILTER ─────────────────────────────────────────────────────────
209
+ if [[ -n "$CUSTODEX_PROJECT_FILTER" && "$CWD" != *"$CUSTODEX_PROJECT_FILTER"* ]]; then
210
+ exit 0
211
+ fi
212
+
213
+ # ─── 6. UTILITY FUNCTIONS ──────────────────────────────────────────────────────
214
+
215
+ # Return the stored Custodex agent ID for the current session / subagent.
216
+ get_agent_id() {
217
+ if [[ -n "$SUBAGENT_ID" ]]; then
218
+ cat "$CUSTODEX_STATE_DIR/subagent-${SESSION_ID}-${SUBAGENT_ID}" 2>/dev/null || echo ""
219
+ else
220
+ cat "$CUSTODEX_STATE_DIR/agent-${SESSION_ID}" 2>/dev/null || echo ""
221
+ fi
222
+ }
223
+
224
+ # Save a Custodex agent ID to state.
225
+ save_agent_id() {
226
+ local id="$1"
227
+ if [[ -n "$SUBAGENT_ID" ]]; then
228
+ printf '%s' "$id" > "$CUSTODEX_STATE_DIR/subagent-${SESSION_ID}-${SUBAGENT_ID}"
229
+ else
230
+ printf '%s' "$id" > "$CUSTODEX_STATE_DIR/agent-${SESSION_ID}"
231
+ fi
232
+ }
233
+
234
+ # Register an agent with the Custodex API and persist its returned ID.
235
+ # Usage: register_agent <name> [extra_json_fragment]
236
+ # Returns: the new agentId string (printed to stdout), or empty on failure.
237
+ register_agent() {
238
+ local name="$1"
239
+ local extra_meta="${2:-}"
240
+ local response agent_id
241
+ response=$(curl -sf --max-time 4 -X POST "${CUSTODEX_BASE_URL}/api/agents/register" \
242
+ -H "Authorization: Bearer ${CUSTODEX_API_KEY}" \
243
+ -H "Content-Type: application/json" \
244
+ -d "{
245
+ \"name\": \"${name}\",
246
+ \"scopes\": [\"read\", \"write\", \"execute\"],
247
+ \"metadata\": {
248
+ \"project\": \"$(basename "${CWD:-unknown}")\",
249
+ \"source\": \"universal-hook\",
250
+ \"ide\": \"${IDE}\",
251
+ \"sessionId\": \"${SESSION_ID}\"
252
+ ${extra_meta}
253
+ },
254
+ \"protocol\": \"mcp\"
255
+ }" 2>/dev/null) || true
256
+
257
+ if [[ -n "$response" ]]; then
258
+ agent_id=$(/usr/bin/python3 -c "
259
+ import json, sys
260
+ try:
261
+ print(json.loads('''${response}''').get('agentId',''))
262
+ except Exception:
263
+ print('')
264
+ " 2>/dev/null || echo "")
265
+ if [[ -n "$agent_id" ]]; then
266
+ save_agent_id "$agent_id"
267
+ echo "$agent_id"
268
+ return 0
269
+ fi
270
+ fi
271
+ echo ""
272
+ }
273
+
274
+ # Map a tool name to a Custodex action string.
275
+ tool_to_action() {
276
+ local t="$1"
277
+ case "$t" in
278
+ Write|Edit) echo "file:write" ;;
279
+ Bash|shell) echo "shell:execute" ;;
280
+ Read) echo "file:read" ;;
281
+ Agent|Task) echo "agent:spawn" ;;
282
+ Glob|Grep) echo "file:search" ;;
283
+ WebFetch|WebSearch) echo "web:access" ;;
284
+ mcp__*) echo "mcp:call" ;;
285
+ *) echo "tool:use" ;;
286
+ esac
287
+ }
288
+
289
+ # Emit a deny response in the format required by the current IDE, then exit.
290
+ # Usage: deny_response <reason_string>
291
+ deny_response() {
292
+ local reason="$1"
293
+ case "$IDE" in
294
+ claude)
295
+ printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"Custodex: %s"}}\n' "$reason"
296
+ ;;
297
+ cursor)
298
+ printf '{"permission":"deny","user_message":"Custodex: %s"}\n' "$reason"
299
+ ;;
300
+ gemini)
301
+ # Gemini reads exit code 2 as a system-level block; no JSON required.
302
+ exit 2
303
+ ;;
304
+ esac
305
+ exit 0
306
+ }
307
+
308
+ # ─── 7. EVENT HANDLERS ─────────────────────────────────────────────────────────
309
+
310
+ # ── SESSION START ──────────────────────────────────────────────────────────────
311
+ handle_session_start() {
312
+ local proj agent_name agent_id extra_meta
313
+
314
+ proj=$(basename "${CWD:-unknown}")
315
+ agent_name="${proj}-orchestrator"
316
+
317
+ # Cursor exposes composer_mode and is_background_agent on session start.
318
+ extra_meta=", \"role\": \"orchestrator\", \"autoDiscovered\": true"
319
+ if [[ "$IDE" == "cursor" ]]; then
320
+ extra_meta="${extra_meta}, \"composerMode\": \"${COMPOSER_MODE}\", \"isBackgroundAgent\": \"${IS_BACKGROUND_AGENT}\""
321
+ fi
322
+ if [[ "$IDE" == "gemini" ]]; then
323
+ extra_meta="${extra_meta}, \"geminiSessionId\": \"${SESSION_ID}\""
324
+ fi
325
+
326
+ agent_id=$(register_agent "$agent_name" "$extra_meta")
327
+ if [[ -n "$agent_id" ]]; then
328
+ printf '0' > "$CUSTODEX_STATE_DIR/gen-${SESSION_ID}"
329
+ # Only Claude Code reads hookSpecificOutput on SessionStart; other IDEs
330
+ # silently ignore JSON printed here, so it is safe to emit for all.
331
+ printf '{"hookSpecificOutput":{"additionalContext":"[Custodex] Orchestrator registered: %s (ID: %s). Running on %s."}}\n' \
332
+ "$agent_name" "$agent_id" "$IDE"
333
+ fi
334
+ exit 0
335
+ }
336
+
337
+ # ── SUBAGENT START ─────────────────────────────────────────────────────────────
338
+ handle_subagent_start() {
339
+ # Gemini CLI has no subagent concept — skip silently.
340
+ [[ "$IDE" == "gemini" ]] && exit 0
341
+
342
+ local proj agent_name parent_agent_id parent_gen child_gen extra_meta response new_id
343
+
344
+ proj=$(basename "${CWD:-unknown}")
345
+
346
+ if [[ -n "$SUBAGENT_TYPE" ]]; then
347
+ agent_name="${proj}-${SUBAGENT_TYPE}"
348
+ else
349
+ agent_name="${proj}-subagent-$(date +%s)"
350
+ fi
351
+
352
+ parent_agent_id=$(cat "$CUSTODEX_STATE_DIR/agent-${SESSION_ID}" 2>/dev/null || echo "")
353
+ parent_gen=$(cat "$CUSTODEX_STATE_DIR/gen-${SESSION_ID}" 2>/dev/null || echo "0")
354
+ child_gen=$((parent_gen + 1))
355
+
356
+ extra_meta=", \"role\": \"subagent\", \"subagentType\": \"${SUBAGENT_TYPE}\", \"subagentId\": \"${SUBAGENT_ID}\""
357
+
358
+ # Cursor provides task description and parent conversation ID.
359
+ if [[ "$IDE" == "cursor" ]]; then
360
+ local cursor_task
361
+ cursor_task=$(/usr/bin/python3 -c "
362
+ import json, sys
363
+ try:
364
+ d = json.loads(sys.stdin.read())
365
+ t = d.get('task','')
366
+ print(str(t)[:200].replace('\"',''))
367
+ except Exception:
368
+ print('')
369
+ " <<< "$INPUT" 2>/dev/null || echo "")
370
+ [[ -n "$cursor_task" ]] && extra_meta="${extra_meta}, \"task\": \"${cursor_task}\""
371
+ fi
372
+
373
+ response=$(curl -sf --max-time 4 -X POST "${CUSTODEX_BASE_URL}/api/agents/register" \
374
+ -H "Authorization: Bearer ${CUSTODEX_API_KEY}" \
375
+ -H "Content-Type: application/json" \
376
+ -d "{
377
+ \"name\": \"${agent_name}\",
378
+ \"scopes\": [\"read\", \"write\", \"execute\"],
379
+ \"metadata\": {
380
+ \"project\": \"$(basename "${CWD:-unknown}")\",
381
+ \"source\": \"universal-hook\",
382
+ \"ide\": \"${IDE}\",
383
+ \"sessionId\": \"${SESSION_ID}\"
384
+ ${extra_meta}
385
+ },
386
+ \"protocol\": \"mcp\",
387
+ \"parentAgentId\": \"${parent_agent_id}\",
388
+ \"generation\": ${child_gen}
389
+ }" 2>/dev/null) || true
390
+
391
+ if [[ -n "$response" ]]; then
392
+ new_id=$(/usr/bin/python3 -c "
393
+ import json
394
+ try:
395
+ print(json.loads('''${response}''').get('agentId',''))
396
+ except Exception:
397
+ print('')
398
+ " 2>/dev/null || echo "")
399
+ if [[ -n "$new_id" ]]; then
400
+ save_agent_id "$new_id"
401
+ printf '%s' "$child_gen" > "$CUSTODEX_STATE_DIR/gen-${SESSION_ID}-${SUBAGENT_ID}"
402
+ fi
403
+ fi
404
+ exit 0
405
+ }
406
+
407
+ # ── USER PROMPT ────────────────────────────────────────────────────────────────
408
+ handle_user_prompt() {
409
+ local custodex_agent_id safe_prompt caller_type caller_id on_behalf
410
+
411
+ custodex_agent_id=$(get_agent_id)
412
+ [[ -z "$custodex_agent_id" ]] && exit 0
413
+
414
+ # Persist the prompt so subsequent PreToolUse can reference it.
415
+ printf '%s' "$USER_PROMPT" > "$CUSTODEX_STATE_DIR/prompt-${SESSION_ID}"
416
+
417
+ safe_prompt=$(printf '%s' "$USER_PROMPT" | /usr/bin/python3 -c "
418
+ import sys, json
419
+ print(json.dumps(sys.stdin.read().strip()[:4096]))
420
+ " 2>/dev/null || echo '""')
421
+
422
+ caller_type="user"
423
+ caller_id=""
424
+ if [[ -n "$SUBAGENT_ID" ]]; then
425
+ caller_type="agent"
426
+ caller_id=$(cat "$CUSTODEX_STATE_DIR/agent-${SESSION_ID}" 2>/dev/null || echo "")
427
+ fi
428
+
429
+ on_behalf=""
430
+ if [[ -n "$SUBAGENT_TYPE" ]]; then
431
+ on_behalf=", \"onBehalfOf\": \"$(basename "${CWD:-unknown}")-${SUBAGENT_TYPE}\""
432
+ fi
433
+
434
+ curl -sf --max-time 4 -X POST "${CUSTODEX_BASE_URL}/api/telemetry" \
435
+ -H "Authorization: Bearer ${CUSTODEX_API_KEY}" \
436
+ -H "Content-Type: application/json" \
437
+ -d "{
438
+ \"action\": \"user.prompt\",
439
+ \"scope\": \"session:input\",
440
+ \"decision\": \"allowed\",
441
+ \"latencyMs\": 0,
442
+ \"metadata\": {
443
+ \"agentId\": \"${custodex_agent_id}\",
444
+ \"hookEvent\": \"UserPromptSubmit\",
445
+ \"ide\": \"${IDE}\",
446
+ \"sessionId\": \"${SESSION_ID}\",
447
+ \"userPrompt\": ${safe_prompt},
448
+ \"caller\": {
449
+ \"type\": \"${caller_type}\",
450
+ \"id\": \"${caller_id}\"
451
+ },
452
+ \"environment\": {
453
+ \"os\": \"$(uname -s)\",
454
+ \"shell\": \"${SHELL:-unknown}\",
455
+ \"cwd\": \"${CWD:-unknown}\"
456
+ }
457
+ }
458
+ ${on_behalf}
459
+ }" >/dev/null 2>&1 &
460
+
461
+ exit 0
462
+ }
463
+
464
+ # ── PRE TOOL USE (synchronous — can deny) ─────────────────────────────────────
465
+ # Skip read-only tools to reduce latency for operations that carry no risk.
466
+ _READONLY_TOOLS="|Read|Glob|Grep|WebSearch|"
467
+
468
+ handle_pre_tool_use() {
469
+ # Skip read-only tools — no governance action needed.
470
+ if [[ "$_READONLY_TOOLS" == *"|${TOOL_NAME}|"* ]]; then
471
+ exit 0
472
+ fi
473
+
474
+ local custodex_agent_id action scope safe_scope response decision reason
475
+
476
+ custodex_agent_id=$(get_agent_id)
477
+
478
+ # Late-register if no agent ID recorded yet.
479
+ if [[ -z "$custodex_agent_id" ]]; then
480
+ local proj late_name
481
+ proj=$(basename "${CWD:-unknown}")
482
+ if [[ -n "$SUBAGENT_ID" ]]; then
483
+ late_name="${proj}-${SUBAGENT_TYPE:-subagent}-late"
484
+ custodex_agent_id=$(register_agent "$late_name" ", \"role\": \"subagent\", \"subagentType\": \"${SUBAGENT_TYPE}\", \"lateRegistration\": true")
485
+ else
486
+ custodex_agent_id=$(register_agent "${proj}-orchestrator-late" ", \"role\": \"orchestrator\", \"lateRegistration\": true")
487
+ fi
488
+ fi
489
+
490
+ action=$(tool_to_action "$TOOL_NAME")
491
+
492
+ # Extract the primary scope value from tool_input.
493
+ scope=$(/usr/bin/python3 -c "
494
+ import sys, json
495
+ try:
496
+ d = json.loads(sys.stdin.read())
497
+ ti = d.get('tool_input', {})
498
+ if not isinstance(ti, dict): ti = {}
499
+ for k in ['file_path','command','pattern','description','prompt','url','query','skill']:
500
+ if k in ti:
501
+ print(str(ti[k])[:150])
502
+ break
503
+ else:
504
+ print('${TOOL_NAME}')
505
+ except Exception:
506
+ print('${TOOL_NAME}')
507
+ " <<< "$INPUT" 2>/dev/null || echo "$TOOL_NAME")
508
+
509
+ safe_scope=$(printf '%s' "$scope" | /usr/bin/python3 -c "
510
+ import sys, json
511
+ print(json.dumps(sys.stdin.read().strip()))
512
+ " 2>/dev/null || echo "\"unknown\"")
513
+
514
+ # Read last persisted prompt for context.
515
+ local safe_last_prompt='""'
516
+ if [[ -f "$CUSTODEX_STATE_DIR/prompt-${SESSION_ID}" ]]; then
517
+ safe_last_prompt=$(cat "$CUSTODEX_STATE_DIR/prompt-${SESSION_ID}" 2>/dev/null | /usr/bin/python3 -c "
518
+ import sys, json
519
+ print(json.dumps(sys.stdin.read().strip()[:2048]))
520
+ " 2>/dev/null || echo '""')
521
+ fi
522
+
523
+ # Call verify endpoint synchronously.
524
+ response=$(curl -sf --max-time 4 -X POST "${CUSTODEX_BASE_URL}/api/verify" \
525
+ -H "Authorization: Bearer ${CUSTODEX_API_KEY}" \
526
+ -H "Content-Type: application/json" \
527
+ -d "{
528
+ \"agentId\": \"${custodex_agent_id}\",
529
+ \"action\": \"${action}\",
530
+ \"scope\": ${safe_scope},
531
+ \"metadata\": {
532
+ \"toolName\": \"${TOOL_NAME}\",
533
+ \"ide\": \"${IDE}\",
534
+ \"sessionId\": \"${SESSION_ID}\",
535
+ \"userPrompt\": ${safe_last_prompt},
536
+ \"environment\": {
537
+ \"os\": \"$(uname -s)\",
538
+ \"cwd\": \"${CWD:-unknown}\"
539
+ }
540
+ }
541
+ }" 2>/dev/null) || true
542
+
543
+ if [[ -n "$response" ]]; then
544
+ decision=$(/usr/bin/python3 -c "
545
+ import json
546
+ try:
547
+ print(json.loads('''${response}''').get('decision','allowed'))
548
+ except Exception:
549
+ print('allowed')
550
+ " 2>/dev/null || echo "allowed")
551
+
552
+ if [[ "$decision" == "denied" ]]; then
553
+ reason=$(/usr/bin/python3 -c "
554
+ import json
555
+ try:
556
+ d = json.loads('''${response}''')
557
+ print(d.get('reason', d.get('message', 'Policy violation')))
558
+ except Exception:
559
+ print('Policy violation')
560
+ " 2>/dev/null || echo "Policy violation")
561
+ deny_response "$reason"
562
+ # deny_response calls exit — execution never reaches here.
563
+ fi
564
+ fi
565
+
566
+ exit 0
567
+ }
568
+
569
+ # ── POST TOOL USE (fire-and-forget telemetry) ──────────────────────────────────
570
+ handle_post_tool_use() {
571
+ local custodex_agent_id action scope safe_scope actor_name on_behalf
572
+ local safe_tool_output safe_last_prompt caller_type caller_id caller_name
573
+
574
+ custodex_agent_id=$(get_agent_id)
575
+ [[ -z "$custodex_agent_id" ]] && exit 0
576
+
577
+ action=$(tool_to_action "$TOOL_NAME")
578
+
579
+ # Cursor afterFileEdit — override action and extract file path from edits field.
580
+ if [[ "$RAW_EVENT" == "afterFileEdit" ]]; then
581
+ action="file:write"
582
+ local file_path
583
+ file_path=$(/usr/bin/python3 -c "
584
+ import json, sys
585
+ try:
586
+ d = json.loads(sys.stdin.read())
587
+ print(d.get('file_path', ''))
588
+ except Exception:
589
+ print('')
590
+ " <<< "$INPUT" 2>/dev/null || echo "")
591
+ [[ -n "$file_path" ]] && TOOL_NAME="Edit($file_path)"
592
+ fi
593
+
594
+ # Cursor afterShellExecution — capture duration.
595
+ local duration_ms="0"
596
+ if [[ "$RAW_EVENT" == "afterShellExecution" ]]; then
597
+ duration_ms=$(/usr/bin/python3 -c "
598
+ import json, sys
599
+ try:
600
+ d = json.loads(sys.stdin.read())
601
+ print(int(d.get('duration', 0) * 1000))
602
+ except Exception:
603
+ print(0)
604
+ " <<< "$INPUT" 2>/dev/null || echo "0")
605
+ fi
606
+
607
+ scope=$(/usr/bin/python3 -c "
608
+ import sys, json
609
+ try:
610
+ d = json.loads(sys.stdin.read())
611
+ ti = d.get('tool_input', {})
612
+ if not isinstance(ti, dict): ti = {}
613
+ for k in ['file_path','command','pattern','description','prompt','url','query','skill']:
614
+ if k in ti:
615
+ print(str(ti[k])[:150])
616
+ break
617
+ else:
618
+ print('${TOOL_NAME}')
619
+ except Exception:
620
+ print('${TOOL_NAME}')
621
+ " <<< "$INPUT" 2>/dev/null || echo "$TOOL_NAME")
622
+
623
+ safe_scope=$(printf '%s' "$scope" | /usr/bin/python3 -c "
624
+ import sys, json
625
+ print(json.dumps(sys.stdin.read().strip()))
626
+ " 2>/dev/null || echo "\"unknown\"")
627
+
628
+ safe_tool_output='""'
629
+ if [[ -n "$TOOL_OUTPUT" ]]; then
630
+ safe_tool_output=$(printf '%s' "$TOOL_OUTPUT" | /usr/bin/python3 -c "
631
+ import sys, json
632
+ print(json.dumps(sys.stdin.read().strip()[:2048]))
633
+ " 2>/dev/null || echo '""')
634
+ fi
635
+
636
+ safe_last_prompt='""'
637
+ if [[ -f "$CUSTODEX_STATE_DIR/prompt-${SESSION_ID}" ]]; then
638
+ safe_last_prompt=$(cat "$CUSTODEX_STATE_DIR/prompt-${SESSION_ID}" 2>/dev/null | /usr/bin/python3 -c "
639
+ import sys, json
640
+ print(json.dumps(sys.stdin.read().strip()[:2048]))
641
+ " 2>/dev/null || echo '""')
642
+ fi
643
+
644
+ actor_name="orchestrator"
645
+ [[ -n "$SUBAGENT_TYPE" ]] && actor_name="$SUBAGENT_TYPE"
646
+
647
+ on_behalf=""
648
+ [[ -n "$SUBAGENT_TYPE" ]] && on_behalf=", \"onBehalfOf\": \"$(basename "${CWD:-unknown}")-${SUBAGENT_TYPE}\""
649
+
650
+ caller_type="user"
651
+ caller_id=""
652
+ caller_name=""
653
+ if [[ -n "$SUBAGENT_ID" ]]; then
654
+ caller_type="agent"
655
+ caller_id=$(cat "$CUSTODEX_STATE_DIR/agent-${SESSION_ID}" 2>/dev/null || echo "")
656
+ caller_name="orchestrator"
657
+ fi
658
+
659
+ # Tool input summary (key fields only, safe for JSON embedding).
660
+ local tool_summary
661
+ tool_summary=$(/usr/bin/python3 -c "
662
+ import sys, json
663
+ try:
664
+ d = json.loads(sys.stdin.read()).get('tool_input', {})
665
+ if not isinstance(d, dict): d = {}
666
+ s = {}
667
+ for k in ['command','file_path','pattern','description','prompt','url','query','skill','name']:
668
+ if k in d: s[k] = str(d[k])[:200]
669
+ print(json.dumps(s))
670
+ except Exception:
671
+ print('{}')
672
+ " <<< "$INPUT" 2>/dev/null || echo "{}")
673
+
674
+ # Fire telemetry in the background — must not block the IDE.
675
+ curl -sf --max-time 4 -X POST "${CUSTODEX_BASE_URL}/api/telemetry" \
676
+ -H "Authorization: Bearer ${CUSTODEX_API_KEY}" \
677
+ -H "Content-Type: application/json" \
678
+ -d "{
679
+ \"action\": \"${action}\",
680
+ \"scope\": ${safe_scope},
681
+ \"decision\": \"allowed\",
682
+ \"latencyMs\": ${duration_ms},
683
+ \"metadata\": {
684
+ \"agentId\": \"${custodex_agent_id}\",
685
+ \"toolName\": \"${TOOL_NAME}\",
686
+ \"hookEvent\": \"PostToolUse\",
687
+ \"rawEvent\": \"${RAW_EVENT}\",
688
+ \"ide\": \"${IDE}\",
689
+ \"sessionId\": \"${SESSION_ID}\",
690
+ \"actor\": \"${actor_name}\",
691
+ \"subagentType\": \"${SUBAGENT_TYPE}\",
692
+ \"subagentId\": \"${SUBAGENT_ID}\",
693
+ \"project\": \"$(basename "${CWD:-unknown}")\",
694
+ \"toolInput\": ${tool_summary},
695
+ \"toolOutput\": ${safe_tool_output},
696
+ \"userPrompt\": ${safe_last_prompt},
697
+ \"caller\": {
698
+ \"type\": \"${caller_type}\",
699
+ \"id\": \"${caller_id}\",
700
+ \"name\": \"${caller_name}\",
701
+ \"directive\": ${safe_last_prompt}
702
+ },
703
+ \"environment\": {
704
+ \"os\": \"$(uname -s)\",
705
+ \"shell\": \"${SHELL:-unknown}\",
706
+ \"cwd\": \"${CWD:-unknown}\"
707
+ }
708
+ }
709
+ ${on_behalf}
710
+ }" >/dev/null 2>&1 &
711
+
712
+ exit 0
713
+ }
714
+
715
+ # ─── 8. ROUTE ──────────────────────────────────────────────────────────────────
716
+ # EVENT is the normalised canonical event name set during field extraction.
717
+ case "$EVENT" in
718
+ SessionStart) handle_session_start ;;
719
+ SubagentStart) handle_subagent_start ;;
720
+ PreToolUse) handle_pre_tool_use ;;
721
+ PostToolUse) handle_post_tool_use ;;
722
+ UserPromptSubmit) handle_user_prompt ;;
723
+ *) exit 0 ;;
724
+ esac