@datagusto/cli 0.0.0-stage → 0.1.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.
Files changed (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +186 -2
  3. package/bin/run.js +5 -0
  4. package/dist/adapters/claude-code.d.ts +47 -0
  5. package/dist/adapters/claude-code.js +533 -0
  6. package/dist/adapters/claude-code.js.map +1 -0
  7. package/dist/adapters/claude-settings.d.ts +29 -0
  8. package/dist/adapters/claude-settings.js +115 -0
  9. package/dist/adapters/claude-settings.js.map +1 -0
  10. package/dist/adapters/index.d.ts +10 -0
  11. package/dist/adapters/index.js +37 -0
  12. package/dist/adapters/index.js.map +1 -0
  13. package/dist/adapters/kiro-agent.d.ts +29 -0
  14. package/dist/adapters/kiro-agent.js +124 -0
  15. package/dist/adapters/kiro-agent.js.map +1 -0
  16. package/dist/adapters/kiro.d.ts +24 -0
  17. package/dist/adapters/kiro.js +740 -0
  18. package/dist/adapters/kiro.js.map +1 -0
  19. package/dist/adapters/types.d.ts +79 -0
  20. package/dist/adapters/types.js +14 -0
  21. package/dist/adapters/types.js.map +1 -0
  22. package/dist/api/client.d.ts +36 -0
  23. package/dist/api/client.js +85 -0
  24. package/dist/api/client.js.map +1 -0
  25. package/dist/api/errors.d.ts +4 -0
  26. package/dist/api/errors.js +25 -0
  27. package/dist/api/errors.js.map +1 -0
  28. package/dist/commands/remove.d.ts +13 -0
  29. package/dist/commands/remove.js +59 -0
  30. package/dist/commands/remove.js.map +1 -0
  31. package/dist/commands/setup.d.ts +16 -0
  32. package/dist/commands/setup.js +114 -0
  33. package/dist/commands/setup.js.map +1 -0
  34. package/dist/commands/status.d.ts +7 -0
  35. package/dist/commands/status.js +53 -0
  36. package/dist/commands/status.js.map +1 -0
  37. package/dist/display.d.ts +2 -0
  38. package/dist/display.js +8 -0
  39. package/dist/display.js.map +1 -0
  40. package/dist/fs/atomic.d.ts +17 -0
  41. package/dist/fs/atomic.js +41 -0
  42. package/dist/fs/atomic.js.map +1 -0
  43. package/dist/key-input.d.ts +24 -0
  44. package/dist/key-input.js +96 -0
  45. package/dist/key-input.js.map +1 -0
  46. package/dist/state/installs.d.ts +24 -0
  47. package/dist/state/installs.js +110 -0
  48. package/dist/state/installs.js.map +1 -0
  49. package/dist/vendor.d.ts +16 -0
  50. package/dist/vendor.js +60 -0
  51. package/dist/vendor.js.map +1 -0
  52. package/package.json +57 -4
  53. package/vendor/claude_code/.claude-plugin/plugin.json +55 -0
  54. package/vendor/claude_code/README.md +267 -0
  55. package/vendor/claude_code/dist/api-client.js +82 -0
  56. package/vendor/claude_code/dist/builtin-tools.generated.js +33 -0
  57. package/vendor/claude_code/dist/extractors.js +201 -0
  58. package/vendor/claude_code/dist/index.js +695 -0
  59. package/vendor/claude_code/dist/monitoring.js +66 -0
  60. package/vendor/claude_code/dist/state.js +71 -0
  61. package/vendor/claude_code/dist/types.js +5 -0
  62. package/vendor/claude_code/hooks/hooks.json +25 -0
  63. package/vendor/claude_code/package.json +26 -0
  64. package/vendor/kiro/.kiro/agents/kiro_default.json +20 -0
  65. package/vendor/kiro/.kiro/hooks/datagusto-post-tool-use.json +10 -0
  66. package/vendor/kiro/.kiro/hooks/datagusto-pre-tool-use.json +10 -0
  67. package/vendor/kiro/.kiro/hooks/datagusto-session-start.json +10 -0
  68. package/vendor/kiro/.kiro/hooks/datagusto-stop.json +10 -0
  69. package/vendor/kiro/.kiro/hooks/datagusto-user-prompt-submit.json +10 -0
  70. package/vendor/kiro/dist/api-client.js +82 -0
  71. package/vendor/kiro/dist/builtin-tools.generated.js +87 -0
  72. package/vendor/kiro/dist/engine.js +65 -0
  73. package/vendor/kiro/dist/extractors.js +201 -0
  74. package/vendor/kiro/dist/index.js +735 -0
  75. package/vendor/kiro/dist/monitoring.js +66 -0
  76. package/vendor/kiro/dist/state.js +71 -0
  77. package/vendor/kiro/dist/types.js +5 -0
  78. package/vendor/kiro/package.json +25 -0
  79. package/vendor/manifest.json +42 -0
@@ -0,0 +1,695 @@
1
+ #!/usr/bin/env node
2
+ // ---------------------------------------------------------------------------
3
+ // Datagusto Safety Plugin - Claude Code Hook Entry Point
4
+ // ---------------------------------------------------------------------------
5
+ //
6
+ // Claude Code does not load a plugin into a long-running process the way
7
+ // OpenClaw does. Instead, it spawns this script as a fresh process for each
8
+ // hook event, writes one JSON payload to stdin, and reads the decision back
9
+ // from stdout / the process exit code. There is no `register(api)` call and
10
+ // no event emitter to subscribe to — `main()` below is the entire plugin,
11
+ // dispatching on `hook_event_name` once per invocation.
12
+ import { readFileSync } from "node:fs";
13
+ import { dirname, join } from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+ import { DatagustoApiClient } from "./api-client.js";
16
+ import { buildBuiltinToolDefinitions, extractBlockReason, extractPhase, hashPayload, isClaudeCodeCommand, normalizeEvaluateResponse, serializeToolResult, } from "./extractors.js";
17
+ import { describeMonitoring, readMonitoringFlag, resolveMonitoring, } from "./monitoring.js";
18
+ import { deleteState, loadState, mutateState } from "./state.js";
19
+ /**
20
+ * Middleware self-declaration of the agent framework, sent on every
21
+ * alignment and validation request. Mirrors the OpenClaw plugin's
22
+ * `AGENT_TYPE` constant; the Datagusto server uses this to select the
23
+ * matching AgentProfile once a Claude-Code-specific profile is configured.
24
+ */
25
+ const AGENT_TYPE = "claude_code";
26
+ // Env var overrides use the DATAGUSTO_SAFETY_* prefix and take precedence
27
+ // over claude-code.json values, exactly like the OpenClaw plugin. This
28
+ // remains the config path for the dev workflow of piping synthetic stdin
29
+ // payloads straight into dist/index.js without a plugin host.
30
+ const ENV_PREFIX = "DATAGUSTO_SAFETY_";
31
+ function envStr(suffix) {
32
+ const v = process.env[ENV_PREFIX + suffix];
33
+ return v && v.length > 0 ? v : undefined;
34
+ }
35
+ function envNum(suffix) {
36
+ const v = process.env[ENV_PREFIX + suffix];
37
+ if (v === undefined || v === "")
38
+ return undefined;
39
+ const n = Number(v);
40
+ return Number.isFinite(n) ? n : undefined;
41
+ }
42
+ function envBool(suffix) {
43
+ const v = process.env[ENV_PREFIX + suffix];
44
+ if (v === undefined)
45
+ return undefined;
46
+ const lower = v.toLowerCase();
47
+ if (["true", "1", "yes"].includes(lower))
48
+ return true;
49
+ if (["false", "0", "no"].includes(lower))
50
+ return false;
51
+ return undefined;
52
+ }
53
+ // CLAUDE_PLUGIN_OPTION_* is exported by the Claude Code plugin host when this
54
+ // plugin is loaded via .claude-plugin/plugin.json's userConfig. It takes
55
+ // top priority over DATAGUSTO_SAFETY_*/claude-code.json, which stay as the
56
+ // fallback for the no-plugin-host dev workflow.
57
+ const PLUGIN_OPTION_PREFIX = "CLAUDE_PLUGIN_OPTION_";
58
+ function pluginOptionStr(suffix) {
59
+ const v = process.env[PLUGIN_OPTION_PREFIX + suffix];
60
+ return v && v.length > 0 ? v : undefined;
61
+ }
62
+ function pluginOptionNum(suffix) {
63
+ const v = process.env[PLUGIN_OPTION_PREFIX + suffix];
64
+ if (v === undefined || v === "")
65
+ return undefined;
66
+ const n = Number(v);
67
+ return Number.isFinite(n) ? n : undefined;
68
+ }
69
+ function pluginOptionBool(suffix) {
70
+ const v = process.env[PLUGIN_OPTION_PREFIX + suffix];
71
+ if (v === undefined)
72
+ return undefined;
73
+ const lower = v.toLowerCase();
74
+ if (["true", "1", "yes"].includes(lower))
75
+ return true;
76
+ if (["false", "0", "no"].includes(lower))
77
+ return false;
78
+ return undefined;
79
+ }
80
+ /**
81
+ * Load plugin config from claude-code.json (resolved next to this script's
82
+ * compiled location, or overridden via DATAGUSTO_SAFETY_CONFIG_PATH) with
83
+ * CLAUDE_PLUGIN_OPTION_* taking top priority, then DATAGUSTO_SAFETY_* env
84
+ * vars, then the file.
85
+ */
86
+ function loadConfig() {
87
+ const here = dirname(fileURLToPath(import.meta.url));
88
+ const configPath = envStr("CONFIG_PATH") ?? join(here, "..", "claude-code.json");
89
+ let fileConfig = {};
90
+ try {
91
+ fileConfig = JSON.parse(readFileSync(configPath, "utf8"));
92
+ }
93
+ catch {
94
+ // No config file on disk — rely entirely on env vars.
95
+ }
96
+ return {
97
+ serverUrl: pluginOptionStr("SERVER_URL") ?? envStr("SERVER_URL") ?? fileConfig.serverUrl ?? "",
98
+ apiKey: pluginOptionStr("API_KEY") ?? envStr("API_KEY") ?? fileConfig.apiKey ?? "",
99
+ timeoutMs: pluginOptionNum("TIMEOUT_MS") ?? envNum("TIMEOUT_MS") ?? fileConfig.timeoutMs ?? 30_000,
100
+ failClosed: pluginOptionBool("FAIL_CLOSED") ?? envBool("FAIL_CLOSED") ?? fileConfig.failClosed ?? true,
101
+ verbose: pluginOptionBool("VERBOSE") ?? envBool("VERBOSE") ?? fileConfig.verbose ?? false,
102
+ monitorOnly: pluginOptionBool("MONITOR_ONLY") ?? envBool("MONITOR_ONLY") ?? fileConfig.monitorOnly ?? false,
103
+ autoApproveVettedPermissions: pluginOptionBool("AUTO_APPROVE_VETTED_PERMISSIONS") ??
104
+ envBool("AUTO_APPROVE_VETTED_PERMISSIONS") ??
105
+ fileConfig.autoApproveVettedPermissions ??
106
+ false,
107
+ allowUncategorizedTools: pluginOptionBool("ALLOW_UNCATEGORIZED_TOOLS") ??
108
+ envBool("ALLOW_UNCATEGORIZED_TOOLS") ??
109
+ fileConfig.allowUncategorizedTools ??
110
+ true,
111
+ };
112
+ }
113
+ async function readStdin() {
114
+ const chunks = [];
115
+ for await (const chunk of process.stdin)
116
+ chunks.push(chunk);
117
+ return Buffer.concat(chunks).toString("utf8");
118
+ }
119
+ // A user-facing warning queued by resolveMonitoringForHook and flushed with
120
+ // whatever JSON this process writes to stdout. Claude Code shows hook stderr
121
+ // only on a non-zero exit, so a notice must ride on the hook output's
122
+ // `systemMessage` field (common to every hook event) to reach the user. A
123
+ // hook writes at most one JSON document, so the notice is merged into the
124
+ // decision rather than emitted on its own — see writeDecision / main().
125
+ let pendingSystemMessage;
126
+ // Guards the one-document-per-process invariant: a second write would
127
+ // concatenate two JSON objects on stdout, which Claude Code rejects.
128
+ let decisionWritten = false;
129
+ function writeDecision(decision) {
130
+ if (decisionWritten) {
131
+ process.stderr.write("[datagusto] BUG: a hook decision was already written; ignoring a second one.\n");
132
+ return;
133
+ }
134
+ decisionWritten = true;
135
+ const out = { ...decision };
136
+ if (pendingSystemMessage) {
137
+ out.systemMessage = pendingSystemMessage;
138
+ pendingSystemMessage = undefined;
139
+ }
140
+ process.stdout.write(JSON.stringify(out));
141
+ }
142
+ /** Write a bare `{ systemMessage }` when no decision consumed the notice. */
143
+ function flushPendingSystemMessage() {
144
+ if (!pendingSystemMessage || decisionWritten)
145
+ return;
146
+ decisionWritten = true;
147
+ process.stdout.write(JSON.stringify({ systemMessage: pendingSystemMessage }));
148
+ pendingSystemMessage = undefined;
149
+ }
150
+ function denyPreToolUse(reason) {
151
+ writeDecision({
152
+ hookSpecificOutput: {
153
+ hookEventName: "PreToolUse",
154
+ permissionDecision: "deny",
155
+ permissionDecisionReason: reason,
156
+ },
157
+ });
158
+ }
159
+ function askPreToolUse(reason) {
160
+ writeDecision({
161
+ hookSpecificOutput: {
162
+ hookEventName: "PreToolUse",
163
+ permissionDecision: "ask",
164
+ permissionDecisionReason: reason,
165
+ },
166
+ });
167
+ }
168
+ /**
169
+ * Blocks a PostToolUse/PostToolUseFailure result and replaces the tool's
170
+ * recorded output via `updatedToolOutput` (`hookSpecificOutput`), so a
171
+ * blocked call's real output never reaches the model — not just feedback
172
+ * alongside it. `summary` is the underlying reason text with no wrapper
173
+ * sentence; this builds both the top-level `reason` and the replacement
174
+ * output text from it independently, so neither repeats the other's framing.
175
+ */
176
+ function blockPostToolUseWithRewrite(toolName, summary, phase, hookEventName) {
177
+ writeDecision({
178
+ decision: "block",
179
+ reason: `Guardrail flagged tool '${toolName}' output [phase: ${phase}]. ${summary}`,
180
+ hookSpecificOutput: {
181
+ hookEventName,
182
+ updatedToolOutput: `[BLOCKED] Output of '${toolName}' was suppressed by Datagusto [phase: ${phase}]. ${summary}`,
183
+ },
184
+ });
185
+ }
186
+ function injectPostToolUseContext(note, hookEventName) {
187
+ writeDecision({
188
+ hookSpecificOutput: {
189
+ hookEventName,
190
+ additionalContext: note,
191
+ },
192
+ });
193
+ }
194
+ const PLATFORM_MONITORING_NOTICE = "[datagusto] Platform monitoring mode is enabled for this agent: " +
195
+ "guardrail verdicts are recorded but not enforced.";
196
+ /**
197
+ * Single place that decides whether verdicts are enforced for this hook.
198
+ *
199
+ * Effective monitoring = plugin `monitorOnly` OR the platform's per-agent
200
+ * `monitoring_mode_enabled`, where the platform value is taken from the
201
+ * current response when it carries the flag, else from the value most
202
+ * recently reported by any alignment / validate response for this session
203
+ * (kept in the state file), else false.
204
+ *
205
+ * The cached flag and the once-per-session notice bookkeeping are persisted
206
+ * here through mutateState(), i.e. against a freshly re-read state rather
207
+ * than the caller's possibly stale snapshot, and the caller's `state` is
208
+ * synced from that fresh copy. The first time the platform flag is seen as
209
+ * true in a session, a user-facing notice is queued as the hook output's
210
+ * `systemMessage` (regardless of `verbose`). Fail-closed handling of API
211
+ * errors deliberately does not go through this helper — the platform value
212
+ * governs verdict enforcement only.
213
+ */
214
+ function resolveMonitoringForHook(cfg, claudeSessionId, state, response, dbg) {
215
+ const flag = readMonitoringFlag(response);
216
+ let showNotice = false;
217
+ const fresh = mutateState(claudeSessionId, (s) => {
218
+ if (flag !== undefined)
219
+ s.monitoringModeEnabled = flag;
220
+ const platformEnabled = typeof flag === "boolean" ? flag : s.monitoringModeEnabled === true;
221
+ if (platformEnabled && !s.monitoringNoticeShown) {
222
+ s.monitoringNoticeShown = true;
223
+ showNotice = true;
224
+ }
225
+ });
226
+ state.monitoringModeEnabled = fresh.monitoringModeEnabled;
227
+ state.monitoringNoticeShown = fresh.monitoringNoticeShown;
228
+ const decision = resolveMonitoring(cfg.monitorOnly, flag, fresh.monitoringModeEnabled);
229
+ if (showNotice)
230
+ pendingSystemMessage = PLATFORM_MONITORING_NOTICE;
231
+ if (decision.active)
232
+ dbg(`Monitoring mode active (${describeMonitoring(decision)}).`);
233
+ return decision;
234
+ }
235
+ // ---------------------------------------------------------------------------
236
+ // SessionStart — eager tool registration
237
+ //
238
+ // Decouples tool-catalog registration from the first UserPromptSubmit so a
239
+ // session that never sends a real instruction (e.g. only ever runs slash
240
+ // commands) still has its tools registered. SessionStart has no blocking
241
+ // semantics, only additionalContext — this handler never needs to emit one.
242
+ // ---------------------------------------------------------------------------
243
+ async function handleSessionStart(payload, client, cfg, dbg) {
244
+ const state = loadState(payload.session_id);
245
+ const tools = buildBuiltinToolDefinitions();
246
+ const toolsHash = hashPayload({ tools });
247
+ if (toolsHash !== state.registeredToolsHash) {
248
+ dbg(`SessionStart (source=${payload.source}): registering ${tools.length} built-in tools...`);
249
+ const res = await client.registerTools(tools);
250
+ dbg(`Register result: ok=${res.ok}, status=${res.status}`);
251
+ if (res.ok)
252
+ state.registeredToolsHash = toolsHash;
253
+ }
254
+ else {
255
+ dbg(`SessionStart (source=${payload.source}): tools hash unchanged, skipping registration.`);
256
+ }
257
+ // Persist only this handler's field; a resumed session may already have
258
+ // other hooks running against the same state file.
259
+ mutateState(payload.session_id, (s) => {
260
+ s.registeredToolsHash = state.registeredToolsHash;
261
+ });
262
+ }
263
+ // ---------------------------------------------------------------------------
264
+ // SessionEnd — state cleanup
265
+ // ---------------------------------------------------------------------------
266
+ async function handleSessionEnd(payload, client, cfg, dbg) {
267
+ dbg(`SessionEnd: deleting state for session ${payload.session_id}`);
268
+ deleteState(payload.session_id);
269
+ }
270
+ // ---------------------------------------------------------------------------
271
+ // UserPromptSubmit — tool registration + alignment
272
+ //
273
+ // Folds OpenClaw's two hooks (`before_agent_start` reset + `llm_input`
274
+ // register/align) into one: Claude Code fires this once per user turn and
275
+ // hands over the raw prompt directly, so there's no historyMessages to scan
276
+ // and no separate reset step (state is already namespaced by session_id).
277
+ // Tool registration is also covered by SessionStart now; this hash-gated
278
+ // check stays as a cheap defense-in-depth fallback.
279
+ // ---------------------------------------------------------------------------
280
+ async function handleUserPromptSubmit(payload, client, cfg, dbg) {
281
+ const state = loadState(payload.session_id);
282
+ const tools = buildBuiltinToolDefinitions();
283
+ const toolsHash = hashPayload({ tools });
284
+ if (toolsHash !== state.registeredToolsHash) {
285
+ dbg(`Registering ${tools.length} built-in tools...`);
286
+ const res = await client.registerTools(tools);
287
+ dbg(`Register result: ok=${res.ok}, status=${res.status}`);
288
+ if (res.ok)
289
+ state.registeredToolsHash = toolsHash;
290
+ }
291
+ else {
292
+ dbg("Tools hash unchanged, skipping registration.");
293
+ }
294
+ if (!isClaudeCodeCommand(payload.prompt)) {
295
+ const instrHash = hashPayload(payload.prompt);
296
+ if (instrHash !== state.lastUserInstructionHash) {
297
+ dbg("Sending alignment...");
298
+ const res = await client.postAlignment(payload.prompt, state.sessionId, AGENT_TYPE);
299
+ dbg(`Alignment result: ok=${res.ok}, status=${res.status}`);
300
+ if (res.ok && res.data) {
301
+ const data = res.data;
302
+ if (typeof data.session_id === "string" && data.session_id) {
303
+ state.sessionId = data.session_id;
304
+ }
305
+ state.lastUserInstructionHash = instrHash;
306
+ // Cache the platform monitoring flag for hooks whose own response
307
+ // has none (PermissionRequest, evaluate-only paths).
308
+ resolveMonitoringForHook(cfg, payload.session_id, state, data, dbg);
309
+ }
310
+ }
311
+ else {
312
+ dbg("Instruction hash unchanged, skipping alignment.");
313
+ }
314
+ }
315
+ else {
316
+ dbg("Prompt is a slash command / bash-mode shortcut, skipping alignment.");
317
+ }
318
+ // Persist only the fields this handler owns (the monitoring fields were
319
+ // already written by resolveMonitoringForHook), never the stale snapshot.
320
+ mutateState(payload.session_id, (s) => {
321
+ s.registeredToolsHash = state.registeredToolsHash;
322
+ s.sessionId = state.sessionId;
323
+ s.lastUserInstructionHash = state.lastUserInstructionHash;
324
+ });
325
+ }
326
+ // ---------------------------------------------------------------------------
327
+ // PreToolUse — on_start validation
328
+ // ---------------------------------------------------------------------------
329
+ async function handlePreToolUse(payload, client, cfg, dbg) {
330
+ const state = loadState(payload.session_id);
331
+ const input = (payload.tool_input ?? {});
332
+ // 1. Alignment/session validation (intent-judge). Session-scoped: only runs
333
+ // once alignment has produced a session_id. Absence isn't fatal on its own
334
+ // — the assigned-guardrail evaluate below is agent-scoped and still runs.
335
+ let alignmentRaw = null;
336
+ let alignmentError = false;
337
+ if (state.sessionId) {
338
+ const res = await client.postValidate({
339
+ session_id: state.sessionId,
340
+ process_name: payload.tool_name,
341
+ process_type: "tool",
342
+ timing: "on_start",
343
+ input,
344
+ agent_type: AGENT_TYPE,
345
+ });
346
+ dbg(`Validate on_start result: ok=${res.ok}, status=${res.status}`);
347
+ if (res.ok && res.data)
348
+ alignmentRaw = res.data;
349
+ else if (!res.ok)
350
+ alignmentError = true;
351
+ }
352
+ else {
353
+ dbg("No sessionId; skipping alignment validate, relying on assigned-guardrail evaluate.");
354
+ }
355
+ // 2. Assigned (dashboard-authored) guardrail evaluation — the only path that
356
+ // fires the agent's assigned rules. Agent-scoped via API key, no session.
357
+ const evalRes = await client.evaluateGuardrails({
358
+ process_name: payload.tool_name,
359
+ process_type: "tool",
360
+ timing: "on_start",
361
+ context: { input },
362
+ });
363
+ dbg(`Evaluate on_start result: ok=${evalRes.ok}, status=${evalRes.status}`);
364
+ let evaluateRaw = null;
365
+ let evaluate = null;
366
+ let evaluateError = false;
367
+ if (evalRes.ok && evalRes.data) {
368
+ evaluateRaw = evalRes.data;
369
+ evaluate = normalizeEvaluateResponse(evalRes.data);
370
+ }
371
+ else if (!evalRes.ok) {
372
+ evaluateError = true;
373
+ }
374
+ // Effective monitoring mode for this call. /guardrails/evaluate carries no
375
+ // flag, so the validate response (or the cached alignment value) decides.
376
+ const monitoring = resolveMonitoringForHook(cfg, payload.session_id, state, alignmentRaw, dbg);
377
+ // 3. Fail-closed: if either layer errored and we can't be sure, deny.
378
+ // Only the plugin's own monitorOnly relaxes this — the platform's
379
+ // monitoring mode governs verdicts, not error handling.
380
+ if ((alignmentError || evaluateError) && cfg.failClosed) {
381
+ if (!cfg.monitorOnly) {
382
+ denyPreToolUse("Datagusto safety: validation API error.");
383
+ }
384
+ else {
385
+ dbg(`[MONITOR] Deny skipped (plugin monitorOnly, API error): ${payload.tool_name}`);
386
+ }
387
+ return;
388
+ }
389
+ // 4. Merge (block-wins). A false from either layer blocks — except the
390
+ // alignment layer's "uncategorized_tool" default-deny, which (when
391
+ // allowUncategorizedTools is on) is downgraded to allow if the
392
+ // assigned-guardrail evaluation explicitly allowed the call. Real intent
393
+ // violations (phase v2:<Tool>:N) are never downgraded.
394
+ let alignmentProceed = alignmentRaw?.should_proceed !== false;
395
+ const evaluateProceed = evaluate?.should_proceed !== false;
396
+ if (!alignmentProceed &&
397
+ cfg.allowUncategorizedTools &&
398
+ evaluate !== null &&
399
+ evaluateProceed &&
400
+ alignmentRaw !== null &&
401
+ extractPhase(alignmentRaw) === "uncategorized_tool") {
402
+ dbg(`on_start uncategorized-deny downgraded to allow (assigned rules allowed): ${payload.tool_name}`);
403
+ alignmentProceed = true;
404
+ }
405
+ const shouldProceed = alignmentProceed && evaluateProceed;
406
+ // Prefer alignment's severity/instruction, then the assigned-guardrail's.
407
+ const alignmentExt = alignmentRaw;
408
+ const severity = alignmentExt?.severity ?? evaluate?.severity;
409
+ const injectedInstruction = alignmentExt?.injected_instruction ?? evaluate?.injected_instruction;
410
+ // Cache the merged verdict for a subsequent PermissionRequest auto-resolve.
411
+ // Written through mutateState so a parallel hook's fields survive.
412
+ const lastPreToolUseCheck = {
413
+ inputHash: hashPayload({ tool_name: payload.tool_name, tool_input: payload.tool_input }),
414
+ shouldProceed,
415
+ severity,
416
+ };
417
+ mutateState(payload.session_id, (s) => {
418
+ s.lastPreToolUseCheck = lastPreToolUseCheck;
419
+ });
420
+ if (!shouldProceed) {
421
+ // Attribute the block to whichever layer denied (assigned rule first — its
422
+ // message is user-authored and more specific — then alignment).
423
+ const blockerRaw = (evaluate?.should_proceed === false ? evaluateRaw : null) ??
424
+ (alignmentProceed ? null : alignmentRaw);
425
+ const reason = blockerRaw ? extractBlockReason(blockerRaw) : "policy violation";
426
+ const phase = (blockerRaw ? extractPhase(blockerRaw) : undefined) ?? "unknown";
427
+ dbg(`on_start BLOCKED (phase=${phase}): ${payload.tool_name} — ${reason}`);
428
+ if (!monitoring.active) {
429
+ denyPreToolUse(`Guardrail blocked tool '${payload.tool_name}' [phase: ${phase}]. ${reason}`);
430
+ }
431
+ else {
432
+ dbg(`[MONITOR] Deny skipped (${describeMonitoring(monitoring)}): ${payload.tool_name}`);
433
+ }
434
+ }
435
+ else if (severity === "medium" && injectedInstruction) {
436
+ dbg(`on_start ASK: ${payload.tool_name} — requesting approval`);
437
+ if (!monitoring.active) {
438
+ askPreToolUse(injectedInstruction);
439
+ }
440
+ else {
441
+ dbg(`[MONITOR] Ask skipped (${describeMonitoring(monitoring)}): ${payload.tool_name}`);
442
+ }
443
+ }
444
+ else {
445
+ const meta = alignmentRaw?.metadata;
446
+ if (meta?.bypassed_by_framework_routine) {
447
+ dbg(`on_start BYPASSED (framework_routine): ${payload.tool_name} (matched=${meta.matched_pattern_id ?? "?"})`);
448
+ }
449
+ else {
450
+ dbg(`on_start ALLOWED: ${payload.tool_name}`);
451
+ }
452
+ }
453
+ }
454
+ // ---------------------------------------------------------------------------
455
+ // PermissionRequest — auto-resolve Claude Code's native permission dialog
456
+ // when Datagusto already vetted this exact call during PreToolUse.
457
+ //
458
+ // Fires only when PreToolUse passed the call through with no explicit
459
+ // decision AND Claude Code's own permission_mode would otherwise prompt the
460
+ // user. Never overrides an explicit PreToolUse "ask" (severity medium) — the
461
+ // whole point of that verdict is to surface the dialog with our injected
462
+ // instruction; auto-answering it would defeat the point. Off by default —
463
+ // gated behind cfg.autoApproveVettedPermissions.
464
+ // ---------------------------------------------------------------------------
465
+ async function handlePermissionRequest(payload, client, cfg, dbg) {
466
+ if (!cfg.autoApproveVettedPermissions) {
467
+ dbg("PermissionRequest: auto-approve disabled, leaving native dialog as-is.");
468
+ return;
469
+ }
470
+ const state = loadState(payload.session_id);
471
+ const inputHash = hashPayload({ tool_name: payload.tool_name, tool_input: payload.tool_input });
472
+ const cached = state.lastPreToolUseCheck;
473
+ if (!cached || cached.inputHash !== inputHash) {
474
+ dbg("PermissionRequest: no matching cached PreToolUse verdict, leaving native dialog as-is.");
475
+ return;
476
+ }
477
+ if (cached.severity === "medium") {
478
+ dbg("PermissionRequest: cached verdict was an explicit ask, not auto-resolving.");
479
+ return;
480
+ }
481
+ const behavior = cached.shouldProceed ? "allow" : "deny";
482
+ // No API call here, so the platform flag comes from the value PreToolUse
483
+ // cached in state for this session.
484
+ const monitoring = resolveMonitoring(cfg.monitorOnly, undefined, state.monitoringModeEnabled);
485
+ if (monitoring.active) {
486
+ dbg(`[MONITOR] Auto-${behavior} skipped (${describeMonitoring(monitoring)}): ${payload.tool_name}`);
487
+ return;
488
+ }
489
+ dbg(`PermissionRequest: auto-${behavior === "allow" ? "allowing" : "denying"} ${payload.tool_name} (already vetted by PreToolUse).`);
490
+ writeDecision({
491
+ hookSpecificOutput: {
492
+ hookEventName: "PermissionRequest",
493
+ decision: { behavior },
494
+ },
495
+ });
496
+ }
497
+ // ---------------------------------------------------------------------------
498
+ // PostToolUse / PostToolUseFailure — on_end validation
499
+ //
500
+ // Both events share the same payload shape and validation logic; only the
501
+ // echoed `hookEventName` differs. A blocked result now replaces the tool's
502
+ // recorded output via `updatedToolOutput`, not just feedback alongside it.
503
+ // ---------------------------------------------------------------------------
504
+ async function handlePostToolUse(payload, client, cfg, dbg) {
505
+ const state = loadState(payload.session_id);
506
+ const input = (payload.tool_input ?? {});
507
+ // Both backends require `output` to be an object on on_end (the alignment
508
+ // path 422s on strings; /guardrails/evaluate 400s when it's absent, as on a
509
+ // failed tool with no response). Wrap non-dict outputs as { result } — which
510
+ // also makes string outputs addressable by `output.result` field paths.
511
+ const rawOutput = serializeToolResult(payload.tool_response);
512
+ const output = rawOutput !== null && typeof rawOutput === "object"
513
+ ? rawOutput
514
+ : { result: rawOutput ?? null };
515
+ // 1. Alignment/session on_end validation (session-scoped, optional).
516
+ let alignmentRaw = null;
517
+ let alignmentError = false;
518
+ if (state.sessionId) {
519
+ const res = await client.postValidate({
520
+ session_id: state.sessionId,
521
+ process_name: payload.tool_name,
522
+ process_type: "tool",
523
+ timing: "on_end",
524
+ input,
525
+ output,
526
+ agent_type: AGENT_TYPE,
527
+ });
528
+ dbg(`Validate on_end result: ok=${res.ok}, status=${res.status}`);
529
+ if (res.ok && res.data)
530
+ alignmentRaw = res.data;
531
+ else if (!res.ok)
532
+ alignmentError = true;
533
+ }
534
+ else {
535
+ dbg("No sessionId; skipping alignment on_end, relying on assigned-guardrail evaluate.");
536
+ }
537
+ // 2. Assigned-guardrail evaluation on_end — the path that fires output rules.
538
+ const evalRes = await client.evaluateGuardrails({
539
+ process_name: payload.tool_name,
540
+ process_type: "tool",
541
+ timing: "on_end",
542
+ context: { input, output },
543
+ });
544
+ dbg(`Evaluate on_end result: ok=${evalRes.ok}, status=${evalRes.status}`);
545
+ let evaluateRaw = null;
546
+ let evaluate = null;
547
+ let evaluateError = false;
548
+ if (evalRes.ok && evalRes.data) {
549
+ evaluateRaw = evalRes.data;
550
+ evaluate = normalizeEvaluateResponse(evalRes.data);
551
+ }
552
+ else if (!evalRes.ok) {
553
+ evaluateError = true;
554
+ }
555
+ // Effective monitoring mode for this call (see handlePreToolUse).
556
+ const monitoring = resolveMonitoringForHook(cfg, payload.session_id, state, alignmentRaw, dbg);
557
+ // 3. Fail-closed: suppress the output on any validation error. Only the
558
+ // plugin's own monitorOnly relaxes this, never the platform flag.
559
+ if ((alignmentError || evaluateError) && cfg.failClosed) {
560
+ if (!cfg.monitorOnly) {
561
+ blockPostToolUseWithRewrite(payload.tool_name, "Datagusto validation API error.", "api-error", payload.hook_event_name);
562
+ }
563
+ else {
564
+ dbg(`[MONITOR] Block skipped (plugin monitorOnly, API error): ${payload.tool_name}`);
565
+ }
566
+ return;
567
+ }
568
+ // 4. Merge (block-wins), with the same uncategorized-tool downgrade as
569
+ // on_start (see handlePreToolUse).
570
+ let alignmentProceed = alignmentRaw?.should_proceed !== false;
571
+ const evaluateProceed = evaluate?.should_proceed !== false;
572
+ if (!alignmentProceed &&
573
+ cfg.allowUncategorizedTools &&
574
+ evaluate !== null &&
575
+ evaluateProceed &&
576
+ alignmentRaw !== null &&
577
+ extractPhase(alignmentRaw) === "uncategorized_tool") {
578
+ dbg(`on_end uncategorized-deny downgraded to allow (assigned rules allowed): ${payload.tool_name}`);
579
+ alignmentProceed = true;
580
+ }
581
+ const shouldProceed = alignmentProceed && evaluateProceed;
582
+ const alignmentExt = alignmentRaw;
583
+ const injectedInstruction = alignmentExt?.injected_instruction ?? evaluate?.injected_instruction;
584
+ if (!shouldProceed) {
585
+ const blockerRaw = (evaluate?.should_proceed === false ? evaluateRaw : null) ??
586
+ (alignmentProceed ? null : alignmentRaw);
587
+ const reason = blockerRaw ? extractBlockReason(blockerRaw) : "policy violation";
588
+ const phase = (blockerRaw ? extractPhase(blockerRaw) : undefined) ?? "unknown";
589
+ dbg(`on_end BLOCKED (phase=${phase}): ${payload.tool_name} — ${reason}`);
590
+ if (!monitoring.active) {
591
+ blockPostToolUseWithRewrite(payload.tool_name, reason, phase, payload.hook_event_name);
592
+ }
593
+ else {
594
+ dbg(`[MONITOR] Block skipped (${describeMonitoring(monitoring)}): ${payload.tool_name}`);
595
+ }
596
+ }
597
+ else if (injectedInstruction) {
598
+ const phase = (evaluate?.injected_instruction && evaluateRaw ? extractPhase(evaluateRaw) : undefined) ??
599
+ (alignmentRaw ? extractPhase(alignmentRaw) : undefined) ??
600
+ "post-check";
601
+ dbg(`on_end INJECT (phase=${phase}): ${payload.tool_name} — "${injectedInstruction.substring(0, 100)}"`);
602
+ if (!monitoring.active) {
603
+ injectPostToolUseContext(`[Datagusto safety note — ${phase}]: ${injectedInstruction}`, payload.hook_event_name);
604
+ }
605
+ else {
606
+ dbg(`[MONITOR] Injection skipped (${describeMonitoring(monitoring)}): ${payload.tool_name}`);
607
+ }
608
+ }
609
+ else {
610
+ const meta = alignmentRaw?.metadata;
611
+ if (meta?.bypassed_by_framework_routine) {
612
+ dbg(`on_end BYPASSED (framework_routine): ${payload.tool_name} (matched=${meta.matched_pattern_id ?? "?"})`);
613
+ }
614
+ else {
615
+ dbg(`on_end ALLOWED: ${payload.tool_name}`);
616
+ }
617
+ }
618
+ }
619
+ async function main() {
620
+ const raw = await readStdin();
621
+ const payload = JSON.parse(raw);
622
+ const cfg = loadConfig();
623
+ const dbg = (msg) => {
624
+ if (cfg.verbose)
625
+ process.stderr.write(`[datagusto] ${msg}\n`);
626
+ };
627
+ if (!cfg.serverUrl || !cfg.apiKey) {
628
+ // Missing config never blocks: a user locked out of every tool couldn't
629
+ // use the agent to troubleshoot. Warn instead, and say how to fix it.
630
+ const warning = "Datagusto safety plugin isn't configured (no server URL or API key), so tool calls are NOT being checked. " +
631
+ "Run `dg setup claude-code` to connect it, or `dg remove claude-code` to uninstall it. " +
632
+ "Config is read from claude-code.json, DATAGUSTO_SAFETY_SERVER_URL / DATAGUSTO_SAFETY_API_KEY, or the plugin's userConfig.";
633
+ if (payload.hook_event_name === "SessionStart") {
634
+ // stderr is only shown on a non-zero exit, and exit 1 on SessionStart
635
+ // would look like a crash; systemMessage shows it once per session.
636
+ pendingSystemMessage = warning;
637
+ flushPendingSystemMessage();
638
+ process.exit(0);
639
+ }
640
+ process.stderr.write(`[datagusto] ${warning}\n`);
641
+ process.exit(1); // non-blocking: the tool call proceeds.
642
+ }
643
+ const client = new DatagustoApiClient(cfg.serverUrl, cfg.apiKey, cfg.timeoutMs ?? 30_000);
644
+ dbg(`=== ${payload.hook_event_name} START === session=${payload.session_id}`);
645
+ try {
646
+ switch (payload.hook_event_name) {
647
+ case "SessionStart":
648
+ await handleSessionStart(payload, client, cfg, dbg);
649
+ break;
650
+ case "UserPromptSubmit":
651
+ await handleUserPromptSubmit(payload, client, cfg, dbg);
652
+ break;
653
+ case "PreToolUse":
654
+ await handlePreToolUse(payload, client, cfg, dbg);
655
+ break;
656
+ case "PermissionRequest":
657
+ await handlePermissionRequest(payload, client, cfg, dbg);
658
+ break;
659
+ case "PostToolUse":
660
+ await handlePostToolUse(payload, client, cfg, dbg);
661
+ break;
662
+ case "PostToolUseFailure":
663
+ await handlePostToolUse(payload, client, cfg, dbg);
664
+ break;
665
+ case "SessionEnd":
666
+ await handleSessionEnd(payload, client, cfg, dbg);
667
+ break;
668
+ default:
669
+ dbg(`Unhandled hook_event_name '${payload.hook_event_name}', ignoring.`);
670
+ }
671
+ }
672
+ catch (err) {
673
+ dbg(`EXCEPTION: ${err instanceof Error ? err.stack : err}`);
674
+ // Fail-closed on exceptions: relaxed by the plugin's monitorOnly only.
675
+ if (cfg.failClosed && !cfg.monitorOnly) {
676
+ if (payload.hook_event_name === "PreToolUse") {
677
+ denyPreToolUse("Datagusto safety: validation failed.");
678
+ }
679
+ else if (payload.hook_event_name === "PostToolUse" || payload.hook_event_name === "PostToolUseFailure") {
680
+ const p = payload;
681
+ blockPostToolUseWithRewrite(p.tool_name, "Datagusto safety: validation failed.", "exception", p.hook_event_name);
682
+ }
683
+ }
684
+ }
685
+ // Allow paths write no decision; make sure a queued notice still reaches
686
+ // the user as a standalone `{ systemMessage }` document.
687
+ flushPendingSystemMessage();
688
+ dbg(`=== ${payload.hook_event_name} END ===`);
689
+ process.exit(0);
690
+ }
691
+ main().catch((err) => {
692
+ process.stderr.write(`[datagusto] fatal: ${err instanceof Error ? err.stack : err}\n`);
693
+ process.exit(2);
694
+ });
695
+ //# sourceMappingURL=index.js.map