@shidesheng0218/agentguard 0.8.0 → 0.9.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.
@@ -0,0 +1,1450 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/paths.ts
4
+ import fs from "fs";
5
+ import os from "os";
6
+ import path from "path";
7
+ function guardHome() {
8
+ const env = process.env.AGENT_GUARD_HOME ?? process.env.KIMI_GUARD_HOME;
9
+ if (env && env.trim()) return path.resolve(env);
10
+ const fresh = path.join(os.homedir(), ".agent-guard");
11
+ const legacy = path.join(os.homedir(), ".kimi-guard");
12
+ if (!fs.existsSync(fresh) && fs.existsSync(legacy)) return legacy;
13
+ return fresh;
14
+ }
15
+ function claudeSettingsPath() {
16
+ const env = process.env.CLAUDE_SETTINGS_PATH;
17
+ if (env && env.trim()) return path.resolve(env);
18
+ return path.join(os.homedir(), ".claude", "settings.json");
19
+ }
20
+ function claudeDetected() {
21
+ return fs.existsSync(claudeSettingsPath()) || fs.existsSync(path.join(os.homedir(), ".claude"));
22
+ }
23
+ function codexHooksPath() {
24
+ const env = process.env.CODEX_HOOKS_PATH;
25
+ if (env && env.trim()) return path.resolve(env);
26
+ return path.join(os.homedir(), ".codex", "hooks.json");
27
+ }
28
+ function codexDetected() {
29
+ return fs.existsSync(path.join(os.homedir(), ".codex"));
30
+ }
31
+ function stateDbPath() {
32
+ return path.join(guardHome(), "state.db");
33
+ }
34
+ function probeLogPath() {
35
+ return path.join(guardHome(), "probe.jsonl");
36
+ }
37
+ function userConfigPath() {
38
+ return path.join(guardHome(), "config.toml");
39
+ }
40
+ function detectKimiConfig() {
41
+ const env = process.env.KIMI_CONFIG_PATH;
42
+ if (env && env.trim()) {
43
+ const p = path.resolve(env);
44
+ return { path: p, exists: fs.existsSync(p) };
45
+ }
46
+ const candidates = [
47
+ path.join(os.homedir(), ".kimi-code", "config.toml"),
48
+ path.join(os.homedir(), ".kimi", "config.toml")
49
+ ];
50
+ for (const p of candidates) {
51
+ if (fs.existsSync(p)) return { path: p, exists: true };
52
+ }
53
+ return { path: candidates[0], exists: false };
54
+ }
55
+
56
+ // src/config.ts
57
+ import fs2 from "fs";
58
+ import { parse as parseToml } from "smol-toml";
59
+
60
+ // src/toolsets.ts
61
+ var DEFAULT_EDIT_TOOLS = ["WriteFile", "StrReplaceFile", "Edit", "Write", "MultiEdit", "NotebookEdit"];
62
+ var DEFAULT_READ_TOOLS = ["ReadFile", "Read"];
63
+ var DEFAULT_SEARCH_TOOLS = ["Grep", "Glob"];
64
+ var DEFAULT_SHELL_TOOLS = ["Shell", "Bash"];
65
+ var CLAUDE_EDIT_TOOLS = ["Write", "Edit", "MultiEdit", "NotebookEdit"];
66
+ var CLAUDE_READ_TOOLS = ["Read"];
67
+ var CLAUDE_SEARCH_TOOLS = ["Grep", "Glob"];
68
+ var CLAUDE_SHELL_TOOLS = ["Bash"];
69
+ var CODEX_EDIT_TOOLS = ["apply_patch", "Edit", "Write"];
70
+ var CODEX_READ_TOOLS = [];
71
+ var CODEX_SEARCH_TOOLS = [];
72
+ var CODEX_SHELL_TOOLS = ["Bash"];
73
+ function toolDefaultsFor(harness) {
74
+ if (harness === "claude")
75
+ return { edit: [...CLAUDE_EDIT_TOOLS], read: [...CLAUDE_READ_TOOLS], search: [...CLAUDE_SEARCH_TOOLS], shell: [...CLAUDE_SHELL_TOOLS] };
76
+ if (harness === "codex")
77
+ return { edit: [...CODEX_EDIT_TOOLS], read: [...CODEX_READ_TOOLS], search: [...CODEX_SEARCH_TOOLS], shell: [...CODEX_SHELL_TOOLS] };
78
+ return { edit: [...DEFAULT_EDIT_TOOLS], read: [...DEFAULT_READ_TOOLS], search: [...DEFAULT_SEARCH_TOOLS], shell: [...DEFAULT_SHELL_TOOLS] };
79
+ }
80
+ function resolve(list) {
81
+ return new Set(list);
82
+ }
83
+ function editTools(cfg) {
84
+ return resolve(cfg.tools.edit);
85
+ }
86
+ function readTools(cfg) {
87
+ return resolve(cfg.tools.read);
88
+ }
89
+ function searchTools(cfg) {
90
+ return resolve(cfg.tools.search);
91
+ }
92
+ function shellTools(cfg) {
93
+ return resolve(cfg.tools.shell);
94
+ }
95
+
96
+ // src/config.ts
97
+ var defaultConfig = {
98
+ harness: "kimi",
99
+ tools: toolDefaultsFor("kimi"),
100
+ repeat: {
101
+ enabled: true,
102
+ maxRepeats: 3,
103
+ warnAt: 2,
104
+ windowMinutes: 30,
105
+ watch: ["Grep", "Glob", "Shell", "Bash", "FetchURL", "SearchWeb", "ReadFile"],
106
+ thresholds: { ReadFile: 5 },
107
+ exemptPatterns: []
108
+ },
109
+ cycle: { enabled: true, windowMinutes: 30 },
110
+ noProgress: { enabled: true, windowMinutes: 30, warnAt: 15, blockAt: 25 },
111
+ nearRepeat: { enabled: true, windowMinutes: 30, warnAt: 6, blockAt: 10 },
112
+ explore: { enabled: true, windowMinutes: 30, warnAt: 10, blockAt: 15 },
113
+ verify: {
114
+ enabled: true,
115
+ blockOnNoEvidence: false,
116
+ evidenceWindowMinutes: 60,
117
+ claimPatterns: [],
118
+ evidencePatterns: [],
119
+ shellTools: ["Shell", "Bash"],
120
+ veto: {
121
+ enabled: false,
122
+ model: "kimi-k3",
123
+ baseUrl: "https://api.moonshot.cn/v1",
124
+ maxCallsPerSession: 3,
125
+ timeoutMs: 1e4
126
+ }
127
+ },
128
+ thinking: { enabled: true, minThinkChars: 2e4, maxTextRatio: 0.1 },
129
+ anchor: { enabled: true, everyNPrompts: 5, maxChars: 1e3 },
130
+ context: { enabled: true, warnPercent: 85 },
131
+ noGain: { enabled: true, windowMinutes: 30, warnAt: 3, blockAt: 4, fuzzyEnabled: true, fuzzySimilarity: 0.85, fuzzyWarnAt: 4, fuzzyBlockAt: 6 },
132
+ churn: {
133
+ enabled: true,
134
+ windowMinutes: 30,
135
+ warnAt: 5,
136
+ blockAt: 10,
137
+ tools: ["WriteFile", "StrReplaceFile", "Edit", "Write", "MultiEdit", "NotebookEdit"]
138
+ },
139
+ policy: { killSwitch: true, maxBlocksPerSession: 5, blockWindowMinutes: 60 },
140
+ budget: {
141
+ enabled: true,
142
+ plan: "tier1",
143
+ weekly: 0,
144
+ fiveHour: 0,
145
+ dispatchTools: ["Task", "Agent"],
146
+ reservePercent: 10,
147
+ subagentWeight: 5,
148
+ warnPercent: 80,
149
+ precise: false,
150
+ preciseUrl: "",
151
+ preciseCacheSeconds: 300
152
+ },
153
+ probe: false
154
+ };
155
+ var CONFIG_TEMPLATE = `# agent-guard configuration
156
+ # Docs: https://github.com/shidesheng0218/kimi-guard
157
+
158
+ [tools] # canonical tool-name taxonomy \u2014 if your CLI version renames tools, fix it HERE
159
+ edit = ["WriteFile", "StrReplaceFile", "Edit", "Write", "MultiEdit", "NotebookEdit"]
160
+ read = ["ReadFile", "Read"]
161
+ search = ["Grep", "Glob"]
162
+ shell = ["Shell", "Bash"]
163
+
164
+ [repeat]
165
+ enabled = true
166
+ maxRepeats = 3 # identical (tool, args) calls allowed per window
167
+ warnAt = 2 # soft context warning before the hard block
168
+ windowMinutes = 30
169
+ watch = ["Grep", "Glob", "Shell", "Bash", "FetchURL", "SearchWeb", "ReadFile"]
170
+ # exemptPatterns = ["git status"] # regexes over JSON-serialized args; matching calls are never repeat-blocked (polling commands like git status, sleep)
171
+
172
+ [repeat.thresholds] # per-tool overrides
173
+ ReadFile = 5
174
+
175
+ [cycle] # A->B->A->B oscillation detection
176
+ enabled = true
177
+ windowMinutes = 30
178
+
179
+ [noProgress] # long stretch of calls with no successful edit
180
+ enabled = true
181
+ windowMinutes = 30
182
+ warnAt = 15
183
+ blockAt = 25
184
+
185
+ [nearRepeat] # fuzzy near-duplicates (punctuation/case/order differences)
186
+ enabled = true
187
+ windowMinutes = 30
188
+ warnAt = 6
189
+ blockAt = 10
190
+
191
+ [explore] # pure-exploration streak: reads/searches with no action in between
192
+ enabled = true
193
+ windowMinutes = 30
194
+ warnAt = 10
195
+ blockAt = 15
196
+
197
+ [verify] # completion-claim gate: "tests pass" must be backed by a real run
198
+ enabled = true
199
+ blockOnNoEvidence = false # hooks path: block Stop when edits landed but nothing was verified
200
+ evidenceWindowMinutes = 60
201
+ # deprecated: shell tool names now live in [tools] shell (this key still works)
202
+
203
+ [verify.veto] # optional LLM veto vote to suppress false positives (self-critic style)
204
+ enabled = false # requires KIMI_GUARD_VETO_API_KEY in the environment
205
+ model = "kimi-k3" # use a cheap fast model \u2014 the LLM only votes, never authors
206
+ baseUrl = "https://api.moonshot.cn/v1"
207
+ maxCallsPerSession = 3 # anti "vote-laundering" cap: the model cannot retry its way out
208
+ timeoutMs = 10000
209
+
210
+ [thinking] # thinking-dominance (pure-reasoning turns), Wire mode only
211
+ enabled = true
212
+ minThinkChars = 20000
213
+ maxTextRatio = 0.1
214
+
215
+ [anchor] # goal anchoring: re-inject the original task periodically
216
+ enabled = true
217
+ everyNPrompts = 5 # re-inject the goal every N prompts / steps
218
+ maxChars = 1000
219
+
220
+ [context] # context-fill gate (Wire mode reads StatusUpdate.context_usage)
221
+ enabled = true
222
+ warnPercent = 85 # steer a wrap-up warning when context is this full
223
+
224
+ [noGain] # different args, byte-identical output
225
+ enabled = true
226
+ windowMinutes = 30
227
+ warnAt = 3
228
+ blockAt = 4
229
+ fuzzyEnabled = true # also catch near-identical outputs (similarity, not identity)
230
+ fuzzySimilarity = 0.85 # trigram Jaccard threshold (0..1); higher = stricter
231
+ fuzzyWarnAt = 4
232
+ fuzzyBlockAt = 6
233
+
234
+ [churn] # same file edited over and over
235
+ enabled = true
236
+ windowMinutes = 30
237
+ warnAt = 5
238
+ blockAt = 10
239
+ # deprecated: edit tool names now live in [tools] edit (this key still works)
240
+
241
+ [policy]
242
+ killSwitch = true # after maxBlocksPerSession interventions, block ALL tools
243
+ maxBlocksPerSession = 5
244
+ blockWindowMinutes = 60
245
+
246
+ [budget] # request accounting for Kimi Coding Plans
247
+ enabled = true
248
+ plan = "tier1" # tier1: 1024/week | tier2: 2048 | tier3: 7168 (200 per 5h)
249
+ weekly = 0 # override weekly requests (0 = use plan preset)
250
+ fiveHour = 0 # override 5h requests (0 = use plan preset)
251
+ dispatchTools = ["Task", "Agent"]
252
+ reservePercent = 10 # keep this much headroom for you, not the agent
253
+ subagentWeight = 5 # ~requests each dispatched subagent costs
254
+ warnPercent = 80
255
+ precise = false # poll the official Kimi usage API for exact windows (needs KIMI_API_KEY, sk-kimi-...)
256
+ preciseUrl = "" # default https://api.kimi.com/coding/v1
257
+ preciseCacheSeconds = 300 # the API is rate-limited; cache aggressively. Falls back to event-based on any error
258
+
259
+ [probe]
260
+ enabled = false
261
+ `;
262
+ function applyClaudeDefaults(cfg) {
263
+ cfg.tools = toolDefaultsFor("claude");
264
+ cfg.repeat.watch = ["Grep", "Glob", "Bash", "Read", "WebFetch", "WebSearch"];
265
+ cfg.repeat.thresholds = { Read: 5 };
266
+ cfg.budget.dispatchTools = ["Task"];
267
+ }
268
+ function applyCodexDefaults(cfg) {
269
+ cfg.tools = toolDefaultsFor("codex");
270
+ cfg.repeat.watch = ["Bash", "apply_patch"];
271
+ cfg.repeat.thresholds = {};
272
+ cfg.budget.dispatchTools = ["Agent", "spawn_agent"];
273
+ }
274
+ function num(v, fallback) {
275
+ return typeof v === "number" && Number.isFinite(v) ? v : fallback;
276
+ }
277
+ function bool(v, fallback) {
278
+ return typeof v === "boolean" ? v : fallback;
279
+ }
280
+ function strArr(v, fallback) {
281
+ return Array.isArray(v) && v.every((x) => typeof x === "string") && v.length > 0 ? v : fallback;
282
+ }
283
+ function strArrOrNull(v) {
284
+ return Array.isArray(v) && v.every((x) => typeof x === "string") && v.length > 0 ? v : null;
285
+ }
286
+ function loadConfig(configPath = userConfigPath(), harness = "kimi") {
287
+ const cfg = structuredClone(defaultConfig);
288
+ cfg.harness = harness;
289
+ if (harness === "claude") applyClaudeDefaults(cfg);
290
+ if (harness === "codex") applyCodexDefaults(cfg);
291
+ let raw;
292
+ try {
293
+ raw = fs2.readFileSync(configPath, "utf8");
294
+ } catch {
295
+ return cfg;
296
+ }
297
+ let data;
298
+ try {
299
+ data = parseToml(raw);
300
+ } catch (err) {
301
+ process.stderr.write(`[agent-guard] failed to parse ${configPath}: ${err.message}
302
+ `);
303
+ return cfg;
304
+ }
305
+ const section = (name) => data[name] ?? {};
306
+ const tools = section("tools");
307
+ const toolsEdit = strArrOrNull(tools["edit"]);
308
+ const toolsRead = strArrOrNull(tools["read"]);
309
+ const toolsSearch = strArrOrNull(tools["search"]);
310
+ const toolsShell = strArrOrNull(tools["shell"]);
311
+ if (toolsEdit) cfg.tools.edit = toolsEdit;
312
+ if (toolsRead) cfg.tools.read = toolsRead;
313
+ if (toolsSearch) cfg.tools.search = toolsSearch;
314
+ if (toolsShell) cfg.tools.shell = toolsShell;
315
+ const repeat = section("repeat");
316
+ cfg.repeat.enabled = bool(repeat["enabled"], cfg.repeat.enabled);
317
+ cfg.repeat.maxRepeats = num(repeat["maxRepeats"], cfg.repeat.maxRepeats);
318
+ cfg.repeat.warnAt = num(repeat["warnAt"], cfg.repeat.warnAt);
319
+ cfg.repeat.windowMinutes = num(repeat["windowMinutes"], cfg.repeat.windowMinutes);
320
+ cfg.repeat.watch = strArr(repeat["watch"], cfg.repeat.watch);
321
+ const exempt = repeat["exemptPatterns"];
322
+ if (Array.isArray(exempt)) cfg.repeat.exemptPatterns = exempt.filter((p) => typeof p === "string");
323
+ const th = repeat["thresholds"];
324
+ if (th) {
325
+ for (const [k, v] of Object.entries(th)) if (typeof v === "number") cfg.repeat.thresholds[k] = v;
326
+ }
327
+ const cycle = section("cycle");
328
+ cfg.cycle.enabled = bool(cycle["enabled"], cfg.cycle.enabled);
329
+ cfg.cycle.windowMinutes = num(cycle["windowMinutes"], cfg.cycle.windowMinutes);
330
+ const noProgress = section("noProgress");
331
+ cfg.noProgress.enabled = bool(noProgress["enabled"], cfg.noProgress.enabled);
332
+ cfg.noProgress.windowMinutes = num(noProgress["windowMinutes"], cfg.noProgress.windowMinutes);
333
+ cfg.noProgress.warnAt = num(noProgress["warnAt"], cfg.noProgress.warnAt);
334
+ cfg.noProgress.blockAt = num(noProgress["blockAt"], cfg.noProgress.blockAt);
335
+ const nearRepeat = section("nearRepeat");
336
+ cfg.nearRepeat.enabled = bool(nearRepeat["enabled"], cfg.nearRepeat.enabled);
337
+ cfg.nearRepeat.windowMinutes = num(nearRepeat["windowMinutes"], cfg.nearRepeat.windowMinutes);
338
+ cfg.nearRepeat.warnAt = num(nearRepeat["warnAt"], cfg.nearRepeat.warnAt);
339
+ cfg.nearRepeat.blockAt = num(nearRepeat["blockAt"], cfg.nearRepeat.blockAt);
340
+ const explore = section("explore");
341
+ cfg.explore.enabled = bool(explore["enabled"], cfg.explore.enabled);
342
+ cfg.explore.windowMinutes = num(explore["windowMinutes"], cfg.explore.windowMinutes);
343
+ cfg.explore.warnAt = num(explore["warnAt"], cfg.explore.warnAt);
344
+ cfg.explore.blockAt = num(explore["blockAt"], cfg.explore.blockAt);
345
+ const verify = section("verify");
346
+ cfg.verify.enabled = bool(verify["enabled"], cfg.verify.enabled);
347
+ cfg.verify.blockOnNoEvidence = bool(verify["blockOnNoEvidence"], cfg.verify.blockOnNoEvidence);
348
+ cfg.verify.evidenceWindowMinutes = num(verify["evidenceWindowMinutes"], cfg.verify.evidenceWindowMinutes);
349
+ const claims = verify["claimPatterns"];
350
+ if (Array.isArray(claims)) cfg.verify.claimPatterns = claims.filter((c) => typeof c === "string");
351
+ const evidence = verify["evidencePatterns"];
352
+ if (Array.isArray(evidence)) cfg.verify.evidencePatterns = evidence.filter((c) => typeof c === "string");
353
+ cfg.verify.shellTools = strArr(verify["shellTools"], cfg.verify.shellTools);
354
+ const legacyShellTools = strArrOrNull(verify["shellTools"]);
355
+ if (legacyShellTools && !toolsShell) cfg.tools.shell = legacyShellTools;
356
+ const veto = verify["veto"];
357
+ if (veto) {
358
+ cfg.verify.veto.enabled = bool(veto["enabled"], cfg.verify.veto.enabled);
359
+ cfg.verify.veto.model = typeof veto["model"] === "string" ? veto["model"] : cfg.verify.veto.model;
360
+ cfg.verify.veto.baseUrl = typeof veto["baseUrl"] === "string" ? veto["baseUrl"] : cfg.verify.veto.baseUrl;
361
+ cfg.verify.veto.maxCallsPerSession = num(veto["maxCallsPerSession"], cfg.verify.veto.maxCallsPerSession);
362
+ cfg.verify.veto.timeoutMs = num(veto["timeoutMs"], cfg.verify.veto.timeoutMs);
363
+ }
364
+ const thinking = section("thinking");
365
+ cfg.thinking.enabled = bool(thinking["enabled"], cfg.thinking.enabled);
366
+ cfg.thinking.minThinkChars = num(thinking["minThinkChars"], cfg.thinking.minThinkChars);
367
+ cfg.thinking.maxTextRatio = num(thinking["maxTextRatio"], cfg.thinking.maxTextRatio);
368
+ const anchor = section("anchor");
369
+ cfg.anchor.enabled = bool(anchor["enabled"], cfg.anchor.enabled);
370
+ cfg.anchor.everyNPrompts = num(anchor["everyNPrompts"], cfg.anchor.everyNPrompts);
371
+ cfg.anchor.maxChars = num(anchor["maxChars"], cfg.anchor.maxChars);
372
+ const context = section("context");
373
+ cfg.context.enabled = bool(context["enabled"], cfg.context.enabled);
374
+ cfg.context.warnPercent = num(context["warnPercent"], cfg.context.warnPercent);
375
+ const noGain = section("noGain");
376
+ cfg.noGain.enabled = bool(noGain["enabled"], cfg.noGain.enabled);
377
+ cfg.noGain.windowMinutes = num(noGain["windowMinutes"], cfg.noGain.windowMinutes);
378
+ cfg.noGain.warnAt = num(noGain["warnAt"], cfg.noGain.warnAt);
379
+ cfg.noGain.blockAt = num(noGain["blockAt"], cfg.noGain.blockAt);
380
+ cfg.noGain.fuzzyEnabled = bool(noGain["fuzzyEnabled"], cfg.noGain.fuzzyEnabled);
381
+ cfg.noGain.fuzzySimilarity = Math.max(0.5, Math.min(1, num(noGain["fuzzySimilarity"], cfg.noGain.fuzzySimilarity)));
382
+ cfg.noGain.fuzzyWarnAt = num(noGain["fuzzyWarnAt"], cfg.noGain.fuzzyWarnAt);
383
+ cfg.noGain.fuzzyBlockAt = num(noGain["fuzzyBlockAt"], cfg.noGain.fuzzyBlockAt);
384
+ const churn = section("churn");
385
+ cfg.churn.enabled = bool(churn["enabled"], cfg.churn.enabled);
386
+ cfg.churn.windowMinutes = num(churn["windowMinutes"], cfg.churn.windowMinutes);
387
+ cfg.churn.warnAt = num(churn["warnAt"], cfg.churn.warnAt);
388
+ cfg.churn.blockAt = num(churn["blockAt"], cfg.churn.blockAt);
389
+ cfg.churn.tools = strArr(churn["tools"], cfg.churn.tools);
390
+ const legacyChurnTools = strArrOrNull(churn["tools"]);
391
+ if (legacyChurnTools && !toolsEdit) cfg.tools.edit = legacyChurnTools;
392
+ const policy = section("policy");
393
+ cfg.policy.killSwitch = bool(policy["killSwitch"], cfg.policy.killSwitch);
394
+ cfg.policy.maxBlocksPerSession = num(policy["maxBlocksPerSession"], cfg.policy.maxBlocksPerSession);
395
+ cfg.policy.blockWindowMinutes = num(policy["blockWindowMinutes"], cfg.policy.blockWindowMinutes);
396
+ const budget = section("budget");
397
+ cfg.budget.enabled = bool(budget["enabled"], cfg.budget.enabled);
398
+ cfg.budget.plan = typeof budget["plan"] === "string" ? budget["plan"] : cfg.budget.plan;
399
+ cfg.budget.weekly = num(budget["weekly"], cfg.budget.weekly);
400
+ cfg.budget.fiveHour = num(budget["fiveHour"], cfg.budget.fiveHour);
401
+ cfg.budget.dispatchTools = strArr(budget["dispatchTools"], cfg.budget.dispatchTools);
402
+ cfg.budget.reservePercent = num(budget["reservePercent"], cfg.budget.reservePercent);
403
+ cfg.budget.subagentWeight = num(budget["subagentWeight"], cfg.budget.subagentWeight);
404
+ cfg.budget.warnPercent = num(budget["warnPercent"], cfg.budget.warnPercent);
405
+ cfg.budget.precise = bool(budget["precise"], cfg.budget.precise);
406
+ cfg.budget.preciseUrl = typeof budget["preciseUrl"] === "string" ? budget["preciseUrl"] : cfg.budget.preciseUrl;
407
+ cfg.budget.preciseCacheSeconds = num(budget["preciseCacheSeconds"], cfg.budget.preciseCacheSeconds);
408
+ cfg.probe = bool(section("probe")["enabled"], cfg.probe);
409
+ cfg.verify.shellTools = cfg.tools.shell;
410
+ cfg.churn.tools = cfg.tools.edit;
411
+ return cfg;
412
+ }
413
+ function writeConfigTemplate(configPath = userConfigPath()) {
414
+ if (fs2.existsSync(configPath)) return false;
415
+ fs2.mkdirSync(configPath.replace(/[/\\][^/\\]+$/, ""), { recursive: true });
416
+ fs2.writeFileSync(configPath, CONFIG_TEMPLATE, "utf8");
417
+ return true;
418
+ }
419
+
420
+ // src/harness/claude.ts
421
+ import fs3 from "fs";
422
+ import path2 from "path";
423
+ var CLAUDE_COMMAND_MARKER = "agentguard hook";
424
+ var CLAUDE_EVENTS = [
425
+ "PreToolUse",
426
+ "PostToolUse",
427
+ "PostToolUseFailure",
428
+ "UserPromptSubmit",
429
+ "Stop",
430
+ "SubagentStart",
431
+ "SessionStart",
432
+ "SessionEnd",
433
+ "PreCompact",
434
+ "PostCompact",
435
+ "StopFailure"
436
+ ];
437
+ var CLAUDE_EVENTS_COMPAT = ["PreToolUse", "PostToolUse", "PostToolUseFailure"];
438
+ function isOurs(group) {
439
+ return (group.hooks ?? []).some((h) => typeof h.command === "string" && h.command.includes(CLAUDE_COMMAND_MARKER));
440
+ }
441
+ function ourGroup(event, bin) {
442
+ return {
443
+ matcher: "",
444
+ hooks: [{ type: "command", command: `${bin} hook ${event} --harness claude`, timeout: 5 }]
445
+ };
446
+ }
447
+ function installClaudeHooks(bin = "agentguard", compat = false) {
448
+ const configPath = claudeSettingsPath();
449
+ const created = !fs3.existsSync(configPath);
450
+ let settings = {};
451
+ let backupPath;
452
+ if (!created) {
453
+ const raw = fs3.readFileSync(configPath, "utf8");
454
+ try {
455
+ settings = JSON.parse(raw);
456
+ } catch {
457
+ backupPath = `${configPath}.agentguard.bak`;
458
+ fs3.copyFileSync(configPath, backupPath);
459
+ settings = {};
460
+ }
461
+ if (!backupPath) {
462
+ backupPath = `${configPath}.agentguard.bak`;
463
+ fs3.writeFileSync(backupPath, raw, "utf8");
464
+ }
465
+ }
466
+ const events = compat ? CLAUDE_EVENTS_COMPAT : CLAUDE_EVENTS;
467
+ const hooks = settings.hooks ??= {};
468
+ let updated = false;
469
+ for (const event of events) {
470
+ const groups = (hooks[event] ?? []).filter((g) => !isOurs(g));
471
+ const before = JSON.stringify(hooks[event] ?? []);
472
+ groups.push(ourGroup(event, bin));
473
+ hooks[event] = groups;
474
+ if (JSON.stringify(groups) !== before) updated = true;
475
+ }
476
+ fs3.mkdirSync(path2.dirname(configPath), { recursive: true });
477
+ fs3.writeFileSync(configPath, JSON.stringify(settings, null, 2) + "\n", "utf8");
478
+ return { configPath, created, updated: updated || created, backupPath: created ? void 0 : backupPath };
479
+ }
480
+ function uninstallClaudeHooks() {
481
+ const configPath = claudeSettingsPath();
482
+ if (!fs3.existsSync(configPath)) return { configPath, removed: false };
483
+ let settings;
484
+ try {
485
+ settings = JSON.parse(fs3.readFileSync(configPath, "utf8"));
486
+ } catch {
487
+ return { configPath, removed: false };
488
+ }
489
+ const hooks = settings.hooks;
490
+ if (!hooks) return { configPath, removed: false };
491
+ let removed = false;
492
+ for (const event of Object.keys(hooks)) {
493
+ const kept = hooks[event].filter((g) => !isOurs(g));
494
+ if (kept.length !== hooks[event].length) removed = true;
495
+ if (kept.length === 0) delete hooks[event];
496
+ else hooks[event] = kept;
497
+ }
498
+ if (removed) fs3.writeFileSync(configPath, JSON.stringify(settings, null, 2) + "\n", "utf8");
499
+ return { configPath, removed };
500
+ }
501
+ function claudeHooksInstalled(configPath = claudeSettingsPath()) {
502
+ try {
503
+ const settings = JSON.parse(fs3.readFileSync(configPath, "utf8"));
504
+ return Object.values(settings.hooks ?? {}).some((groups) => groups.some(isOurs));
505
+ } catch {
506
+ return false;
507
+ }
508
+ }
509
+
510
+ // src/store.ts
511
+ import fs4 from "fs";
512
+ import path3 from "path";
513
+ import { createRequire } from "module";
514
+ var nodeRequire = createRequire(import.meta.url);
515
+ function sqliteCtor() {
516
+ return nodeRequire("node:sqlite").DatabaseSync;
517
+ }
518
+ var SCHEMA_VERSION = 4;
519
+ var SCHEMA = `
520
+ CREATE TABLE IF NOT EXISTS calls (
521
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
522
+ session_id TEXT NOT NULL,
523
+ tool_name TEXT NOT NULL,
524
+ args_hash TEXT NOT NULL,
525
+ args_json TEXT NOT NULL,
526
+ output_hash TEXT,
527
+ output_sample TEXT,
528
+ file_path TEXT,
529
+ status TEXT NOT NULL,
530
+ ts INTEGER NOT NULL
531
+ );
532
+ CREATE INDEX IF NOT EXISTS idx_calls_session ON calls(session_id, ts);
533
+ CREATE INDEX IF NOT EXISTS idx_calls_sig ON calls(session_id, tool_name, args_hash, ts);
534
+ CREATE INDEX IF NOT EXISTS idx_calls_out ON calls(session_id, tool_name, output_hash, ts);
535
+ CREATE INDEX IF NOT EXISTS idx_calls_time ON calls(ts);
536
+ CREATE TABLE IF NOT EXISTS events (
537
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
538
+ session_id TEXT NOT NULL,
539
+ kind TEXT NOT NULL,
540
+ meta_json TEXT NOT NULL,
541
+ ts INTEGER NOT NULL
542
+ );
543
+ CREATE INDEX IF NOT EXISTS idx_events_kind ON events(session_id, kind, ts);
544
+ CREATE INDEX IF NOT EXISTS idx_events_time ON events(ts);
545
+ CREATE TABLE IF NOT EXISTS blocks (
546
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
547
+ session_id TEXT NOT NULL,
548
+ tool_name TEXT NOT NULL,
549
+ kind TEXT NOT NULL,
550
+ ts INTEGER NOT NULL,
551
+ feedback TEXT
552
+ );
553
+ CREATE INDEX IF NOT EXISTS idx_blocks_session ON blocks(session_id, ts);
554
+ CREATE TABLE IF NOT EXISTS meta (
555
+ k TEXT PRIMARY KEY,
556
+ v TEXT NOT NULL
557
+ );
558
+ `;
559
+ var db = null;
560
+ function openDb() {
561
+ if (db) return db;
562
+ const file = stateDbPath();
563
+ fs4.mkdirSync(path3.dirname(file), { recursive: true });
564
+ const d = new (sqliteCtor())(file);
565
+ d.exec("PRAGMA journal_mode = WAL;");
566
+ migrate(d);
567
+ db = d;
568
+ return db;
569
+ }
570
+ function migrate(d) {
571
+ d.exec("CREATE TABLE IF NOT EXISTS meta (k TEXT PRIMARY KEY, v TEXT NOT NULL);");
572
+ const row = d.prepare("SELECT v FROM meta WHERE k = 'schema_version'").get();
573
+ const version = row ? Number(row.v) : 0;
574
+ if (version === SCHEMA_VERSION) {
575
+ d.exec(SCHEMA);
576
+ return;
577
+ }
578
+ if (version >= 2) {
579
+ d.exec(SCHEMA);
580
+ if (version < 3) {
581
+ try {
582
+ d.exec("ALTER TABLE blocks ADD COLUMN feedback TEXT");
583
+ } catch {
584
+ }
585
+ }
586
+ if (version < 4) {
587
+ try {
588
+ d.exec("ALTER TABLE calls ADD COLUMN output_sample TEXT");
589
+ } catch {
590
+ }
591
+ }
592
+ } else {
593
+ d.exec("DROP TABLE IF EXISTS calls; DROP TABLE IF EXISTS events; DROP TABLE IF EXISTS blocks;");
594
+ d.exec(SCHEMA);
595
+ }
596
+ d.prepare(
597
+ "INSERT INTO meta (k, v) VALUES ('schema_version', ?) ON CONFLICT(k) DO UPDATE SET v = excluded.v"
598
+ ).run(String(SCHEMA_VERSION));
599
+ }
600
+ function recordCall(call) {
601
+ openDb().prepare(
602
+ "INSERT INTO calls (session_id, tool_name, args_hash, args_json, output_hash, output_sample, file_path, status, ts) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
603
+ ).run(
604
+ call.sessionId,
605
+ call.toolName,
606
+ call.argsHash,
607
+ call.argsJson,
608
+ call.outputHash,
609
+ call.outputSample ?? null,
610
+ call.filePath,
611
+ call.status,
612
+ call.ts ?? Date.now()
613
+ );
614
+ }
615
+ function callsSince(sessionId, sinceTs, limit = 500) {
616
+ return openDb().prepare(
617
+ "SELECT tool_name, args_hash, args_json, output_hash, output_sample, file_path, status, ts FROM calls WHERE session_id = ? AND ts >= ? ORDER BY ts ASC LIMIT ?"
618
+ ).all(sessionId, sinceTs, limit);
619
+ }
620
+ function recordEvent(sessionId, kind, meta, ts = Date.now()) {
621
+ openDb().prepare("INSERT INTO events (session_id, kind, meta_json, ts) VALUES (?, ?, ?, ?)").run(sessionId, kind, JSON.stringify(meta), ts);
622
+ }
623
+ function countEvents(sessionId, kinds, sinceTs) {
624
+ const placeholders = kinds.map(() => "?").join(",");
625
+ const row = openDb().prepare(
626
+ `SELECT COUNT(*) AS n FROM events WHERE session_id = ? AND kind IN (${placeholders}) AND ts >= ?`
627
+ ).get(sessionId, ...kinds, sinceTs);
628
+ return Number(row?.n ?? 0);
629
+ }
630
+ function oldestEventTs(sessionId, kinds, sinceTs) {
631
+ const placeholders = kinds.map(() => "?").join(",");
632
+ const row = openDb().prepare(
633
+ `SELECT MIN(ts) AS m FROM events WHERE session_id = ? AND kind IN (${placeholders}) AND ts >= ?`
634
+ ).get(sessionId, ...kinds, sinceTs);
635
+ return row?.m ?? null;
636
+ }
637
+ function recordBlock(sessionId, toolName, kind, ts = Date.now()) {
638
+ const info = openDb().prepare("INSERT INTO blocks (session_id, tool_name, kind, ts) VALUES (?, ?, ?, ?)").run(sessionId, toolName, kind, ts);
639
+ return Number(info.lastInsertRowid);
640
+ }
641
+ function listBlocks(limit = 20) {
642
+ return openDb().prepare("SELECT id, session_id, tool_name, kind, ts, feedback FROM blocks ORDER BY id DESC LIMIT ?").all(limit);
643
+ }
644
+ function setBlockFeedback(id, verdict) {
645
+ const info = openDb().prepare("UPDATE blocks SET feedback = ? WHERE id = ?").run(verdict, id);
646
+ return Number(info.changes) > 0;
647
+ }
648
+ function blockKindStats() {
649
+ const rows = openDb().prepare(
650
+ `SELECT kind, COUNT(*) AS n,
651
+ SUM(CASE WHEN feedback = 'fp' THEN 1 ELSE 0 END) AS fp,
652
+ SUM(CASE WHEN feedback = 'tp' THEN 1 ELSE 0 END) AS tp
653
+ FROM blocks GROUP BY kind ORDER BY n DESC`
654
+ ).all();
655
+ return rows.map((r) => ({ kind: r.kind, n: Number(r.n), fp: Number(r.fp), tp: Number(r.tp) }));
656
+ }
657
+ function countBlocks(sessionId, sinceTs) {
658
+ const row = openDb().prepare("SELECT COUNT(*) AS n FROM blocks WHERE session_id = ? AND ts >= ?").get(sessionId, sinceTs);
659
+ return Number(row?.n ?? 0);
660
+ }
661
+ function getMeta(key) {
662
+ const row = openDb().prepare("SELECT v FROM meta WHERE k = ?").get(key);
663
+ return row?.v;
664
+ }
665
+ function setMeta(key, value) {
666
+ openDb().prepare("INSERT INTO meta (k, v) VALUES (?, ?) ON CONFLICT(k) DO UPDATE SET v = excluded.v").run(key, value);
667
+ }
668
+ function knownSessions(limit = 5) {
669
+ return openDb().prepare(
670
+ `SELECT session_id, MAX(last_ts) AS last_ts, SUM(n) AS n FROM (
671
+ SELECT session_id, MAX(ts) AS last_ts, COUNT(*) AS n FROM calls GROUP BY session_id
672
+ UNION ALL
673
+ SELECT session_id, MAX(ts) AS last_ts, COUNT(*) AS n FROM events GROUP BY session_id
674
+ ) GROUP BY session_id ORDER BY last_ts DESC LIMIT ?`
675
+ ).all(limit);
676
+ }
677
+ function buildStatus() {
678
+ const d = openDb();
679
+ const now = Date.now();
680
+ const day = now - 864e5;
681
+ const calls24h = Number(d.prepare("SELECT COUNT(*) AS n FROM calls WHERE ts >= ?").get(day).n);
682
+ const blocks24h = d.prepare("SELECT kind, COUNT(*) AS n FROM blocks WHERE ts >= ? GROUP BY kind").all(day).map((r) => ({ kind: r.kind, n: Number(r.n) }));
683
+ const topRepeated = d.prepare(
684
+ "SELECT tool_name, args_hash, COUNT(*) AS n FROM calls WHERE ts >= ? GROUP BY session_id, tool_name, args_hash ORDER BY n DESC LIMIT 5"
685
+ ).all(day).map((r) => ({ tool_name: r.tool_name, args_hash: r.args_hash, n: Number(r.n) }));
686
+ const noGainPairs = d.prepare(
687
+ "SELECT tool_name, COUNT(DISTINCT session_id || ':' || output_hash) AS n FROM calls WHERE ts >= ? AND output_hash IS NOT NULL GROUP BY tool_name ORDER BY n DESC LIMIT 5"
688
+ ).all(day).map((r) => ({ tool_name: r.tool_name, n: Number(r.n) }));
689
+ const events24h = d.prepare("SELECT kind, COUNT(*) AS n FROM events WHERE ts >= ? GROUP BY kind").all(day).map((r) => ({ kind: r.kind, n: Number(r.n) }));
690
+ const lastActivity = d.prepare("SELECT MAX(m) AS m FROM (SELECT MAX(ts) AS m FROM calls UNION ALL SELECT MAX(ts) AS m FROM events)").get();
691
+ return {
692
+ calls24h,
693
+ blocks24h,
694
+ topRepeated,
695
+ noGainPairs,
696
+ events24h,
697
+ lastActivityTs: lastActivity?.m ?? null
698
+ };
699
+ }
700
+
701
+ // src/events.ts
702
+ import { createHash } from "crypto";
703
+ function pickString(payload, keys) {
704
+ for (const k of keys) {
705
+ const v = payload[k];
706
+ if (typeof v === "string" && v) return v;
707
+ }
708
+ return "";
709
+ }
710
+ function pickField(payload, keys) {
711
+ for (const k of keys) {
712
+ if (payload[k] !== void 0) return payload[k];
713
+ }
714
+ return void 0;
715
+ }
716
+ function collapseWs(s) {
717
+ return s.replace(/\s+/g, " ").trim();
718
+ }
719
+ function sortDeep(value, collapseStrings) {
720
+ if (Array.isArray(value)) return value.map((x) => sortDeep(x, collapseStrings));
721
+ if (value !== null && typeof value === "object") {
722
+ const out = {};
723
+ for (const k of Object.keys(value).sort()) {
724
+ out[k] = sortDeep(value[k], collapseStrings);
725
+ }
726
+ return out;
727
+ }
728
+ if (value === void 0) return null;
729
+ if (typeof value === "string") return collapseStrings ? collapseWs(value) : value;
730
+ return value;
731
+ }
732
+ var WHITESPACE_SENSITIVE = /* @__PURE__ */ new Set(["Shell", "Bash", "Grep", "Glob", "FetchURL", "SearchWeb", "ReadFile", "WriteFile", "StrReplaceFile", "Edit", "Write", "MultiEdit"]);
733
+ function fingerprint(tool, args) {
734
+ let v = args ?? {};
735
+ if (v === null || typeof v !== "object") v = { value: v ?? null };
736
+ const normalized = sortDeep(v, !WHITESPACE_SENSITIVE.has(tool));
737
+ return createHash("sha256").update(JSON.stringify(normalized)).digest("hex").slice(0, 16);
738
+ }
739
+ function hashOutput(output) {
740
+ if (output === void 0 || output === null) return null;
741
+ let s;
742
+ if (typeof output === "string") s = output;
743
+ else {
744
+ try {
745
+ s = JSON.stringify(sortDeep(output, false));
746
+ } catch {
747
+ s = String(output);
748
+ }
749
+ }
750
+ s = collapseWs(s).slice(0, 4096);
751
+ if (!s) return null;
752
+ return createHash("sha256").update(s).digest("hex").slice(0, 16);
753
+ }
754
+ function outputSampleOf(output) {
755
+ if (output === void 0 || output === null) return null;
756
+ let s;
757
+ if (typeof output === "string") s = output;
758
+ else {
759
+ try {
760
+ s = JSON.stringify(output);
761
+ } catch {
762
+ s = String(output);
763
+ }
764
+ }
765
+ s = collapseWs(s).slice(0, 1024);
766
+ return s || null;
767
+ }
768
+ var FILE_KEYS = ["file_path", "filePath", "path", "file", "filename", "notebook_path", "target"];
769
+ function extractFile(args) {
770
+ if (args === null || typeof args !== "object") return null;
771
+ const obj = args;
772
+ for (const k of FILE_KEYS) {
773
+ const v = obj[k];
774
+ if (typeof v === "string" && v) return v;
775
+ }
776
+ return null;
777
+ }
778
+ function normalizeCall(payload, event, ts = Date.now()) {
779
+ const sessionId = pickString(payload, ["session_id", "sessionId", "session", "sessionID"]) || "unknown";
780
+ const tool = pickString(payload, ["tool_name", "toolName", "tool"]);
781
+ if (!tool) return null;
782
+ const args = pickField(payload, ["tool_input", "toolInput", "input"]) ?? {};
783
+ const outputKeys = ["tool_output", "toolOutput", "tool_response", "output", "result"];
784
+ const output = event === "PostToolUse" ? pickField(payload, outputKeys) : event === "PostToolUseFailure" ? pickField(payload, ["error", "error_message", ...outputKeys]) : void 0;
785
+ const argsJson = JSON.stringify(args).slice(0, 2048);
786
+ return {
787
+ sessionId,
788
+ tool,
789
+ args,
790
+ argsHash: fingerprint(tool, args),
791
+ argsJson,
792
+ outputHash: output !== void 0 ? hashOutput(output) : null,
793
+ outputSample: output !== void 0 ? outputSampleOf(output) : null,
794
+ filePath: extractFile(args),
795
+ status: event === "PostToolUseFailure" ? "failure" : "ok",
796
+ ts
797
+ };
798
+ }
799
+
800
+ // src/analysis.ts
801
+ var allow = [];
802
+ function isRepeatExempt(proposed, cfg) {
803
+ if (cfg.repeat.exemptPatterns.length === 0) return false;
804
+ let text;
805
+ try {
806
+ text = JSON.stringify(proposed.args ?? {});
807
+ } catch {
808
+ text = String(proposed.args);
809
+ }
810
+ for (const p of cfg.repeat.exemptPatterns) {
811
+ try {
812
+ if (new RegExp(p).test(text)) return true;
813
+ } catch {
814
+ }
815
+ }
816
+ return false;
817
+ }
818
+ function analyzeRepetition(history, proposed, cfg, now) {
819
+ if (!cfg.repeat.enabled) return allow;
820
+ const watched = cfg.repeat.watch.includes(proposed.tool) || proposed.tool in cfg.repeat.thresholds;
821
+ if (!watched) return allow;
822
+ if (isRepeatExempt(proposed, cfg)) return allow;
823
+ const threshold = cfg.repeat.thresholds[proposed.tool] ?? cfg.repeat.maxRepeats;
824
+ const since = now - cfg.repeat.windowMinutes * 6e4;
825
+ const n = history.filter(
826
+ (r) => r.tool_name === proposed.tool && r.args_hash === proposed.argsHash && r.ts >= since
827
+ ).length;
828
+ if (n >= threshold) {
829
+ return [
830
+ {
831
+ kind: "repeat",
832
+ severity: "block",
833
+ tool: proposed.tool,
834
+ message: `"${proposed.tool}" has already been called ${n} times with identical arguments in the last ${cfg.repeat.windowMinutes} minutes. The previous results are already in context \u2014 use them instead of re-running. If a retry is genuinely required, change the arguments or state why the previous result is insufficient.`,
835
+ evidence: `signature count=${n}, threshold=${threshold}`
836
+ }
837
+ ];
838
+ }
839
+ if (cfg.repeat.warnAt > 0 && n >= cfg.repeat.warnAt) {
840
+ return [
841
+ {
842
+ kind: "repeat",
843
+ severity: "warn",
844
+ tool: proposed.tool,
845
+ message: `"${proposed.tool}" has been called ${n} times with identical arguments \u2014 the result is already in context. Identical calls are blocked at ${threshold}; make sure any retry adds new information.`,
846
+ evidence: `signature count=${n}, warnAt=${cfg.repeat.warnAt}`
847
+ }
848
+ ];
849
+ }
850
+ return allow;
851
+ }
852
+ function analyzeCycles(history, cfg, now) {
853
+ if (!cfg.cycle.enabled) return allow;
854
+ const since = now - cfg.cycle.windowMinutes * 6e4;
855
+ const recent = history.filter((r) => r.ts >= since).slice(-16).map((r) => `${r.tool_name}:${r.args_hash}`);
856
+ if (recent.length < 8) return allow;
857
+ const findings = [];
858
+ for (let period = 1; period <= 3; period++) {
859
+ const minReps = period === 1 ? 5 : 3;
860
+ const needed = period * minReps;
861
+ const tail = recent.slice(-needed);
862
+ if (tail.length < needed) continue;
863
+ const base = tail.slice(0, period);
864
+ let isCycle = true;
865
+ for (let i = period; i < tail.length; i++) {
866
+ if (tail[i] !== base[i % period]) {
867
+ isCycle = false;
868
+ break;
869
+ }
870
+ }
871
+ if (isCycle) {
872
+ const desc = period === 1 ? `the same call (${base[0]})` : `a ${period}-step cycle (${base.map((s) => s.split(":")[0]).join(" \u2192 ")})`;
873
+ findings.push({
874
+ kind: "cycle",
875
+ severity: "block",
876
+ tool: base[0]?.split(":")[0],
877
+ message: `Loop detected: the agent has repeated ${desc} ${minReps}+ times in a row without progress. Stop re-running this sequence. Re-read the results already in context, reassess the approach, and either proceed differently or end the turn with a summary.`,
878
+ evidence: `period=${period}, reps>=${minReps}`
879
+ });
880
+ break;
881
+ }
882
+ }
883
+ return findings;
884
+ }
885
+ function analyzeNoGain(history, cfg, now) {
886
+ if (!cfg.noGain.enabled) return allow;
887
+ const since = now - cfg.noGain.windowMinutes * 6e4;
888
+ const byPair = /* @__PURE__ */ new Map();
889
+ for (const r of history) {
890
+ if (r.ts < since || !r.output_hash) continue;
891
+ const key = `${r.tool_name}:${r.output_hash}`;
892
+ byPair.set(key, (byPair.get(key) ?? 0) + 1);
893
+ }
894
+ const findings = [];
895
+ for (const [key, n] of byPair) {
896
+ if (n < cfg.noGain.warnAt) continue;
897
+ const tool = key.split(":")[0];
898
+ if (n >= cfg.noGain.blockAt) {
899
+ findings.push({
900
+ kind: "noGain",
901
+ severity: "block",
902
+ tool,
903
+ message: `No-progress loop: ${tool} returned the exact same output ${n} times despite different arguments. You are not gaining new information. Stop calling ${tool}, analyze the result you already have, change strategy, or report your findings.`,
904
+ evidence: `tool=${tool} identical_output_count=${n}`
905
+ });
906
+ } else {
907
+ findings.push({
908
+ kind: "noGain",
909
+ severity: "warn",
910
+ tool,
911
+ message: `${tool} has returned the same output ${n} times \u2014 verify you are not repeating work.`,
912
+ evidence: `tool=${tool} identical_output_count=${n}`
913
+ });
914
+ }
915
+ }
916
+ return findings.slice(0, 2);
917
+ }
918
+ function editToolSet(cfg) {
919
+ return editTools(cfg);
920
+ }
921
+ function analyzeNoProgress(history, proposed, cfg, now) {
922
+ if (!cfg.noProgress.enabled) return allow;
923
+ if (editToolSet(cfg).has(proposed.tool)) return allow;
924
+ const since = now - cfg.noProgress.windowMinutes * 6e4;
925
+ const tools = editToolSet(cfg);
926
+ let lastEditTs = -1;
927
+ for (const r of history) {
928
+ if (r.ts < since) continue;
929
+ if (tools.has(r.tool_name) && r.status === "ok") lastEditTs = Math.max(lastEditTs, r.ts);
930
+ }
931
+ const stretch = history.filter((r) => r.ts >= since && r.ts > lastEditTs).length;
932
+ if (stretch < cfg.noProgress.warnAt) return allow;
933
+ if (stretch >= cfg.noProgress.blockAt) {
934
+ return [
935
+ {
936
+ kind: "noProgress",
937
+ severity: "block",
938
+ tool: proposed.tool,
939
+ message: `No progress: ${stretch} tool calls in the last ${cfg.noProgress.windowMinutes} minutes with no successful file edit landing. You are circling, not converging. Stop, pick the single most valuable next change, make it deliberately, or report what is blocking you.`,
940
+ evidence: `stretch=${stretch} warnAt=${cfg.noProgress.warnAt} blockAt=${cfg.noProgress.blockAt}`
941
+ }
942
+ ];
943
+ }
944
+ return [
945
+ {
946
+ kind: "noProgress",
947
+ severity: "warn",
948
+ tool: proposed.tool,
949
+ message: `${stretch} calls without a landed edit recently \u2014 make sure the next step actually produces a change.`,
950
+ evidence: `stretch=${stretch}`
951
+ }
952
+ ];
953
+ }
954
+ function analyzeChurn(history, cfg, now) {
955
+ if (!cfg.churn.enabled) return allow;
956
+ const since = now - cfg.churn.windowMinutes * 6e4;
957
+ const tools = editTools(cfg);
958
+ const byFile = /* @__PURE__ */ new Map();
959
+ for (const r of history) {
960
+ if (r.ts < since || !r.file_path || !tools.has(r.tool_name)) continue;
961
+ byFile.set(r.file_path, (byFile.get(r.file_path) ?? 0) + 1);
962
+ }
963
+ const findings = [];
964
+ for (const [file, n] of byFile) {
965
+ if (n >= cfg.churn.blockAt) {
966
+ findings.push({
967
+ kind: "churn",
968
+ severity: "block",
969
+ tool: "edit",
970
+ message: `Edit churn: ${file} has been modified ${n} times in the last ${cfg.churn.windowMinutes} minutes without converging. Stop editing. Re-read the file and the error output, form an explicit hypothesis about why previous fixes failed, then make a single deliberate change \u2014 or ask the user for help.`,
971
+ evidence: `file=${file} edits=${n}`
972
+ });
973
+ } else if (n >= cfg.churn.warnAt) {
974
+ findings.push({
975
+ kind: "churn",
976
+ severity: "warn",
977
+ tool: "edit",
978
+ message: `${file} has been edited ${n} times recently \u2014 step back and verify your approach before editing again.`,
979
+ evidence: `file=${file} edits=${n}`
980
+ });
981
+ }
982
+ }
983
+ return findings.sort((a, b) => a.severity === b.severity ? 0 : a.severity === "block" ? -1 : 1).slice(0, 1);
984
+ }
985
+ function trigrams(s) {
986
+ const out = /* @__PURE__ */ new Set();
987
+ if (s.length < 3) {
988
+ if (s.length > 0) out.add(s);
989
+ return out;
990
+ }
991
+ for (let i = 0; i + 3 <= s.length; i++) out.add(s.slice(i, i + 3));
992
+ return out;
993
+ }
994
+ function trigramJaccard(a, b) {
995
+ const A = trigrams(a);
996
+ const B = trigrams(b);
997
+ if (A.size === 0 || B.size === 0) return 0;
998
+ let inter = 0;
999
+ for (const t of A) if (B.has(t)) inter++;
1000
+ return inter / (A.size + B.size - inter);
1001
+ }
1002
+ function analyzeNoGainFuzzy(history, proposed, cfg, now) {
1003
+ if (!cfg.noGain.enabled || !cfg.noGain.fuzzyEnabled) return allow;
1004
+ const since = now - cfg.noGain.windowMinutes * 6e4;
1005
+ const outs = history.filter((r) => r.ts >= since && r.tool_name === proposed.tool && r.output_sample);
1006
+ if (outs.length < 2) return allow;
1007
+ let streak = 0;
1008
+ for (let i = outs.length - 1; i > 0; i--) {
1009
+ const cur = outs[i];
1010
+ const prev = outs[i - 1];
1011
+ if (cur.output_hash === prev.output_hash) continue;
1012
+ if (trigramJaccard(cur.output_sample, prev.output_sample) >= cfg.noGain.fuzzySimilarity) streak++;
1013
+ else break;
1014
+ }
1015
+ if (streak < cfg.noGain.fuzzyWarnAt) return allow;
1016
+ if (streak >= cfg.noGain.fuzzyBlockAt) {
1017
+ return [
1018
+ {
1019
+ kind: "noGainFuzzy",
1020
+ severity: "block",
1021
+ tool: proposed.tool,
1022
+ message: `Stagnation: the last ${streak + 1} ${proposed.tool} calls returned outputs that differ only trivially (similarity \u2265 ${cfg.noGain.fuzzySimilarity}). You are re-querying without learning anything new. Work with what you have, or change the query substantially.`,
1023
+ evidence: `similar_streak=${streak} blockAt=${cfg.noGain.fuzzyBlockAt}`
1024
+ }
1025
+ ];
1026
+ }
1027
+ return [
1028
+ {
1029
+ kind: "noGainFuzzy",
1030
+ severity: "warn",
1031
+ tool: proposed.tool,
1032
+ message: `the last ${streak + 1} ${proposed.tool} outputs are near-identical \u2014 verify these calls still return new information.`,
1033
+ evidence: `similar_streak=${streak}`
1034
+ }
1035
+ ];
1036
+ }
1037
+ function analyzeExplore(history, proposed, cfg, now) {
1038
+ if (!cfg.explore.enabled) return allow;
1039
+ const passive = /* @__PURE__ */ new Set([...readTools(cfg), ...searchTools(cfg)]);
1040
+ if (!passive.has(proposed.tool)) return allow;
1041
+ const since = now - cfg.explore.windowMinutes * 6e4;
1042
+ let streak = 0;
1043
+ for (let i = history.length - 1; i >= 0; i--) {
1044
+ const r = history[i];
1045
+ if (r.ts < since || !passive.has(r.tool_name)) break;
1046
+ streak++;
1047
+ }
1048
+ if (streak < cfg.explore.warnAt) return allow;
1049
+ if (streak >= cfg.explore.blockAt) {
1050
+ return [
1051
+ {
1052
+ kind: "explore",
1053
+ severity: "block",
1054
+ tool: proposed.tool,
1055
+ message: `Exploration without implementation: ${streak} consecutive read/search calls with no action in the last ${cfg.explore.windowMinutes} minutes. You have gathered enough \u2014 pick the most valuable thing you learned and act on it (edit, run, or write). If nothing is actionable, summarize what you found and say so.`,
1056
+ evidence: `streak=${streak} blockAt=${cfg.explore.blockAt}`
1057
+ }
1058
+ ];
1059
+ }
1060
+ return [
1061
+ {
1062
+ kind: "explore",
1063
+ severity: "warn",
1064
+ tool: proposed.tool,
1065
+ message: `${streak} consecutive read/search calls \u2014 make sure the next step acts on what you already learned.`,
1066
+ evidence: `streak=${streak}`
1067
+ }
1068
+ ];
1069
+ }
1070
+ function fuzzyKey(tool, argsJson) {
1071
+ let text = argsJson;
1072
+ try {
1073
+ const obj = JSON.parse(argsJson);
1074
+ const parts = [];
1075
+ for (const v of Object.values(obj)) {
1076
+ if (typeof v === "string") parts.push(v);
1077
+ else if (v !== null && v !== void 0) parts.push(JSON.stringify(v));
1078
+ }
1079
+ if (parts.length > 0) text = parts.join("|");
1080
+ } catch {
1081
+ }
1082
+ return `${tool}:${text.toLowerCase().replace(/[^a-z0-9\u4e00-\u9fa5]/g, "")}`;
1083
+ }
1084
+ function analyzeNearRepeat(history, cfg, now = Date.now()) {
1085
+ if (!cfg.nearRepeat.enabled) return allow;
1086
+ const since = now - cfg.nearRepeat.windowMinutes * 6e4;
1087
+ const byKey = /* @__PURE__ */ new Map();
1088
+ for (const r of history) {
1089
+ if (r.ts < since) continue;
1090
+ const key = fuzzyKey(r.tool_name, r.args_json);
1091
+ const cur = byKey.get(key) ?? { n: 0, tool: r.tool_name };
1092
+ cur.n++;
1093
+ byKey.set(key, cur);
1094
+ }
1095
+ const findings = [];
1096
+ for (const [, v] of byKey) {
1097
+ if (v.n >= cfg.nearRepeat.blockAt) {
1098
+ findings.push({
1099
+ kind: "nearRepeat",
1100
+ severity: "block",
1101
+ tool: v.tool,
1102
+ message: `Near-duplicate loop: ${v.tool} has been called ${v.n} times with arguments that differ only trivially (punctuation, case, spacing, order). You are not trying anything new. Use the results already in context or change the approach substantially.`,
1103
+ evidence: `fuzzy_count=${v.n} blockAt=${cfg.nearRepeat.blockAt}`
1104
+ });
1105
+ } else if (v.n >= cfg.nearRepeat.warnAt) {
1106
+ findings.push({
1107
+ kind: "nearRepeat",
1108
+ severity: "warn",
1109
+ tool: v.tool,
1110
+ message: `${v.tool} has ${v.n} near-identical calls recently \u2014 verify these calls differ meaningfully.`,
1111
+ evidence: `fuzzy_count=${v.n}`
1112
+ });
1113
+ }
1114
+ }
1115
+ return findings.sort((a, b) => a.severity === b.severity ? 0 : a.severity === "block" ? -1 : 1).slice(0, 1);
1116
+ }
1117
+ function analyzeCall(history, proposed, cfg, now = Date.now()) {
1118
+ const findings = [
1119
+ ...analyzeRepetition(history, proposed, cfg, now),
1120
+ ...analyzeCycles(history, cfg, now),
1121
+ ...analyzeNoGain(history, cfg, now),
1122
+ ...analyzeNoGainFuzzy(history, proposed, cfg, now),
1123
+ ...analyzeChurn(history, cfg, now),
1124
+ ...analyzeNoProgress(history, proposed, cfg, now),
1125
+ ...analyzeNearRepeat(history, cfg, now),
1126
+ ...analyzeExplore(history, proposed, cfg, now)
1127
+ ];
1128
+ const rank = { block: 0, warn: 1 };
1129
+ findings.sort((a, b) => rank[a.severity] - rank[b.severity]);
1130
+ return { findings: findings.slice(0, 2) };
1131
+ }
1132
+
1133
+ // src/checkpoint.ts
1134
+ import fs5 from "fs";
1135
+ import path4 from "path";
1136
+ function argSummary(argsJson, max = 100) {
1137
+ try {
1138
+ const obj = JSON.parse(argsJson);
1139
+ const parts = [];
1140
+ for (const [k, v] of Object.entries(obj)) {
1141
+ const s = typeof v === "string" ? v : JSON.stringify(v);
1142
+ parts.push(`${k}=${s.length > 60 ? s.slice(0, 57) + "..." : s}`);
1143
+ }
1144
+ const joined = parts.join(", ");
1145
+ return joined.length > max ? joined.slice(0, max - 3) + "..." : joined || "{}";
1146
+ } catch {
1147
+ return argsJson.slice(0, max);
1148
+ }
1149
+ }
1150
+ function buildBrief(sessionId, now = Date.now(), windowMs = 6 * 36e5, cfg = loadConfig()) {
1151
+ const calls = callsSince(sessionId, now - windowMs, 1e3);
1152
+ if (calls.length === 0) return "";
1153
+ const shells = shellTools(cfg);
1154
+ const edits = editTools(cfg);
1155
+ const reads = readTools(cfg);
1156
+ const searchesSet = searchTools(cfg);
1157
+ const files = /* @__PURE__ */ new Map();
1158
+ const commands = [];
1159
+ const searches = [];
1160
+ const failures = [];
1161
+ for (const r of calls) {
1162
+ const summary = argSummary(r.args_json, 90);
1163
+ if (edits.has(r.tool_name) && r.file_path) {
1164
+ const f = files.get(r.file_path) ?? { reads: 0, edits: 0 };
1165
+ f.edits++;
1166
+ files.set(r.file_path, f);
1167
+ } else if (r.file_path && reads.has(r.tool_name)) {
1168
+ const f = files.get(r.file_path) ?? { reads: 0, edits: 0 };
1169
+ f.reads++;
1170
+ files.set(r.file_path, f);
1171
+ }
1172
+ if (shells.has(r.tool_name)) commands.push(summary);
1173
+ if (searchesSet.has(r.tool_name)) searches.push(summary);
1174
+ if (r.status === "failure") failures.push(`${r.tool_name}: ${summary}`);
1175
+ }
1176
+ const lines = [];
1177
+ lines.push("## Observed activity (auto-captured by agent-guard)");
1178
+ lines.push("");
1179
+ if (files.size > 0) {
1180
+ lines.push("### Files touched");
1181
+ for (const [f, c] of [...files.entries()].slice(0, 25)) {
1182
+ lines.push(`- ${f} (read \xD7${c.reads}, edited \xD7${c.edits})`);
1183
+ }
1184
+ lines.push("");
1185
+ }
1186
+ if (commands.length > 0) {
1187
+ lines.push("### Commands run (most recent last)");
1188
+ for (const c of commands.slice(-10)) lines.push(`- ${c}`);
1189
+ lines.push("");
1190
+ }
1191
+ if (searches.length > 0) {
1192
+ lines.push("### Searches performed (results are already known \u2014 do not redo them)");
1193
+ const seen = /* @__PURE__ */ new Set();
1194
+ for (const s of searches.slice(-15)) {
1195
+ if (seen.has(s)) continue;
1196
+ seen.add(s);
1197
+ lines.push(`- ${s}`);
1198
+ }
1199
+ lines.push("");
1200
+ }
1201
+ if (failures.length > 0) {
1202
+ lines.push("### Failed calls (avoid repeating these)");
1203
+ const seen = /* @__PURE__ */ new Set();
1204
+ for (const f of failures.slice(-8)) {
1205
+ if (seen.has(f)) continue;
1206
+ seen.add(f);
1207
+ lines.push(`- ${f}`);
1208
+ }
1209
+ lines.push("");
1210
+ }
1211
+ lines.push(`Total recorded tool calls in window: ${calls.length}`);
1212
+ return lines.join("\n");
1213
+ }
1214
+ function captureCheckpoint(sessionId, reason, now = Date.now(), cfg = loadConfig()) {
1215
+ const brief = buildBrief(sessionId, now, 6 * 36e5, cfg);
1216
+ if (!brief) return null;
1217
+ const dir = path4.join(guardHome(), "checkpoints", sessionId.replace(/[^\w.-]/g, "_"));
1218
+ fs5.mkdirSync(dir, { recursive: true });
1219
+ const file = path4.join(dir, `${now}-${reason.replace(/[^\w-]/g, "_")}.md`);
1220
+ const header = [
1221
+ `# agent-guard checkpoint`,
1222
+ ``,
1223
+ `- session: ${sessionId}`,
1224
+ `- time: ${new Date(now).toISOString()}`,
1225
+ `- reason: ${reason}`,
1226
+ ``
1227
+ ].join("\n");
1228
+ fs5.writeFileSync(file, header + brief + "\n", "utf8");
1229
+ recordEvent(sessionId, "checkpoint", { reason, file }, now);
1230
+ return { sessionId, path: file, brief, reason, ts: now };
1231
+ }
1232
+ function latestSessionId() {
1233
+ const sessions = knownSessions(1);
1234
+ return sessions[0]?.session_id ?? null;
1235
+ }
1236
+ function latestCheckpointFile(sessionId) {
1237
+ const base = path4.join(guardHome(), "checkpoints");
1238
+ if (!fs5.existsSync(base)) return null;
1239
+ let dir = sessionId ? path4.join(base, sessionId.replace(/[^\w.-]/g, "_")) : "";
1240
+ if (!dir || !fs5.existsSync(dir)) {
1241
+ const dirs = fs5.readdirSync(base).map((d) => ({ d, m: fs5.statSync(path4.join(base, d)).mtimeMs })).sort((a, b) => b.m - a.m);
1242
+ if (dirs.length === 0) return null;
1243
+ dir = path4.join(base, dirs[0].d);
1244
+ }
1245
+ const files = fs5.readdirSync(dir).filter((f) => f.endsWith(".md")).sort().reverse();
1246
+ return files[0] ? path4.join(dir, files[0]) : null;
1247
+ }
1248
+ function renderResumeBlock(brief, reason) {
1249
+ return [
1250
+ `<agent-guard-resume reason="${reason}">`,
1251
+ "You are resuming a task that was interrupted. Use the observed state below as verified",
1252
+ "prior knowledge. Do NOT re-explore files you have already read, do NOT redo searches",
1253
+ "listed here, and do NOT repeat failed calls. Continue from the last known state.",
1254
+ "",
1255
+ brief,
1256
+ "</agent-guard-resume>"
1257
+ ].join("\n");
1258
+ }
1259
+
1260
+ // src/verify.ts
1261
+ var DEFAULT_CLAIM_PATTERNS = [
1262
+ /\btests?\b.{0,40}\b(pass(?:ed|ing)?|green)\b/i,
1263
+ /\ball\b.{0,24}\btests?\b.{0,24}\bpass/i,
1264
+ /\bbuild\b.{0,30}\b(succeed(?:ed)?|passed|ok)\b/i,
1265
+ /\bcompil(?:es?|ed)\b.{0,20}\bsuccessfully\b/i,
1266
+ /\blint\b.{0,30}\b(clean|passed|no issues)\b/i,
1267
+ /\bfixed\b.{0,40}\b(all|every)\b/i,
1268
+ /测试(全部|都)?通过/,
1269
+ /全部(测试)?通过/,
1270
+ /构建成功/,
1271
+ /编译通过/,
1272
+ /零(错误|警告)/,
1273
+ /问题已全部解决/
1274
+ ];
1275
+ var DEFAULT_EVIDENCE_PATTERNS = [
1276
+ /\b(test|tests|vitest|jest|mocha|pytest|cargo test|go test|make test)\b/i,
1277
+ /\b(npm|pnpm|yarn)\s+(run\s+)?(test|check)\b/i,
1278
+ /\b(mvn|gradle|sbt|dotnet\s+test)\b/i,
1279
+ /\b(tsc|pyright|mypy|eslint|biome|ruff|flake8|clippy)\b/i,
1280
+ /\b(build|compile|lint|check|verify)\b/i,
1281
+ /\bmake\b/i
1282
+ ];
1283
+ function findClaims(text, cfg) {
1284
+ if (!text) return [];
1285
+ const patterns = cfg.verify.claimPatterns.length > 0 ? cfg.verify.claimPatterns.map((p) => new RegExp(p)) : DEFAULT_CLAIM_PATTERNS;
1286
+ const claims = [];
1287
+ for (const p of patterns) {
1288
+ const m = p.exec(text);
1289
+ if (m) {
1290
+ claims.push({
1291
+ pattern: String(p),
1292
+ snippet: text.slice(Math.max(0, (m.index ?? 0) - 40), (m.index ?? 0) + m[0].length + 40).replace(/\s+/g, " ").trim()
1293
+ });
1294
+ }
1295
+ if (claims.length >= 3) break;
1296
+ }
1297
+ return claims;
1298
+ }
1299
+ function hasEvidence(sessionId, cfg, now = Date.now()) {
1300
+ const vouched = getMeta(`vouched:${sessionId}`) === "1";
1301
+ if (vouched) return true;
1302
+ const since = now - cfg.verify.evidenceWindowMinutes * 6e4;
1303
+ const patterns = cfg.verify.evidencePatterns.length > 0 ? cfg.verify.evidencePatterns.map((p) => new RegExp(p)) : DEFAULT_EVIDENCE_PATTERNS;
1304
+ const shells = shellTools(cfg);
1305
+ const calls = callsSince(sessionId, since, 400);
1306
+ for (const r of calls) {
1307
+ if (r.status !== "ok") continue;
1308
+ if (!shells.has(r.tool_name)) continue;
1309
+ try {
1310
+ const args = JSON.parse(r.args_json);
1311
+ const cmd = args.command ?? "";
1312
+ for (const p of patterns) {
1313
+ if (p.test(cmd)) return true;
1314
+ }
1315
+ } catch {
1316
+ continue;
1317
+ }
1318
+ }
1319
+ return false;
1320
+ }
1321
+ function hasRecentEdits(sessionId, cfg, now = Date.now()) {
1322
+ const since = now - cfg.verify.evidenceWindowMinutes * 6e4;
1323
+ const edits = editTools(cfg);
1324
+ return callsSince(sessionId, since, 400).some(
1325
+ (r) => r.status === "ok" && edits.has(r.tool_name)
1326
+ );
1327
+ }
1328
+ var HOOKS_STOP_BLOCK_REASON = "[agent-guard] Blocked (verify): this turn ended after successful file edits with no successful verification command (test/build/lint) in the session. Run your verification before claiming completion, or state explicitly why it cannot run here.";
1329
+ var WIRE_VERIFY_CORRECTIVE = "[agent-guard verification] Your final message claims tests/build pass, but no successful verification command was recorded in this session. Actually run the verification now and base your claims on real results, then restate the conclusion.";
1330
+
1331
+ // src/veto.ts
1332
+ function vetoKeyConfigured(env = process.env) {
1333
+ return Boolean(env.KIMI_GUARD_VETO_API_KEY?.trim());
1334
+ }
1335
+ function vetoBaseUrls(cfg, env = process.env) {
1336
+ return {
1337
+ baseUrl: env.KIMI_GUARD_VETO_BASE_URL?.trim() || cfg.baseUrl,
1338
+ model: env.KIMI_GUARD_VETO_MODEL?.trim() || cfg.model
1339
+ };
1340
+ }
1341
+ function collectVetoContext(sessionId, cfg, now = Date.now()) {
1342
+ const since = now - cfg.verify.evidenceWindowMinutes * 6e4;
1343
+ const calls = callsSince(sessionId, since, 400);
1344
+ const shells = shellTools(cfg);
1345
+ const recentCommands = [];
1346
+ const editedFiles = [];
1347
+ for (const r of calls.slice(-40)) {
1348
+ if (shells.has(r.tool_name)) {
1349
+ try {
1350
+ const args = JSON.parse(r.args_json);
1351
+ if (args.command) recentCommands.push(args.command.slice(0, 120));
1352
+ } catch {
1353
+ }
1354
+ }
1355
+ if (r.file_path && editedFiles.length < 10) editedFiles.push(r.file_path);
1356
+ }
1357
+ return { sessionId, claims: [], goal: "", recentCommands: recentCommands.slice(-5), editedFiles };
1358
+ }
1359
+ var PROMPT_HEADER = "You are a false-positive detector for an AI-agent guardrail. An agent just finished its turn claiming completion, but the session's recorded command history contains NO successful verification command (test/build/lint). Decide whether blocking would be a FALSE POSITIVE \u2014 i.e. the agent has a legitimate reason why verification cannot run in this session.\nRules: base your vote ONLY on the facts below. A claim that verification is unnecessary or happens elsewhere is NOT by itself a reason to veto. Answer with EXACTLY one line:\nVETO: yes (false positive \u2014 allow the completion)\nVETO: no (block stands \u2014 the agent must actually run verification)\nDo not write anything else.\n\nFacts:\n";
1360
+ function buildVetoPrompt(ctx) {
1361
+ const lines = [];
1362
+ lines.push(`- user goal: ${ctx.goal.slice(0, 300) || "(unknown)"}`);
1363
+ lines.push(`- claims made by the agent:`);
1364
+ for (const c of ctx.claims.slice(0, 3)) lines.push(` "${c.snippet}"`);
1365
+ lines.push(`- recent commands the agent ran: ${ctx.recentCommands.length > 0 ? ctx.recentCommands.join(" ; ") : "(none)"}`);
1366
+ lines.push(`- files the agent edited: ${ctx.editedFiles.length > 0 ? ctx.editedFiles.join(", ") : "(none)"}`);
1367
+ lines.push("- recorded successful verification commands in session history: none");
1368
+ return PROMPT_HEADER + lines.join("\n");
1369
+ }
1370
+ async function castVetoVote(ctx, cfg, env = process.env) {
1371
+ if (!cfg.enabled || !vetoKeyConfigured(env)) return { vetoed: false, error: "veto disabled" };
1372
+ const calls = Number(getMeta(`veto_calls:${ctx.sessionId}`) ?? "0");
1373
+ if (calls >= cfg.maxCallsPerSession) return { vetoed: false, error: "session vote budget exhausted" };
1374
+ setMeta(`veto_calls:${ctx.sessionId}`, String(calls + 1));
1375
+ const { baseUrl, model } = vetoBaseUrls(cfg, env);
1376
+ const key = env.KIMI_GUARD_VETO_API_KEY.trim();
1377
+ try {
1378
+ const res = await fetch(`${baseUrl.replace(/\/$/, "")}/chat/completions`, {
1379
+ method: "POST",
1380
+ headers: {
1381
+ "Content-Type": "application/json",
1382
+ Authorization: `Bearer ${key}`
1383
+ },
1384
+ body: JSON.stringify({
1385
+ model,
1386
+ messages: [{ role: "user", content: buildVetoPrompt(ctx) }],
1387
+ max_tokens: 8,
1388
+ temperature: 0,
1389
+ stream: false
1390
+ }),
1391
+ signal: AbortSignal.timeout(cfg.timeoutMs)
1392
+ });
1393
+ if (!res.ok) return { vetoed: false, error: `http ${res.status}` };
1394
+ const data = await res.json();
1395
+ const raw = (data.choices?.[0]?.message?.content ?? "").trim();
1396
+ return { vetoed: /^VETO:\s*yes\b/i.test(raw), raw };
1397
+ } catch (err) {
1398
+ return { vetoed: false, error: err.message };
1399
+ }
1400
+ }
1401
+
1402
+ export {
1403
+ guardHome,
1404
+ claudeSettingsPath,
1405
+ claudeDetected,
1406
+ codexHooksPath,
1407
+ codexDetected,
1408
+ stateDbPath,
1409
+ probeLogPath,
1410
+ userConfigPath,
1411
+ detectKimiConfig,
1412
+ loadConfig,
1413
+ writeConfigTemplate,
1414
+ installClaudeHooks,
1415
+ uninstallClaudeHooks,
1416
+ claudeHooksInstalled,
1417
+ openDb,
1418
+ recordCall,
1419
+ callsSince,
1420
+ recordEvent,
1421
+ countEvents,
1422
+ oldestEventTs,
1423
+ recordBlock,
1424
+ listBlocks,
1425
+ setBlockFeedback,
1426
+ blockKindStats,
1427
+ countBlocks,
1428
+ getMeta,
1429
+ setMeta,
1430
+ knownSessions,
1431
+ buildStatus,
1432
+ fingerprint,
1433
+ hashOutput,
1434
+ outputSampleOf,
1435
+ extractFile,
1436
+ normalizeCall,
1437
+ analyzeCall,
1438
+ captureCheckpoint,
1439
+ latestSessionId,
1440
+ latestCheckpointFile,
1441
+ renderResumeBlock,
1442
+ findClaims,
1443
+ hasEvidence,
1444
+ hasRecentEdits,
1445
+ HOOKS_STOP_BLOCK_REASON,
1446
+ WIRE_VERIFY_CORRECTIVE,
1447
+ vetoKeyConfigured,
1448
+ collectVetoContext,
1449
+ castVetoVote
1450
+ };