agy-context-saver 0.0.0-stage → 1.2.1

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,575 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Agy-Context-Saver: Intelligent Execution Governor Hook for Antigravity
5
+ *
6
+ * Closed Execution Topology & Governance Invariants:
7
+ * 1. RTK Transparent Command Optimization:
8
+ * - Offloads 100% of command output reduction to RTK (Rust Token Killer).
9
+ * - Intercepts native run_command and calls `rtk rewrite <cmd>`.
10
+ * - Non-daemon commands maintain WaitMsBeforeAsync=10000 for synchronous completion.
11
+ * 2. Hard-Routed Native File/Search Inspection (LH-07):
12
+ * - view_file, read_file, read_many_files -> Deny and route to `rtk read <file>`.
13
+ * - grep_search -> Deny and route to `rtk grep <pattern>`.
14
+ * - find_by_name -> Deny and route to `rtk find <path>`.
15
+ * - list_dir -> Deny and route to `rtk ls` or `rtk tree`.
16
+ * - Safe Harbor: Documented Antigravity special files (SKILL.md, brain artifacts, configs) are allowed.
17
+ * 3. Root-Based Antigravity Internal State Protection (LH-02):
18
+ * - Hard denial for any inspection of `.system_generated/` (transcripts, task logs, progress).
19
+ * - Directed to Agy MCP tools `read_transcript` / `query_transcript`.
20
+ * 4. Closed Lifecycle Governance (LH-03, LH-04, LH-05, LH-06):
21
+ * - Session-wide ledger without cooldown resets, task-ID resets, or duration exemptions.
22
+ * - Enforces native Reactive Wakeup (<SYSTEM_MESSAGE>) for background tasks.
23
+ * - Circuit Breaker: 5 cumulative denials trigger `force_ask` to halt runaway autonomous loops.
24
+ * 5. Benchmark & Evaluator Mutation Protection (LH-09):
25
+ * - Requires user approval before mutating benchmark definitions, evaluators, or scoring files.
26
+ * 6. Fail-Closed on Governed Operations (LH-10):
27
+ * - Governed tool failures deny instead of silently allowing.
28
+ */
29
+
30
+ import fs from "node:fs";
31
+ import os from "node:os";
32
+ import path from "node:path";
33
+ import process from "node:process";
34
+ import { spawnSync } from "node:child_process";
35
+
36
+ function respond(obj) {
37
+ process.stdout.write(JSON.stringify(obj) + "\n");
38
+ process.exit(0);
39
+ }
40
+
41
+ const GOVERNED_TOOLS = new Set([
42
+ "manage_task",
43
+ "schedule",
44
+ "run_command",
45
+ "view_file",
46
+ "read_file",
47
+ "read_many_files",
48
+ "grep_search",
49
+ "find_by_name",
50
+ "list_dir",
51
+ "replace_file_content",
52
+ "write_to_file"
53
+ ]);
54
+
55
+ const STATE_TTL_MS = 3600000; // 1 hour TTL
56
+ const MAX_STATE_ENTRIES = 50;
57
+
58
+ function getStateFilePath(convId = "global") {
59
+ if (!convId || convId === "global") {
60
+ return path.join(os.tmpdir(), "agy-session-governance-state.json");
61
+ }
62
+ const safeId = String(convId).replace(/[^a-zA-Z0-9_-]/g, "_");
63
+ return path.join(os.tmpdir(), `agy-session-${safeId}.json`);
64
+ }
65
+
66
+ function getSessionState(convId = "global") {
67
+ const file = getStateFilePath(convId);
68
+ try {
69
+ if (fs.existsSync(file)) {
70
+ const parsed = JSON.parse(fs.readFileSync(file, "utf-8"));
71
+ const entry = (parsed && typeof parsed.pollCount === "number") ? parsed : (parsed && parsed[convId]);
72
+ const now = Date.now();
73
+ if (entry && typeof entry.lastPollTime === "number" && (now - entry.lastPollTime) < STATE_TTL_MS) {
74
+ return entry;
75
+ }
76
+ }
77
+ } catch {}
78
+ return { pollCount: 0, denials: 0, taskIds: [], lastPollTime: 0 };
79
+ }
80
+
81
+ function saveSessionState(convId = "global", entry) {
82
+ const file = getStateFilePath(convId);
83
+ try {
84
+ const now = Date.now();
85
+ entry.lastPollTime = now;
86
+ let toWrite;
87
+ if (!convId || convId === "global") {
88
+ let state = {};
89
+ if (fs.existsSync(file)) {
90
+ try {
91
+ const parsed = JSON.parse(fs.readFileSync(file, "utf-8"));
92
+ if (parsed && typeof parsed === "object" && typeof parsed.pollCount !== "number") {
93
+ state = parsed;
94
+ }
95
+ } catch {}
96
+ }
97
+ state[convId] = entry;
98
+
99
+ // Prune stale entries
100
+ const entries = Object.entries(state)
101
+ .filter(([_, v]) => v && typeof v.lastPollTime === "number" && (now - v.lastPollTime) < STATE_TTL_MS)
102
+ .sort((a, b) => (b[1].lastPollTime || 0) - (a[1].lastPollTime || 0))
103
+ .slice(0, MAX_STATE_ENTRIES);
104
+
105
+ toWrite = JSON.stringify(Object.fromEntries(entries));
106
+ } else {
107
+ toWrite = JSON.stringify(entry);
108
+ }
109
+
110
+ const tmpFile = `${file}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
111
+ fs.writeFileSync(tmpFile, toWrite, "utf-8");
112
+ try {
113
+ fs.renameSync(tmpFile, file);
114
+ } catch {
115
+ fs.writeFileSync(file, toWrite, "utf-8");
116
+ try { fs.unlinkSync(tmpFile); } catch {}
117
+ }
118
+ } catch {}
119
+ }
120
+
121
+ /**
122
+ * Checks if target path references Antigravity internal state by root directory.
123
+ */
124
+ function isProtectedInternalState(targetPath) {
125
+ if (!targetPath || typeof targetPath !== "string") return false;
126
+ const normalized = targetPath.replace(/\\/g, "/");
127
+ return (
128
+ normalized.includes("/.system_generated/") ||
129
+ normalized.endsWith("/.system_generated") ||
130
+ /\.system_generated(?:$|[\/\\])/i.test(normalized)
131
+ );
132
+ }
133
+
134
+ /**
135
+ * Checks if target file is a documented Antigravity special file (skills, brain artifacts, configs).
136
+ */
137
+ function isAntigravitySpecialFile(targetPath) {
138
+ if (!targetPath || typeof targetPath !== "string") return false;
139
+ const normalized = targetPath.replace(/\\/g, "/");
140
+
141
+ // If inside .system_generated, it's ALWAYS protected internal state, never a special file
142
+ if (isProtectedInternalState(normalized)) {
143
+ return false;
144
+ }
145
+
146
+ // Antigravity skill definitions
147
+ if (normalized.endsWith("/SKILL.md") || normalized === "SKILL.md" || normalized.includes("/skills/")) {
148
+ return true;
149
+ }
150
+
151
+ // Antigravity brain markdown artifacts or scratch scripts (outside .system_generated)
152
+ if (normalized.includes("/.gemini/antigravity/brain/")) {
153
+ return true;
154
+ }
155
+
156
+ // Gemini / Antigravity plugin, MCP, or configuration files
157
+ if (normalized.includes("/.gemini/config/") || normalized.includes("/.gemini/antigravity/mcp/")) {
158
+ return true;
159
+ }
160
+
161
+ // Governance rulebooks
162
+ if (
163
+ normalized.endsWith("/AGENTS.md") ||
164
+ normalized === "AGENTS.md" ||
165
+ normalized.endsWith("/CLAUDE.md") ||
166
+ normalized === "CLAUDE.md" ||
167
+ normalized.includes("/.agents/rules/")
168
+ ) {
169
+ return true;
170
+ }
171
+
172
+ return false;
173
+ }
174
+
175
+ /**
176
+ * Checks if a target path is a protected benchmark or evaluation artifact.
177
+ */
178
+ function isProtectedBenchmarkPath(targetPath) {
179
+ if (!targetPath || typeof targetPath !== "string") return false;
180
+ const normalized = targetPath.replace(/\\/g, "/");
181
+ return (
182
+ /\/(?:benchmarks?|evaluators?|evaluation|scoring|ground_truth|prompt_templates)\//i.test(normalized) ||
183
+ /\/(?:eval_[^\/]+\.(?:py|json|toml)|bench_[^\/]+\.(?:py|json|toml))$/i.test(normalized)
184
+ );
185
+ }
186
+
187
+ let resolvedRtkPath = null;
188
+ function getRtkBinary() {
189
+ if (resolvedRtkPath) return resolvedRtkPath;
190
+
191
+ // 1. Try global rtk on PATH
192
+ try {
193
+ const check = spawnSync("rtk", ["--version"], { timeout: 1000, windowsHide: true });
194
+ if (check.status === 0) {
195
+ resolvedRtkPath = "rtk";
196
+ return resolvedRtkPath;
197
+ }
198
+ } catch {}
199
+
200
+ // 2. Try plugin local bin
201
+ const isWin = os.platform() === "win32";
202
+ const exeName = isWin ? "rtk.exe" : "rtk";
203
+ const homeDir = os.homedir();
204
+ const pluginBin = path.join(homeDir, ".gemini", "config", "plugins", "agy-context-saver", "bin", exeName);
205
+ if (fs.existsSync(pluginBin)) {
206
+ resolvedRtkPath = pluginBin;
207
+ return resolvedRtkPath;
208
+ }
209
+
210
+ resolvedRtkPath = "rtk";
211
+ return resolvedRtkPath;
212
+ }
213
+
214
+ const NON_REWRITABLE_BINARIES = new Set([
215
+ "node", "node.exe",
216
+ "powershell", "powershell.exe",
217
+ "pwsh", "pwsh.exe",
218
+ "cmd", "cmd.exe",
219
+ "dir",
220
+ "echo",
221
+ "cd",
222
+ "del",
223
+ "mkdir",
224
+ "rmdir",
225
+ "copy",
226
+ "move",
227
+ "type",
228
+ "cls",
229
+ "clear",
230
+ "set",
231
+ "export"
232
+ ]);
233
+
234
+ const REWRITE_CACHE_FILE = path.join(os.tmpdir(), "agy-rtk-rewrite-cache.json");
235
+ const REWRITE_CACHE_MAX = 100;
236
+ let inMemoryRewriteCache = null;
237
+
238
+ function loadRewriteCache() {
239
+ if (inMemoryRewriteCache) return inMemoryRewriteCache;
240
+ try {
241
+ if (fs.existsSync(REWRITE_CACHE_FILE)) {
242
+ const data = JSON.parse(fs.readFileSync(REWRITE_CACHE_FILE, "utf-8"));
243
+ if (data && typeof data === "object") {
244
+ inMemoryRewriteCache = data;
245
+ return inMemoryRewriteCache;
246
+ }
247
+ }
248
+ } catch {}
249
+ inMemoryRewriteCache = {};
250
+ return inMemoryRewriteCache;
251
+ }
252
+
253
+ function saveRewriteCache(cache) {
254
+ try {
255
+ const keys = Object.keys(cache);
256
+ let toSave = cache;
257
+ if (keys.length > REWRITE_CACHE_MAX) {
258
+ const slice = keys.slice(keys.length - REWRITE_CACHE_MAX);
259
+ toSave = {};
260
+ for (const k of slice) toSave[k] = cache[k];
261
+ }
262
+ fs.writeFileSync(REWRITE_CACHE_FILE, JSON.stringify(toSave), "utf-8");
263
+ } catch {}
264
+ }
265
+
266
+ /**
267
+ * Rewrite raw shell command using RTK (Rust Token Killer).
268
+ */
269
+ function rewriteCommandWithRtk(rawCmd) {
270
+ if (!rawCmd || typeof rawCmd !== "string") return rawCmd;
271
+ const trimmed = rawCmd.trim();
272
+ if (!trimmed) return rawCmd;
273
+
274
+ // Already prefixed with rtk
275
+ if (/^rtk(?:\.exe)?\s+/i.test(trimmed)) {
276
+ return trimmed;
277
+ }
278
+
279
+ // Fast-path bypass for shell builtins, non-rewritable binaries, and powershell cmdlets
280
+ const firstWord = (trimmed.match(/^[^\s"']+/)?.[0] || "").toLowerCase();
281
+ if (
282
+ NON_REWRITABLE_BINARIES.has(firstWord) ||
283
+ firstWord.startsWith("get-") ||
284
+ firstWord.startsWith("set-") ||
285
+ firstWord.startsWith("start-") ||
286
+ firstWord.startsWith("stop-") ||
287
+ firstWord.startsWith("new-") ||
288
+ firstWord.startsWith("remove-") ||
289
+ firstWord.startsWith("test-")
290
+ ) {
291
+ return trimmed;
292
+ }
293
+
294
+ // Check persistent cache
295
+ const cache = loadRewriteCache();
296
+ if (cache[trimmed] !== undefined) {
297
+ return cache[trimmed];
298
+ }
299
+
300
+ let rewritten = trimmed;
301
+ try {
302
+ const rtkBin = getRtkBinary();
303
+ const res = spawnSync(rtkBin, ["rewrite", trimmed], {
304
+ encoding: "utf-8",
305
+ timeout: 2000,
306
+ windowsHide: true
307
+ });
308
+
309
+ // RTK exits 0 or 3 when rewritten, with new command on stdout
310
+ if ((res.status === 0 || res.status === 3) && res.stdout && res.stdout.trim()) {
311
+ rewritten = res.stdout.trim();
312
+ }
313
+ } catch {}
314
+
315
+ cache[trimmed] = rewritten;
316
+ saveRewriteCache(cache);
317
+ return rewritten;
318
+ }
319
+
320
+ // --- Main Hook Entrypoint ---
321
+ let isGovernedTool = false;
322
+
323
+ try {
324
+ const inputRaw = fs.readFileSync(0, "utf-8");
325
+ if (!inputRaw || !inputRaw.trim()) {
326
+ respond({ decision: "allow" });
327
+ }
328
+
329
+ // Fast-path string scan (< 1ms)
330
+ let foundGoverned = false;
331
+ for (const name of GOVERNED_TOOLS) {
332
+ if (inputRaw.includes(name)) {
333
+ foundGoverned = true;
334
+ break;
335
+ }
336
+ }
337
+
338
+ if (!foundGoverned) {
339
+ respond({ decision: "allow" });
340
+ }
341
+
342
+ isGovernedTool = true;
343
+ const payload = JSON.parse(inputRaw);
344
+ const toolCall = payload.toolCall || payload.tool_call || {};
345
+ const toolName = String(toolCall.name || toolCall.tool_name || "").trim().toLowerCase();
346
+ const args = toolCall.args || toolCall.arguments || {};
347
+ const convId = payload.conversationId || "global";
348
+
349
+ if (!GOVERNED_TOOLS.has(toolName)) {
350
+ respond({ decision: "allow" });
351
+ }
352
+
353
+ isGovernedTool = true;
354
+
355
+ // =========================================================================
356
+ // 1. run_command: Transparent RTK Command Optimization & Synchronous Wait
357
+ // =========================================================================
358
+ if (toolName === "run_command") {
359
+ const rawCmd = args.CommandLine !== undefined ? args.CommandLine : args.command;
360
+ if (typeof rawCmd !== "string" || !rawCmd.trim()) {
361
+ respond({
362
+ decision: "deny",
363
+ reason: "Antigravity Governance: Missing or empty CommandLine in run_command."
364
+ });
365
+ }
366
+
367
+ const rewritten = rewriteCommandWithRtk(rawCmd);
368
+ const isDaemon = args.IsDaemon === true || args.isDaemon === true;
369
+ const waitMs = args.WaitMsBeforeAsync !== undefined ? args.WaitMsBeforeAsync : args.waitMsBeforeAsync;
370
+ const targetMaxWait = Number(process.env.AGY_MAX_WAIT_MS) || 10000;
371
+
372
+ const overwrite = {};
373
+ if (rewritten !== rawCmd) {
374
+ overwrite.CommandLine = rewritten;
375
+ }
376
+ if (!isDaemon && (waitMs === undefined || waitMs < targetMaxWait)) {
377
+ overwrite.WaitMsBeforeAsync = targetMaxWait;
378
+ }
379
+
380
+ if (Object.keys(overwrite).length > 0) {
381
+ respond({ decision: "allow", overwrite });
382
+ }
383
+ respond({ decision: "allow" });
384
+ }
385
+
386
+ // =========================================================================
387
+ // 2. Native File & Inspection Tools: Root Protection & RTK Hard-Routing
388
+ // =========================================================================
389
+ const INSPECTION_TOOLS = new Set([
390
+ "view_file",
391
+ "read_file",
392
+ "read_many_files",
393
+ "grep_search",
394
+ "find_by_name",
395
+ "list_dir"
396
+ ]);
397
+
398
+ if (INSPECTION_TOOLS.has(toolName)) {
399
+ const rawTarget =
400
+ args.AbsolutePath ||
401
+ args.absolutePath ||
402
+ args.targetFile ||
403
+ args.target_file ||
404
+ args.path ||
405
+ args.dirPath ||
406
+ args.dir_path ||
407
+ args.directory ||
408
+ "";
409
+ const target = String(rawTarget).trim();
410
+
411
+ // 2a. Internal State Protection by Root (LH-02)
412
+ if (isProtectedInternalState(target) || isProtectedInternalState(JSON.stringify(args))) {
413
+ respond({
414
+ decision: "deny",
415
+ reason:
416
+ "Antigravity Execution Governance: Direct access to internal Antigravity execution state (.system_generated) is strictly prohibited to prevent transcript bloat and polling loops.\n" +
417
+ "- To inspect conversation transcripts: Call MCP tool read_transcript(conversationId=\"...\", mode=\"compact\") or query_transcript(...).\n" +
418
+ "- To inspect background tasks: Yield execution turn and rely on native Reactive Wakeup (<SYSTEM_MESSAGE>). Do not read internal task logs."
419
+ });
420
+ }
421
+
422
+ // 2b. Allow Antigravity Special Files (SKILL.md, brain artifacts, configs)
423
+ if (isAntigravitySpecialFile(target)) {
424
+ respond({ decision: "allow" });
425
+ }
426
+
427
+ // 2c. Hard-Route Workspace Code/Data Inspection to Canonical RTK Commands (LH-07)
428
+ if (toolName === "view_file" || toolName === "read_file") {
429
+ respond({
430
+ decision: "deny",
431
+ reason: `Antigravity Governance: Native file inspection via '${toolName}' bypasses token optimization. Run 'rtk read ${target || "<file>"}' via run_command instead.`
432
+ });
433
+ }
434
+
435
+ if (toolName === "read_many_files") {
436
+ respond({
437
+ decision: "deny",
438
+ reason: "Antigravity Governance: Native multi-file reading via 'read_many_files' bypasses token optimization. Run 'rtk read <file>' via run_command instead."
439
+ });
440
+ }
441
+
442
+ if (toolName === "grep_search") {
443
+ const pattern = args.query || args.pattern || "";
444
+ const isWin = os.platform() === "win32";
445
+ const cmdHint = isWin ? `rtk rg "${pattern}" .` : `rtk grep "${pattern}"`;
446
+ respond({
447
+ decision: "deny",
448
+ reason: `Antigravity Governance: Native grep inspection via 'grep_search' bypasses token optimization. Run '${cmdHint}' via run_command instead.`
449
+ });
450
+ }
451
+
452
+ if (toolName === "find_by_name") {
453
+ const searchTarget = args.query || args.pattern || target || ".";
454
+ respond({
455
+ decision: "deny",
456
+ reason: `Antigravity Governance: Native file search via 'find_by_name' bypasses token optimization. Run 'rtk find ${searchTarget}' via run_command instead.`
457
+ });
458
+ }
459
+
460
+ if (toolName === "list_dir") {
461
+ const isWin = os.platform() === "win32";
462
+ const cmdHint = isWin ? `rtk find ${target || "."}` : `rtk ls ${target || ""}`;
463
+ respond({
464
+ decision: "deny",
465
+ reason: `Antigravity Governance: Native directory listing via 'list_dir' bypasses token optimization. Run '${cmdHint}' via run_command instead.`
466
+ });
467
+ }
468
+
469
+ respond({ decision: "allow" });
470
+ }
471
+
472
+ // =========================================================================
473
+ // 3. Background Task Lifecycle: Closed Session Ledger & Reactive Wakeup
474
+ // =========================================================================
475
+ if (toolName === "manage_task") {
476
+ const action = String(args.Action || args.action || "").replace(/^["']|["']$/g, "").trim().toLowerCase();
477
+
478
+ // kill, send_input are always allowed immediately
479
+ if (action !== "status" && action !== "list") {
480
+ respond({ decision: "allow" });
481
+ }
482
+
483
+ const session = getSessionState(convId);
484
+ const taskId = String(args.TaskId || args.taskId || "").trim();
485
+ if (taskId && !session.taskIds.includes(taskId)) {
486
+ session.taskIds.push(taskId);
487
+ }
488
+
489
+ // First inspection check in the session is permitted
490
+ if (session.pollCount === 0) {
491
+ session.pollCount = 1;
492
+ saveSessionState(convId, session);
493
+ respond({ decision: "allow" });
494
+ }
495
+
496
+ // Any subsequent polling checks in the session are strictly denied
497
+ session.denials++;
498
+ saveSessionState(convId, session);
499
+
500
+ if (session.denials >= 5) {
501
+ respond({
502
+ decision: "force_ask",
503
+ reason: `[CIRCUIT BREAKER ACTIVATED] Autonomous loop suspended: manage_task('${action}') has been denied ${session.denials} times across tasks [${session.taskIds.join(", ")}]. Stop calling tools and yield the turn for native Reactive Wakeup (<SYSTEM_MESSAGE>). User confirmation required to proceed.`
504
+ });
505
+ }
506
+
507
+ respond({
508
+ decision: "deny",
509
+ reason: `Antigravity Execution Governance: Background task polling is prohibited (${session.denials} denial). Tasks execute asynchronously and notify you automatically via native Reactive Wakeup (<SYSTEM_MESSAGE>) upon completion. Stop calling tools and yield your turn now.`
510
+ });
511
+ }
512
+
513
+ if (toolName === "schedule") {
514
+ const prompt = String(args.Prompt || args.prompt || "").replace(/^["']|["']$/g, "").trim().toLowerCase();
515
+ const cond = String(args.TimerCondition || args.timerCondition || "").replace(/^["']|["']$/g, "").trim().toLowerCase();
516
+
517
+ const isTaskPoll =
518
+ cond.startsWith("task-") ||
519
+ cond.includes("task") ||
520
+ prompt.includes("check on") ||
521
+ prompt.includes("check task") ||
522
+ prompt.includes("background task") ||
523
+ prompt.includes("still running") ||
524
+ prompt.includes("test suite") ||
525
+ ((prompt.includes("check") || prompt.includes("status") || prompt.includes("poll") || prompt.includes("monitor") || prompt.includes("wait")) &&
526
+ (prompt.includes("task") || prompt.includes("process") || prompt.includes("job") || prompt.includes("test") || prompt.includes("command") || prompt.includes("suite")));
527
+
528
+ // All polling timers are strictly denied regardless of duration (LH-03)
529
+ if (isTaskPoll) {
530
+ const session = getSessionState(convId);
531
+ session.denials++;
532
+ saveSessionState(convId, session);
533
+
534
+ if (session.denials >= 5) {
535
+ respond({
536
+ decision: "force_ask",
537
+ reason: `[CIRCUIT BREAKER ACTIVATED] Autonomous loop suspended: schedule polling timer has been denied ${session.denials} times. Background tasks notify you automatically upon completion. User confirmation required to proceed.`
538
+ });
539
+ }
540
+
541
+ respond({
542
+ decision: "deny",
543
+ reason: "Antigravity Execution Governance: Scheduling timers to poll or monitor background tasks is prohibited regardless of duration. Background tasks notify you automatically via native Reactive Wakeup (<SYSTEM_MESSAGE>). Stop calling tools and yield the turn instead."
544
+ });
545
+ }
546
+
547
+ // Non-polling timers (e.g. user-requested reminders) are allowed
548
+ respond({ decision: "allow" });
549
+ }
550
+
551
+ // =========================================================================
552
+ // 4. Benchmark & Evaluator Mutation Protection (LH-09)
553
+ // =========================================================================
554
+ if (toolName === "replace_file_content" || toolName === "write_to_file") {
555
+ const targetFile = String(args.TargetFile || args.targetFile || args.path || "").trim();
556
+ if (isProtectedBenchmarkPath(targetFile)) {
557
+ respond({
558
+ decision: "force_ask",
559
+ reason: `[BENCHMARK MUTATION DETECTED] Mutation of protected evaluator/benchmark artifact '${targetFile}' requires explicit user confirmation to prevent benchmark contamination.`
560
+ });
561
+ }
562
+ respond({ decision: "allow" });
563
+ }
564
+
565
+ respond({ decision: "allow" });
566
+ } catch (err) {
567
+ // Fail-closed for governed tools, fail-open for unclassified non-governed tools (LH-10)
568
+ if (isGovernedTool) {
569
+ respond({
570
+ decision: "deny",
571
+ reason: `Antigravity Governance: Exception encountered while evaluating governed tool call: ${err.message}. Denied for session integrity.`
572
+ });
573
+ }
574
+ respond({ decision: "allow" });
575
+ }