@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.
- package/LICENSE +21 -0
- package/README.md +186 -2
- package/bin/run.js +5 -0
- package/dist/adapters/claude-code.d.ts +47 -0
- package/dist/adapters/claude-code.js +533 -0
- package/dist/adapters/claude-code.js.map +1 -0
- package/dist/adapters/claude-settings.d.ts +29 -0
- package/dist/adapters/claude-settings.js +115 -0
- package/dist/adapters/claude-settings.js.map +1 -0
- package/dist/adapters/index.d.ts +10 -0
- package/dist/adapters/index.js +37 -0
- package/dist/adapters/index.js.map +1 -0
- package/dist/adapters/kiro-agent.d.ts +29 -0
- package/dist/adapters/kiro-agent.js +124 -0
- package/dist/adapters/kiro-agent.js.map +1 -0
- package/dist/adapters/kiro.d.ts +24 -0
- package/dist/adapters/kiro.js +740 -0
- package/dist/adapters/kiro.js.map +1 -0
- package/dist/adapters/types.d.ts +79 -0
- package/dist/adapters/types.js +14 -0
- package/dist/adapters/types.js.map +1 -0
- package/dist/api/client.d.ts +36 -0
- package/dist/api/client.js +85 -0
- package/dist/api/client.js.map +1 -0
- package/dist/api/errors.d.ts +4 -0
- package/dist/api/errors.js +25 -0
- package/dist/api/errors.js.map +1 -0
- package/dist/commands/remove.d.ts +13 -0
- package/dist/commands/remove.js +59 -0
- package/dist/commands/remove.js.map +1 -0
- package/dist/commands/setup.d.ts +16 -0
- package/dist/commands/setup.js +114 -0
- package/dist/commands/setup.js.map +1 -0
- package/dist/commands/status.d.ts +7 -0
- package/dist/commands/status.js +53 -0
- package/dist/commands/status.js.map +1 -0
- package/dist/display.d.ts +2 -0
- package/dist/display.js +8 -0
- package/dist/display.js.map +1 -0
- package/dist/fs/atomic.d.ts +17 -0
- package/dist/fs/atomic.js +41 -0
- package/dist/fs/atomic.js.map +1 -0
- package/dist/key-input.d.ts +24 -0
- package/dist/key-input.js +96 -0
- package/dist/key-input.js.map +1 -0
- package/dist/state/installs.d.ts +24 -0
- package/dist/state/installs.js +110 -0
- package/dist/state/installs.js.map +1 -0
- package/dist/vendor.d.ts +16 -0
- package/dist/vendor.js +60 -0
- package/dist/vendor.js.map +1 -0
- package/package.json +57 -4
- package/vendor/claude_code/.claude-plugin/plugin.json +55 -0
- package/vendor/claude_code/README.md +267 -0
- package/vendor/claude_code/dist/api-client.js +82 -0
- package/vendor/claude_code/dist/builtin-tools.generated.js +33 -0
- package/vendor/claude_code/dist/extractors.js +201 -0
- package/vendor/claude_code/dist/index.js +695 -0
- package/vendor/claude_code/dist/monitoring.js +66 -0
- package/vendor/claude_code/dist/state.js +71 -0
- package/vendor/claude_code/dist/types.js +5 -0
- package/vendor/claude_code/hooks/hooks.json +25 -0
- package/vendor/claude_code/package.json +26 -0
- package/vendor/kiro/.kiro/agents/kiro_default.json +20 -0
- package/vendor/kiro/.kiro/hooks/datagusto-post-tool-use.json +10 -0
- package/vendor/kiro/.kiro/hooks/datagusto-pre-tool-use.json +10 -0
- package/vendor/kiro/.kiro/hooks/datagusto-session-start.json +10 -0
- package/vendor/kiro/.kiro/hooks/datagusto-stop.json +10 -0
- package/vendor/kiro/.kiro/hooks/datagusto-user-prompt-submit.json +10 -0
- package/vendor/kiro/dist/api-client.js +82 -0
- package/vendor/kiro/dist/builtin-tools.generated.js +87 -0
- package/vendor/kiro/dist/engine.js +65 -0
- package/vendor/kiro/dist/extractors.js +201 -0
- package/vendor/kiro/dist/index.js +735 -0
- package/vendor/kiro/dist/monitoring.js +66 -0
- package/vendor/kiro/dist/state.js +71 -0
- package/vendor/kiro/dist/types.js +5 -0
- package/vendor/kiro/package.json +25 -0
- 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
|