@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,735 @@
1
+ #!/usr/bin/env node
2
+ // ---------------------------------------------------------------------------
3
+ // Datagusto Safety Plugin - Kiro CLI Hook Entry Point
4
+ // ---------------------------------------------------------------------------
5
+ //
6
+ // Kiro CLI, like Claude Code, spawns this script as a fresh process for each
7
+ // hook event and writes one JSON payload to stdin — same payload shape as
8
+ // Claude Code's (hook_event_name, session_id, tool_name, tool_input,
9
+ // tool_response, prompt, source). The difference is entirely in how the
10
+ // decision is communicated back: Kiro CLI has no stdout-JSON decision
11
+ // channel, only a process exit code plus stderr text:
12
+ //
13
+ // - exit 0 → allow, nothing printed
14
+ // - exit 2 + stderr text → block, stderr holds the human-readable reason
15
+ // - "ask" (severity medium + injected instruction) has no dialog to show in
16
+ // Kiro CLI, so it degrades to a hard block (exit 2) — there is no softer
17
+ // option available.
18
+ // - monitoring mode always logs the would-be verdict via dbg but never emits
19
+ // a block. Two switches turn it on: the plugin's own `monitorOnly` config,
20
+ // and the platform's per-agent `monitoring_mode_enabled` flag returned on
21
+ // alignment / validate responses. The platform flag governs VERDICTS only —
22
+ // it never relaxes the error paths below, since an unvalidated call is not
23
+ // a call the platform judged safe.
24
+ // - fail-closed emits exit 2 on a Datagusto API error, exactly like a
25
+ // policy block, EXCEPT the one deliberate exception: missing
26
+ // serverUrl/apiKey exits 1 (warn-and-allow) so a broken config can never
27
+ // hard-block every tool call in the session.
28
+ //
29
+ // Incoming events are matched against `hook_event_name` on the stdin payload,
30
+ // which Kiro spells differently from the hook file's `trigger` field — see the
31
+ // "Hook event normalization" section below. Anything that still doesn't
32
+ // resolve to a known event is treated as unvalidatable, not as safe.
33
+ //
34
+ // PostToolUse enforcement here is exit-2 + stderr only — Kiro CLI has no
35
+ // confirmed mechanism analogous to Claude Code's `updatedToolOutput` rewrite,
36
+ // so a blocked tool result cannot be suppressed/replaced, only flagged.
37
+ import { readFileSync, writeSync } from "node:fs";
38
+ import { dirname, join } from "node:path";
39
+ import { fileURLToPath } from "node:url";
40
+ import { DatagustoApiClient } from "./api-client.js";
41
+ import { buildToolDefinitions, detectEngine } from "./engine.js";
42
+ import { extractBlockReason, extractPhase, hashPayload, isClaudeCodeCommand, normalizeEvaluateResponse, serializeToolResult, } from "./extractors.js";
43
+ import { describeMonitoring, readMonitoringFlag, resolveMonitoring, } from "./monitoring.js";
44
+ import { deleteState, loadState, mutateState, saveState } from "./state.js";
45
+ /**
46
+ * Middleware self-declaration of the agent framework, sent on every
47
+ * alignment and validation request. Mirrors the Claude Code plugin's
48
+ * `AGENT_TYPE` constant; the Datagusto server uses this to select the
49
+ * matching AgentProfile once a Kiro-specific profile is configured.
50
+ */
51
+ const AGENT_TYPE = "kiro";
52
+ // Env var overrides use the DATAGUSTO_SAFETY_* prefix and take precedence
53
+ // over kiro.json values. This remains the config path for the dev workflow
54
+ // of piping synthetic stdin payloads straight into dist/index.js without a
55
+ // CLI host.
56
+ const ENV_PREFIX = "DATAGUSTO_SAFETY_";
57
+ function envStr(suffix) {
58
+ const v = process.env[ENV_PREFIX + suffix];
59
+ return v && v.length > 0 ? v : undefined;
60
+ }
61
+ function envNum(suffix) {
62
+ const v = process.env[ENV_PREFIX + suffix];
63
+ if (v === undefined || v === "")
64
+ return undefined;
65
+ const n = Number(v);
66
+ return Number.isFinite(n) ? n : undefined;
67
+ }
68
+ function envBool(suffix) {
69
+ const v = process.env[ENV_PREFIX + suffix];
70
+ if (v === undefined)
71
+ return undefined;
72
+ const lower = v.toLowerCase();
73
+ if (["true", "1", "yes"].includes(lower))
74
+ return true;
75
+ if (["false", "0", "no"].includes(lower))
76
+ return false;
77
+ return undefined;
78
+ }
79
+ /**
80
+ * Load plugin config from kiro.json (resolved next to this script's compiled
81
+ * location, or overridden via DATAGUSTO_SAFETY_CONFIG_PATH) with
82
+ * DATAGUSTO_SAFETY_* env vars taking priority over the file. Unlike Claude
83
+ * Code, there is no plugin-host userConfig layer (no CLAUDE_PLUGIN_OPTION_*
84
+ * equivalent) — env vars + file config is the whole story here.
85
+ */
86
+ function loadConfig() {
87
+ const here = dirname(fileURLToPath(import.meta.url));
88
+ const configPath = envStr("CONFIG_PATH") ?? join(here, "..", "kiro.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: envStr("SERVER_URL") ?? fileConfig.serverUrl ?? "",
98
+ apiKey: envStr("API_KEY") ?? fileConfig.apiKey ?? "",
99
+ timeoutMs: envNum("TIMEOUT_MS") ?? fileConfig.timeoutMs ?? 30_000,
100
+ failClosed: envBool("FAIL_CLOSED") ?? fileConfig.failClosed ?? true,
101
+ verbose: envBool("VERBOSE") ?? fileConfig.verbose ?? false,
102
+ monitorOnly: envBool("MONITOR_ONLY") ?? fileConfig.monitorOnly ?? false,
103
+ allowUncategorizedTools: envBool("ALLOW_UNCATEGORIZED_TOOLS") ?? fileConfig.allowUncategorizedTools ?? true,
104
+ };
105
+ }
106
+ async function readStdin() {
107
+ const chunks = [];
108
+ for await (const chunk of process.stdin)
109
+ chunks.push(chunk);
110
+ return Buffer.concat(chunks).toString("utf8");
111
+ }
112
+ // ---------------------------------------------------------------------------
113
+ // Decision adapter — the only piece that differs from Claude Code's index.
114
+ // Kiro CLI has no stdout-JSON decision channel: the whole verdict is exit
115
+ // code + stderr text.
116
+ // ---------------------------------------------------------------------------
117
+ const EXIT_ALLOW = 0;
118
+ const EXIT_BLOCK = 2;
119
+ /**
120
+ * Write to a stdio fd SYNCHRONOUSLY.
121
+ *
122
+ * Every write in this file is followed, sooner or later, by `process.exit()`.
123
+ * That combination is unsafe with `process.stdout`/`process.stderr`: when the
124
+ * host spawns this hook with piped stdio (which is exactly how Kiro captures
125
+ * our output), those streams are ASYNCHRONOUS on POSIX, and `process.exit()`
126
+ * abandons whatever is still buffered. Measured on this codebase: a 70 KB
127
+ * stderr write followed by `process.exit(2)` arrives truncated at the 64 KB
128
+ * pipe buffer, silently losing the tail.
129
+ *
130
+ * That matters because a block reason is assembled from user-authored
131
+ * guardrail messages joined together (see `extractBlockReason`), so its length
132
+ * is not bounded by anything we control. `writeSync` bypasses the stream
133
+ * buffer entirely, so the text is in the pipe before we exit.
134
+ *
135
+ * Handles short writes, a full pipe (EAGAIN), and a reader that has already
136
+ * gone away (EPIPE). Falls back to the stream API rather than throwing: a
137
+ * logging failure must never become an enforcement failure.
138
+ */
139
+ function writeFdSync(fd, text) {
140
+ const buf = Buffer.from(text, "utf8");
141
+ let offset = 0;
142
+ while (offset < buf.length) {
143
+ try {
144
+ offset += writeSync(fd, buf, offset);
145
+ }
146
+ catch (err) {
147
+ const code = err.code;
148
+ if (code === "EAGAIN")
149
+ continue; // pipe momentarily full — retry
150
+ if (code === "EPIPE")
151
+ return; // reader closed — nothing to deliver
152
+ (fd === 2 ? process.stderr : process.stdout).write(buf.subarray(offset));
153
+ return;
154
+ }
155
+ }
156
+ }
157
+ /** Synchronous stderr write. Use instead of `process.stderr.write`. */
158
+ function writeErr(text) {
159
+ writeFdSync(2, text);
160
+ }
161
+ /**
162
+ * Allow. Kiro CLI's contract for this is exit 0 with nothing printed, which
163
+ * is simply `process.exitCode`'s default — this function is a no-op, kept
164
+ * as an explicit call site for symmetry/readability with emitBlock/emitInject.
165
+ */
166
+ function emitAllow() {
167
+ // Nothing to do; process.exitCode defaults to EXIT_ALLOW (0).
168
+ }
169
+ /**
170
+ * Block. Kiro CLI's contract is exit 2 with the human-readable reason on
171
+ * stderr. This is the ONLY exit code that means "block" — never use exit 1
172
+ * (reserved for the deliberate fail-open on missing config in main()) or any
173
+ * other non-zero code here.
174
+ *
175
+ * The reason is written synchronously: it is the agent's only explanation of
176
+ * why the call was denied, and losing it to a buffer flush would leave a
177
+ * correct block that nobody can act on.
178
+ */
179
+ function emitBlock(reason) {
180
+ writeErr(reason + "\n");
181
+ process.exitCode = EXIT_BLOCK;
182
+ }
183
+ /**
184
+ * Best-effort context injection (PostToolUse "inject" case). Kiro CLI has no
185
+ * confirmed additionalContext-equivalent channel, so this writes the note to
186
+ * stdout and leaves the exit code untouched — an injection never blocks.
187
+ */
188
+ function emitInject(note) {
189
+ writeFdSync(1, note + "\n");
190
+ }
191
+ /**
192
+ * User-facing notice that does NOT change the verdict.
193
+ *
194
+ * Claude Code has to smuggle this through the hook output's `systemMessage`
195
+ * field, because it only shows hook stderr on a non-zero exit and a hook may
196
+ * write just one JSON document. Kiro CLI has neither constraint: stderr is a
197
+ * free-form side channel, so the notice is simply written when it happens.
198
+ * Deliberately leaves process.exitCode untouched — a notice must never turn an
199
+ * allow into a block.
200
+ */
201
+ function emitNotice(text) {
202
+ writeErr(text + "\n");
203
+ }
204
+ /**
205
+ * Keyed by the normalized form (lowercased, separators stripped) so that
206
+ * "preToolUse", "PreToolUse", "pre_tool_use" and "pre-tool-use" all resolve to
207
+ * the same canonical event.
208
+ */
209
+ const HOOK_EVENT_ALIASES = {
210
+ sessionstart: "SessionStart",
211
+ agentspawn: "SessionStart", // CLI 2.x name for what 3.0 calls SessionStart
212
+ userpromptsubmit: "UserPromptSubmit",
213
+ pretooluse: "PreToolUse",
214
+ posttooluse: "PostToolUse",
215
+ stop: "Stop",
216
+ };
217
+ function normalizeHookEvent(value) {
218
+ if (typeof value !== "string")
219
+ return null;
220
+ const key = value.trim().toLowerCase().replace(/[\s_-]/g, "");
221
+ return HOOK_EVENT_ALIASES[key] ?? null;
222
+ }
223
+ /**
224
+ * True when the payload carries tool context. This is what decides how an
225
+ * unrecognized event name is handled: an unknown TOOL event is a call we were
226
+ * invoked to vet and could not, so it must fail closed; an unknown lifecycle
227
+ * event gates no tool call and is safe to pass through.
228
+ */
229
+ function hasToolContext(payload) {
230
+ return typeof payload.tool_name === "string" && payload.tool_name.length > 0;
231
+ }
232
+ /**
233
+ * Terminal handler for an event name the alias table doesn't know.
234
+ *
235
+ * This is the branch that used to be a bare `emitAllow()`. Passing an
236
+ * unrecognized tool event through is indistinguishable from having no
237
+ * guardrail at all, so when the payload carries a tool call we block instead
238
+ * — loudly, naming the event, so the alias table can be extended rather than
239
+ * the failure being absorbed silently. Honors failClosed/monitorOnly like
240
+ * every other enforcement path.
241
+ */
242
+ function handleUnrecognizedEvent(rawEventName, payload, cfg, dbg) {
243
+ if (!hasToolContext(payload)) {
244
+ dbg(`Unrecognized non-tool hook event '${rawEventName}'; no tool call to gate, allowing.`);
245
+ emitAllow();
246
+ return;
247
+ }
248
+ const toolName = String(payload.tool_name);
249
+ if (cfg.failClosed && !cfg.monitorOnly) {
250
+ emitBlock(`Datagusto safety: unrecognized hook event '${rawEventName}' carrying tool '${toolName}'. ` +
251
+ "The call could not be validated, so it was blocked. " +
252
+ "Add this event to the plugin's alias table if it is legitimate.");
253
+ }
254
+ else {
255
+ dbg(`[MONITOR] Unrecognized tool event '${rawEventName}' for '${toolName}' not blocked ` +
256
+ `(failClosed=${cfg.failClosed}, monitorOnly=${cfg.monitorOnly}).`);
257
+ }
258
+ }
259
+ // ---------------------------------------------------------------------------
260
+ // Monitoring mode
261
+ //
262
+ // Two independent switches turn enforcement off: the plugin's own
263
+ // `monitorOnly` config, and the platform's per-agent monitoring mode
264
+ // (`monitoring_mode_enabled`, returned on alignment / validate responses).
265
+ // Either being true means a verdict is logged but not enforced.
266
+ //
267
+ // The distinction that matters for a fail-closed plugin: the platform flag
268
+ // governs VERDICTS ONLY. It never relaxes the error paths (API failure,
269
+ // exception, empty stdin, unrecognized tool event), because those carry no
270
+ // verdict to begin with — an unvalidated call is not a call the platform
271
+ // judged safe. Only the operator's own `monitorOnly` relaxes those.
272
+ // ---------------------------------------------------------------------------
273
+ const PLATFORM_MONITORING_NOTICE = "[datagusto] Platform monitoring mode is enabled for this agent: " +
274
+ "guardrail verdicts are recorded but not enforced.";
275
+ /**
276
+ * Resolve whether verdicts are enforced for this hook invocation.
277
+ *
278
+ * The platform value comes from the current response when it carries the flag,
279
+ * else from the value most recently cached for this session, else false (older
280
+ * servers omit it and keep their previous behaviour). Bookkeeping is written
281
+ * through mutateState() against a freshly re-read state rather than the
282
+ * caller's snapshot, because Kiro can run hook processes for one session
283
+ * concurrently and each handler awaits HTTP round-trips before saving.
284
+ *
285
+ * The first time the platform flag is seen true in a session, a notice is
286
+ * written to stderr regardless of `verbose` — the operator should know
287
+ * enforcement is off. Best-effort: whether Kiro surfaces stderr on a zero exit
288
+ * is unconfirmed, so this is a diagnostic, not a guarantee.
289
+ */
290
+ function resolveMonitoringForHook(cfg, sessionId, state, response, dbg) {
291
+ const flag = readMonitoringFlag(response);
292
+ let showNotice = false;
293
+ const fresh = mutateState(sessionId, (s) => {
294
+ if (flag !== undefined)
295
+ s.monitoringModeEnabled = flag;
296
+ const platformEnabled = typeof flag === "boolean" ? flag : s.monitoringModeEnabled === true;
297
+ if (platformEnabled && !s.monitoringNoticeShown) {
298
+ s.monitoringNoticeShown = true;
299
+ showNotice = true;
300
+ }
301
+ });
302
+ state.monitoringModeEnabled = fresh.monitoringModeEnabled;
303
+ state.monitoringNoticeShown = fresh.monitoringNoticeShown;
304
+ const decision = resolveMonitoring(cfg.monitorOnly, flag, fresh.monitoringModeEnabled);
305
+ if (showNotice)
306
+ emitNotice(PLATFORM_MONITORING_NOTICE);
307
+ if (decision.active)
308
+ dbg(`Monitoring mode active (${describeMonitoring(decision)}).`);
309
+ return decision;
310
+ }
311
+ // ---------------------------------------------------------------------------
312
+ // SessionStart — eager tool registration
313
+ //
314
+ // Decouples tool-catalog registration from the first UserPromptSubmit so a
315
+ // session that never sends a real instruction still has its tools
316
+ // registered. SessionStart has no blocking semantics — this handler never
317
+ // needs to emit a decision.
318
+ // ---------------------------------------------------------------------------
319
+ async function handleSessionStart(payload, client, cfg, dbg) {
320
+ const state = loadState(payload.session_id);
321
+ // The catalog is the running engine's, so its hash differs per engine.
322
+ const { engine } = detectEngine(payload);
323
+ const tools = buildToolDefinitions(engine);
324
+ const toolsHash = hashPayload({ tools });
325
+ if (toolsHash !== state.registeredToolsHash) {
326
+ dbg(`SessionStart (source=${payload.source}): registering ${tools.length} built-in tools (engine ${engine})...`);
327
+ const res = await client.registerTools(tools);
328
+ dbg(`Register result: ok=${res.ok}, status=${res.status}`);
329
+ if (res.ok)
330
+ state.registeredToolsHash = toolsHash;
331
+ }
332
+ else {
333
+ dbg(`SessionStart (source=${payload.source}): tools hash unchanged, skipping registration.`);
334
+ }
335
+ saveState(payload.session_id, state);
336
+ }
337
+ // ---------------------------------------------------------------------------
338
+ // Stop — state cleanup (Kiro CLI's equivalent of Claude Code's SessionEnd)
339
+ // ---------------------------------------------------------------------------
340
+ async function handleStop(payload, client, cfg, dbg) {
341
+ dbg(`Stop: deleting state for session ${payload.session_id}`);
342
+ deleteState(payload.session_id);
343
+ }
344
+ // ---------------------------------------------------------------------------
345
+ // UserPromptSubmit — tool registration + alignment
346
+ // ---------------------------------------------------------------------------
347
+ async function handleUserPromptSubmit(payload, client, cfg, dbg) {
348
+ const state = loadState(payload.session_id);
349
+ const { engine } = detectEngine(payload);
350
+ const tools = buildToolDefinitions(engine);
351
+ const toolsHash = hashPayload({ tools });
352
+ if (toolsHash !== state.registeredToolsHash) {
353
+ dbg(`Registering ${tools.length} built-in tools (engine ${engine})...`);
354
+ const res = await client.registerTools(tools);
355
+ dbg(`Register result: ok=${res.ok}, status=${res.status}`);
356
+ if (res.ok)
357
+ state.registeredToolsHash = toolsHash;
358
+ }
359
+ else {
360
+ dbg("Tools hash unchanged, skipping registration.");
361
+ }
362
+ if (!isClaudeCodeCommand(payload.prompt)) {
363
+ const instrHash = hashPayload(payload.prompt);
364
+ if (instrHash !== state.lastUserInstructionHash) {
365
+ dbg("Sending alignment...");
366
+ const res = await client.postAlignment(payload.prompt, state.sessionId, AGENT_TYPE);
367
+ dbg(`Alignment result: ok=${res.ok}, status=${res.status}`);
368
+ if (res.ok && res.data) {
369
+ const data = res.data;
370
+ if (typeof data.session_id === "string" && data.session_id) {
371
+ state.sessionId = data.session_id;
372
+ }
373
+ state.lastUserInstructionHash = instrHash;
374
+ // Cache the platform monitoring flag for later hooks: /guardrails/evaluate
375
+ // never carries it, and a PreToolUse whose validate call is skipped
376
+ // (no session yet) would otherwise have nothing to read.
377
+ resolveMonitoringForHook(cfg, payload.session_id, state, data, dbg);
378
+ }
379
+ }
380
+ else {
381
+ dbg("Instruction hash unchanged, skipping alignment.");
382
+ }
383
+ }
384
+ else {
385
+ dbg("Prompt is a slash command / bash-mode shortcut, skipping alignment.");
386
+ }
387
+ saveState(payload.session_id, state);
388
+ }
389
+ // ---------------------------------------------------------------------------
390
+ // PreToolUse — on_start validation
391
+ // ---------------------------------------------------------------------------
392
+ async function handlePreToolUse(payload, client, cfg, dbg) {
393
+ const state = loadState(payload.session_id);
394
+ const input = (payload.tool_input ?? {});
395
+ // 1. Alignment/session validation (intent-judge). Session-scoped: only runs
396
+ // once alignment has produced a session_id. Absence isn't fatal on its own
397
+ // — the assigned-guardrail evaluate below is agent-scoped and still runs.
398
+ let alignmentRaw = null;
399
+ let alignmentError = false;
400
+ if (state.sessionId) {
401
+ const res = await client.postValidate({
402
+ session_id: state.sessionId,
403
+ process_name: payload.tool_name,
404
+ process_type: "tool",
405
+ timing: "on_start",
406
+ input,
407
+ agent_type: AGENT_TYPE,
408
+ });
409
+ dbg(`Validate on_start result: ok=${res.ok}, status=${res.status}`);
410
+ if (res.ok && res.data)
411
+ alignmentRaw = res.data;
412
+ else if (!res.ok)
413
+ alignmentError = true;
414
+ }
415
+ else {
416
+ dbg("No sessionId; skipping alignment validate, relying on assigned-guardrail evaluate.");
417
+ }
418
+ // 2. Assigned (dashboard-authored) guardrail evaluation — the only path that
419
+ // fires the agent's assigned rules. Agent-scoped via API key, no session.
420
+ const evalRes = await client.evaluateGuardrails({
421
+ process_name: payload.tool_name,
422
+ process_type: "tool",
423
+ timing: "on_start",
424
+ context: { input },
425
+ });
426
+ dbg(`Evaluate on_start result: ok=${evalRes.ok}, status=${evalRes.status}`);
427
+ let evaluateRaw = null;
428
+ let evaluate = null;
429
+ let evaluateError = false;
430
+ if (evalRes.ok && evalRes.data) {
431
+ evaluateRaw = evalRes.data;
432
+ evaluate = normalizeEvaluateResponse(evalRes.data);
433
+ }
434
+ else if (!evalRes.ok) {
435
+ evaluateError = true;
436
+ }
437
+ // Effective monitoring for this call. /guardrails/evaluate carries no flag,
438
+ // so the validate response (or the cached alignment value) decides.
439
+ const monitoring = resolveMonitoringForHook(cfg, payload.session_id, state, alignmentRaw, dbg);
440
+ // 3. Fail-closed: if either layer errored and we can't be sure, block.
441
+ // Only the operator's own monitorOnly relaxes this — the platform flag
442
+ // governs verdicts, and an errored request produced no verdict.
443
+ if ((alignmentError || evaluateError) && cfg.failClosed) {
444
+ if (!cfg.monitorOnly) {
445
+ emitBlock("Datagusto safety: validation API error.");
446
+ }
447
+ else {
448
+ dbg(`[MONITOR] Deny skipped (plugin monitorOnly, API error): ${payload.tool_name}`);
449
+ }
450
+ return;
451
+ }
452
+ // 4. Merge (block-wins). A false from either layer blocks — except the
453
+ // alignment layer's "uncategorized_tool" default-deny, which (when
454
+ // allowUncategorizedTools is on) is downgraded to allow if the
455
+ // assigned-guardrail evaluation explicitly allowed the call. Real intent
456
+ // violations (phase v2:<Tool>:N) are never downgraded.
457
+ let alignmentProceed = alignmentRaw?.should_proceed !== false;
458
+ const evaluateProceed = evaluate?.should_proceed !== false;
459
+ if (!alignmentProceed &&
460
+ cfg.allowUncategorizedTools &&
461
+ evaluate !== null &&
462
+ evaluateProceed &&
463
+ alignmentRaw !== null &&
464
+ extractPhase(alignmentRaw) === "uncategorized_tool") {
465
+ dbg(`on_start uncategorized-deny downgraded to allow (assigned rules allowed): ${payload.tool_name}`);
466
+ alignmentProceed = true;
467
+ }
468
+ const shouldProceed = alignmentProceed && evaluateProceed;
469
+ // Prefer alignment's severity/instruction, then the assigned-guardrail's.
470
+ const alignmentExt = alignmentRaw;
471
+ const severity = alignmentExt?.severity ?? evaluate?.severity;
472
+ const injectedInstruction = alignmentExt?.injected_instruction ?? evaluate?.injected_instruction;
473
+ // Cache the merged verdict for state-shape parity with Claude Code (Kiro
474
+ // has no PermissionRequest event to consume it, but state.ts is shared and
475
+ // this keeps the persisted shape consistent).
476
+ state.lastPreToolUseCheck = {
477
+ inputHash: hashPayload({ tool_name: payload.tool_name, tool_input: payload.tool_input }),
478
+ shouldProceed,
479
+ severity,
480
+ };
481
+ saveState(payload.session_id, state);
482
+ if (!shouldProceed) {
483
+ // Attribute the block to whichever layer denied (assigned rule first — its
484
+ // message is user-authored and more specific — then alignment).
485
+ const blockerRaw = (evaluate?.should_proceed === false ? evaluateRaw : null) ??
486
+ (alignmentProceed ? null : alignmentRaw);
487
+ const reason = blockerRaw ? extractBlockReason(blockerRaw) : "policy violation";
488
+ const phase = (blockerRaw ? extractPhase(blockerRaw) : undefined) ?? "unknown";
489
+ dbg(`on_start BLOCKED (phase=${phase}): ${payload.tool_name} — ${reason}`);
490
+ if (!monitoring.active) {
491
+ emitBlock(`Guardrail blocked tool '${payload.tool_name}' [phase: ${phase}]. ${reason}`);
492
+ }
493
+ else {
494
+ dbg(`[MONITOR] Deny skipped (${describeMonitoring(monitoring)}): ${payload.tool_name}`);
495
+ }
496
+ }
497
+ else if (severity === "medium" && injectedInstruction) {
498
+ // LOCKED DECISION: Kiro CLI has no approval-dialog mechanism, so an
499
+ // "ask" verdict degrades to a hard block — there is no softer option.
500
+ dbg(`on_start ASK->BLOCK (no dialog in Kiro CLI): ${payload.tool_name}`);
501
+ if (!monitoring.active) {
502
+ emitBlock(injectedInstruction);
503
+ }
504
+ else {
505
+ dbg(`[MONITOR] Ask/Block skipped (${describeMonitoring(monitoring)}): ${payload.tool_name}`);
506
+ }
507
+ }
508
+ else {
509
+ const meta = alignmentRaw?.metadata;
510
+ if (meta?.bypassed_by_framework_routine) {
511
+ dbg(`on_start BYPASSED (framework_routine): ${payload.tool_name} (matched=${meta.matched_pattern_id ?? "?"})`);
512
+ }
513
+ else {
514
+ dbg(`on_start ALLOWED: ${payload.tool_name}`);
515
+ }
516
+ emitAllow();
517
+ }
518
+ }
519
+ // ---------------------------------------------------------------------------
520
+ // PostToolUse — on_end validation
521
+ //
522
+ // Kiro CLI enforcement here is exit-2 + stderr only: there is no confirmed
523
+ // mechanism analogous to Claude Code's `updatedToolOutput` rewrite, so a
524
+ // blocked tool result can be flagged but not suppressed/replaced.
525
+ // ---------------------------------------------------------------------------
526
+ async function handlePostToolUse(payload, client, cfg, dbg) {
527
+ const state = loadState(payload.session_id);
528
+ const input = (payload.tool_input ?? {});
529
+ // Both backends require `output` to be an object on on_end (the alignment
530
+ // path 422s on strings; /guardrails/evaluate 400s when it's absent, as on a
531
+ // failed tool with no response). Wrap non-dict outputs as { result } — which
532
+ // also makes string outputs addressable by `output.result` field paths.
533
+ const rawOutput = serializeToolResult(payload.tool_response);
534
+ const output = rawOutput !== null && typeof rawOutput === "object"
535
+ ? rawOutput
536
+ : { result: rawOutput ?? null };
537
+ // 1. Alignment/session on_end validation (session-scoped, optional).
538
+ let alignmentRaw = null;
539
+ let alignmentError = false;
540
+ if (state.sessionId) {
541
+ const res = await client.postValidate({
542
+ session_id: state.sessionId,
543
+ process_name: payload.tool_name,
544
+ process_type: "tool",
545
+ timing: "on_end",
546
+ input,
547
+ output,
548
+ agent_type: AGENT_TYPE,
549
+ });
550
+ dbg(`Validate on_end result: ok=${res.ok}, status=${res.status}`);
551
+ if (res.ok && res.data)
552
+ alignmentRaw = res.data;
553
+ else if (!res.ok)
554
+ alignmentError = true;
555
+ }
556
+ else {
557
+ dbg("No sessionId; skipping alignment on_end, relying on assigned-guardrail evaluate.");
558
+ }
559
+ // 2. Assigned-guardrail evaluation on_end — the path that fires output rules.
560
+ const evalRes = await client.evaluateGuardrails({
561
+ process_name: payload.tool_name,
562
+ process_type: "tool",
563
+ timing: "on_end",
564
+ context: { input, output },
565
+ });
566
+ dbg(`Evaluate on_end result: ok=${evalRes.ok}, status=${evalRes.status}`);
567
+ let evaluateRaw = null;
568
+ let evaluate = null;
569
+ let evaluateError = false;
570
+ if (evalRes.ok && evalRes.data) {
571
+ evaluateRaw = evalRes.data;
572
+ evaluate = normalizeEvaluateResponse(evalRes.data);
573
+ }
574
+ else if (!evalRes.ok) {
575
+ evaluateError = true;
576
+ }
577
+ // Effective monitoring for this call (see handlePreToolUse).
578
+ const monitoring = resolveMonitoringForHook(cfg, payload.session_id, state, alignmentRaw, dbg);
579
+ // 3. Fail-closed: block on any validation error. As on_start, only the
580
+ // plugin's own monitorOnly relaxes this, never the platform flag.
581
+ if ((alignmentError || evaluateError) && cfg.failClosed) {
582
+ if (!cfg.monitorOnly) {
583
+ emitBlock("Datagusto validation API error.");
584
+ }
585
+ else {
586
+ dbg(`[MONITOR] Block skipped (plugin monitorOnly, API error): ${payload.tool_name}`);
587
+ }
588
+ return;
589
+ }
590
+ // 4. Merge (block-wins), with the same uncategorized-tool downgrade as
591
+ // on_start (see handlePreToolUse).
592
+ let alignmentProceed = alignmentRaw?.should_proceed !== false;
593
+ const evaluateProceed = evaluate?.should_proceed !== false;
594
+ if (!alignmentProceed &&
595
+ cfg.allowUncategorizedTools &&
596
+ evaluate !== null &&
597
+ evaluateProceed &&
598
+ alignmentRaw !== null &&
599
+ extractPhase(alignmentRaw) === "uncategorized_tool") {
600
+ dbg(`on_end uncategorized-deny downgraded to allow (assigned rules allowed): ${payload.tool_name}`);
601
+ alignmentProceed = true;
602
+ }
603
+ const shouldProceed = alignmentProceed && evaluateProceed;
604
+ const alignmentExt = alignmentRaw;
605
+ const injectedInstruction = alignmentExt?.injected_instruction ?? evaluate?.injected_instruction;
606
+ if (!shouldProceed) {
607
+ const blockerRaw = (evaluate?.should_proceed === false ? evaluateRaw : null) ??
608
+ (alignmentProceed ? null : alignmentRaw);
609
+ const reason = blockerRaw ? extractBlockReason(blockerRaw) : "policy violation";
610
+ const phase = (blockerRaw ? extractPhase(blockerRaw) : undefined) ?? "unknown";
611
+ dbg(`on_end BLOCKED (phase=${phase}): ${payload.tool_name} — ${reason}`);
612
+ if (!monitoring.active) {
613
+ emitBlock(`Guardrail flagged tool '${payload.tool_name}' output [phase: ${phase}]. ${reason}`);
614
+ }
615
+ else {
616
+ dbg(`[MONITOR] Block skipped (${describeMonitoring(monitoring)}): ${payload.tool_name}`);
617
+ }
618
+ }
619
+ else if (injectedInstruction) {
620
+ const phase = (evaluate?.injected_instruction && evaluateRaw ? extractPhase(evaluateRaw) : undefined) ??
621
+ (alignmentRaw ? extractPhase(alignmentRaw) : undefined) ??
622
+ "post-check";
623
+ dbg(`on_end INJECT (phase=${phase}): ${payload.tool_name} — "${injectedInstruction.substring(0, 100)}"`);
624
+ if (!monitoring.active) {
625
+ emitInject(`[Datagusto safety note — ${phase}]: ${injectedInstruction}`);
626
+ }
627
+ else {
628
+ dbg(`[MONITOR] Injection skipped (${describeMonitoring(monitoring)}): ${payload.tool_name}`);
629
+ }
630
+ }
631
+ else {
632
+ const meta = alignmentRaw?.metadata;
633
+ if (meta?.bypassed_by_framework_routine) {
634
+ dbg(`on_end BYPASSED (framework_routine): ${payload.tool_name} (matched=${meta.matched_pattern_id ?? "?"})`);
635
+ }
636
+ else {
637
+ dbg(`on_end ALLOWED: ${payload.tool_name}`);
638
+ }
639
+ emitAllow();
640
+ }
641
+ }
642
+ async function main() {
643
+ const raw = await readStdin();
644
+ const cfg = loadConfig();
645
+ const dbg = (msg) => {
646
+ if (cfg.verbose)
647
+ writeErr(`[datagusto] ${msg}\n`);
648
+ };
649
+ // Checked BEFORE the empty-stdin guard below: a misconfigured plugin must
650
+ // take the documented fail-open path rather than the fail-closed one.
651
+ if (!cfg.serverUrl || !cfg.apiKey) {
652
+ writeErr("[datagusto] serverUrl and apiKey are required " +
653
+ "(via kiro.json or DATAGUSTO_SAFETY_SERVER_URL / DATAGUSTO_SAFETY_API_KEY). " +
654
+ "Plugin disabled for this invocation.\n");
655
+ // DELIBERATE non-blocking fail-open on misconfiguration: exit 1 warns and
656
+ // allows, per the Kiro CLI contract (exit 1 is not the block code — that's
657
+ // exit 2). A broken config must never hard-block every tool call.
658
+ process.exit(1);
659
+ }
660
+ // Empty stdin means we have zero context: we cannot tell whether this
661
+ // invocation is gating a tool call or not. Previously this allowed (exit 0),
662
+ // which turns any surface that doesn't deliver a stdin payload into a silent
663
+ // bypass of the guardrail. Treat it as unvalidatable and fail closed; set
664
+ // monitorOnly or failClosed=false to opt out.
665
+ if (raw.trim() === "") {
666
+ if (cfg.failClosed && !cfg.monitorOnly) {
667
+ emitBlock("Datagusto safety: hook invoked with empty stdin, so no tool call could be validated. " +
668
+ "This surface may not deliver hook payloads on stdin.");
669
+ }
670
+ else {
671
+ writeErr("[datagusto] empty stdin; nothing to validate.\n");
672
+ }
673
+ process.exit(process.exitCode ?? EXIT_ALLOW);
674
+ }
675
+ // Malformed JSON throws here and is caught by the fatal handler, which exits
676
+ // 2 (fail-closed) — the correct outcome for a payload we cannot read.
677
+ const payload = JSON.parse(raw);
678
+ const rawEventName = typeof payload.hook_event_name === "string" && payload.hook_event_name.length > 0
679
+ ? payload.hook_event_name
680
+ : "<missing>";
681
+ const event = normalizeHookEvent(payload.hook_event_name);
682
+ const client = new DatagustoApiClient(cfg.serverUrl, cfg.apiKey, cfg.timeoutMs ?? 30_000);
683
+ dbg(`=== ${rawEventName} (canonical: ${event ?? "UNRECOGNIZED"}) START === ` +
684
+ `session=${String(payload.session_id ?? "<none>")}`);
685
+ const detected = detectEngine(payload);
686
+ dbg(detected.recognized
687
+ ? `Kiro engine: ${detected.engine}`
688
+ : `Kiro engine: not recognizable from this payload, assuming ${detected.engine} (the default engine)`);
689
+ try {
690
+ switch (event) {
691
+ case "SessionStart":
692
+ await handleSessionStart(payload, client, cfg, dbg);
693
+ break;
694
+ case "UserPromptSubmit":
695
+ await handleUserPromptSubmit(payload, client, cfg, dbg);
696
+ break;
697
+ case "PreToolUse":
698
+ await handlePreToolUse(payload, client, cfg, dbg);
699
+ break;
700
+ case "PostToolUse":
701
+ await handlePostToolUse(payload, client, cfg, dbg);
702
+ break;
703
+ case "Stop":
704
+ await handleStop(payload, client, cfg, dbg);
705
+ break;
706
+ default:
707
+ handleUnrecognizedEvent(rawEventName, payload, cfg, dbg);
708
+ }
709
+ }
710
+ catch (err) {
711
+ dbg(`EXCEPTION: ${err instanceof Error ? err.stack : err}`);
712
+ if (cfg.failClosed && !cfg.monitorOnly) {
713
+ // hasToolContext covers the case where the event name never resolved:
714
+ // an exception while handling a payload that carries a tool call still
715
+ // has to fail closed, not fall through to allow.
716
+ if (event === "PreToolUse" || event === "PostToolUse" || hasToolContext(payload)) {
717
+ emitBlock("Datagusto safety: validation failed.");
718
+ }
719
+ }
720
+ }
721
+ dbg(`=== ${rawEventName} END ===`);
722
+ // Do not force exit(0) unconditionally: process.exitCode already carries the
723
+ // real decision (emitBlock sets it to EXIT_BLOCK), so exiting with that value
724
+ // preserves the verdict. Note this does NOT flush the stdio streams — that is
725
+ // why every message this process emits goes through writeFdSync, which has
726
+ // already reached the pipe by the time we get here.
727
+ process.exit(process.exitCode ?? EXIT_ALLOW);
728
+ }
729
+ main().catch((err) => {
730
+ // Stack traces are the longest thing this process writes, so the synchronous
731
+ // path matters most here.
732
+ writeErr(`[datagusto] fatal: ${err instanceof Error ? err.stack : err}\n`);
733
+ process.exit(2); // fatal = fail-closed
734
+ });
735
+ //# sourceMappingURL=index.js.map