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/dist/detect.d.ts +27 -0
- package/dist/detect.d.ts.map +1 -0
- package/dist/detect.js +108 -0
- package/dist/detect.js.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +147 -0
- package/dist/index.js.map +1 -0
- package/dist/install.d.ts +59 -0
- package/dist/install.d.ts.map +1 -0
- package/dist/install.js +410 -0
- package/dist/install.js.map +1 -0
- package/dist/uninstall.d.ts +11 -0
- package/dist/uninstall.d.ts.map +1 -0
- package/dist/uninstall.js +176 -0
- package/dist/uninstall.js.map +1 -0
- package/dist/wizard.d.ts +20 -0
- package/dist/wizard.d.ts.map +1 -0
- package/dist/wizard.js +332 -0
- package/dist/wizard.js.map +1 -0
- package/hooks/hook.sh +724 -0
- package/package.json +21 -0
- package/plugins/custodex-opencode.ts +330 -0
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
|