agy-context-saver 1.8.1 → 2.0.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.
@@ -1,31 +1,31 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  /**
4
- * Agy-Context-Saver: Intelligent Execution Governor Hook for Antigravity
4
+ * Agy-Context-Saver: Intelligent Execution Governor Hook for Antigravity (v2.0.0)
5
5
  *
6
6
  * Closed Execution Topology & Governance Invariants:
7
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.
8
+ * - Delegates command rewriting to `rtk hook antigravity` (>= 1.1.0) via stdin JSON.
9
+ * - Reverts lossy `rtk wc` / `rtk ls` / `rtk diff` rewrites unless explicitly requested.
10
+ * - Never emits `rtk rg` when `rg` is absent on PATH (maps safe invocations to `rtk grep`
11
+ * with explicit path, or strips `rtk ` when rg-only flags are used).
12
+ * - Sets WaitMsBeforeAsync=10000 only when `!isDaemon && waitMs === undefined`.
11
13
  * 2. Hard-Routed Native File/Search Inspection (LH-07):
12
- * - view_file -> Allow bounded slices (<= 150 lines, paired with waymark_ask); deny unbounded dumps.
13
- * - read_file, read_many_files -> Deny and route to bounded view_file or `rtk read <file>`.
14
- * - grep_search -> Deny and route to `rtk rg "<pattern>" .` / `rtk grep <pattern>`.
15
- * - find_by_name -> Deny and route to `rtk find <path>`.
16
- * - list_dir -> Deny and route to `rtk find` (Win) or `rtk ls` / `rtk tree` (POSIX).
17
- * - Safe Harbor: Documented Antigravity special files (SKILL.md, brain artifacts, configs) are allowed.
14
+ * - read_file & view_file -> Symmetrical Safe Harbor + Hybrid Slice (<= 150 lines, or
15
+ * explicit full-file StartLine=1..EndLine=TotalLines when <= 800 lines & <= 45 KiB);
16
+ * bounded read_file (<= 150 lines) allowed on `.system_generated/steps/<n>/output.txt`.
17
+ * - read_many_files, grep_search, find_by_name, list_dir -> Hard-routed to RTK CLI.
18
18
  * 3. Root-Based Antigravity Internal State Protection (LH-02):
19
- * - Hard denial for any inspection of `.system_generated/` (transcripts, task logs, progress).
20
- * - Directed to Agy MCP tools `read_transcript` / `query_transcript`.
19
+ * - Quote/heredoc-aware shell parser (`splitShellSegments`) protects `.system_generated/`
20
+ * without false-blocking heredoc prose, `echo`/`printf`, or literal search patterns.
21
+ * - Allows strictly single unchained `rtk read <spillover|completed task log>` and
22
+ * `agy-context transcript (read|query|step|health) ...` within `<brainDir>`.
21
23
  * 4. Closed Lifecycle Governance (LH-03, LH-04, LH-05, LH-06):
22
- * - Session-wide ledger without cooldown resets, task-ID resets, or duration exemptions.
23
- * - Enforces native Reactive Wakeup (<SYSTEM_MESSAGE>) for background tasks.
24
- * - Circuit Breaker: 5 cumulative denials trigger `force_ask` to halt runaway autonomous loops.
24
+ * - Session-wide ledger with proportional backoff and 5-denial `force_ask` circuit breaker.
25
25
  * 5. Benchmark & Evaluator Mutation Protection (LH-09):
26
- * - Requires user approval before mutating benchmark definitions, evaluators, or scoring files.
27
- * 6. Fail-Closed on Governed Operations (LH-10):
28
- * - Governed tool failures deny instead of silently allowing.
26
+ * - Requires user approval (`force_ask`) for `edit_file`, `write_file`,
27
+ * `replace_file_content`, `write_to_file`, and destructive shell commands.
28
+ * 6. Fail-Closed on Governed Operations (LH-10).
29
29
  */
30
30
 
31
31
  import fs from "node:fs";
@@ -35,7 +35,38 @@ import process from "node:process";
35
35
  import { spawnSync } from "node:child_process";
36
36
  import { fileURLToPath } from "node:url";
37
37
 
38
+ const HOOK_DEDUP_TTL_MS = 5000;
39
+ const HOOK_SAME_ARG_CROSS_SCRIPT_MS = 500;
40
+ let dedupRecordCtx = null;
41
+
38
42
  function respond(obj) {
43
+ if (dedupRecordCtx && dedupRecordCtx.convId) {
44
+ try {
45
+ const latest = getSessionState(dedupRecordCtx.convId);
46
+ const now = Date.now();
47
+ let outSig = dedupRecordCtx.rawSig;
48
+ if (obj && obj.overwrite && typeof obj.overwrite === "object") {
49
+ try {
50
+ const parsedRaw = JSON.parse(dedupRecordCtx.rawSig);
51
+ outSig = JSON.stringify({ ...parsedRaw, ...obj.overwrite });
52
+ } catch {}
53
+ }
54
+ const prevList = Array.isArray(latest.recentHookCalls) ? latest.recentHookCalls : [];
55
+ const freshList = prevList.filter((e) => e && typeof e.ts === "number" && (now - e.ts) < HOOK_DEDUP_TTL_MS);
56
+ freshList.push({
57
+ ts: now,
58
+ callId: dedupRecordCtx.callId || "",
59
+ stepIdx: dedupRecordCtx.stepIdx !== undefined ? dedupRecordCtx.stepIdx : null,
60
+ toolName: dedupRecordCtx.toolName,
61
+ scriptPath: dedupRecordCtx.scriptPath,
62
+ rawSig: dedupRecordCtx.rawSig,
63
+ outSig,
64
+ response: obj
65
+ });
66
+ latest.recentHookCalls = freshList.slice(-8);
67
+ saveSessionState(dedupRecordCtx.convId, latest, { updatePollTime: false });
68
+ } catch {}
69
+ }
39
70
  process.stdout.write(JSON.stringify(obj) + "\n");
40
71
  process.exit(0);
41
72
  }
@@ -51,7 +82,9 @@ const GOVERNED_TOOLS = new Set([
51
82
  "find_by_name",
52
83
  "list_dir",
53
84
  "replace_file_content",
54
- "write_to_file"
85
+ "write_to_file",
86
+ "write_file",
87
+ "edit_file"
55
88
  ]);
56
89
 
57
90
  const STATE_TTL_MS = 3600000; // 1 hour TTL
@@ -91,19 +124,23 @@ function getSessionState(convId = "global") {
91
124
  const parsed = JSON.parse(fs.readFileSync(file, "utf-8"));
92
125
  const entry = (parsed && typeof parsed.pollCount === "number") ? parsed : (parsed && parsed[convId]);
93
126
  const now = Date.now();
94
- if (entry && typeof entry.lastPollTime === "number" && (now - entry.lastPollTime) < STATE_TTL_MS) {
127
+ const activityTime = Math.max(entry?.lastPollTime || 0, entry?.lastSeenTime || 0);
128
+ if (entry && activityTime > 0 && (now - activityTime) < STATE_TTL_MS) {
95
129
  return entry;
96
130
  }
97
131
  } catch {}
98
- return { pollCount: 0, denials: 0, taskIds: [], lastPollTime: 0 };
132
+ return { pollCount: 0, denials: 0, taskIds: [], lastPollTime: 0, lastSeenTime: 0, recentHookCalls: [] };
99
133
  }
100
134
 
101
- function saveSessionState(convId = "global", entry) {
135
+ function saveSessionState(convId = "global", entry, { updatePollTime = true } = {}) {
102
136
  const file = getStateFilePath(convId);
103
137
  ensureStateDir();
104
138
  try {
105
139
  const now = Date.now();
106
- entry.lastPollTime = now;
140
+ entry.lastSeenTime = now;
141
+ if (updatePollTime) {
142
+ entry.lastPollTime = now;
143
+ }
107
144
  let toWrite;
108
145
  if (!convId || convId === "global") {
109
146
  let state = {};
@@ -117,8 +154,11 @@ function saveSessionState(convId = "global", entry) {
117
154
 
118
155
  // Prune stale entries
119
156
  const entries = Object.entries(state)
120
- .filter(([_, v]) => v && typeof v.lastPollTime === "number" && (now - v.lastPollTime) < STATE_TTL_MS)
121
- .sort((a, b) => (b[1].lastPollTime || 0) - (a[1].lastPollTime || 0))
157
+ .filter(([_, v]) => {
158
+ const ts = Math.max(v?.lastPollTime || 0, v?.lastSeenTime || 0);
159
+ return v && ts > 0 && (now - ts) < STATE_TTL_MS;
160
+ })
161
+ .sort((a, b) => Math.max(b[1].lastPollTime || 0, b[1].lastSeenTime || 0) - Math.max(a[1].lastPollTime || 0, a[1].lastSeenTime || 0))
122
162
  .slice(0, MAX_STATE_ENTRIES);
123
163
 
124
164
  toWrite = JSON.stringify(Object.fromEntries(entries));
@@ -137,12 +177,150 @@ function saveSessionState(convId = "global", entry) {
137
177
  } catch {}
138
178
  }
139
179
 
180
+ function matchesRawProtectedInternalState(targetPath) {
181
+ if (!targetPath || typeof targetPath !== "string") return false;
182
+ if (/\.system_generated(?:$|[\/\\])/i.test(targetPath)) return true;
183
+ if (/(?:^|[\/\\'"`\s=:])\.system_gen/i.test(targetPath)) return true;
184
+ if (/['"]\.system_['"]\s*(?:[+.]\s*)?['"]gen/i.test(targetPath)) return true;
185
+ if (/(?:^|[^a-zA-Z0-9_-])system_generated\b/i.test(targetPath)) return true;
186
+ if (/\btranscript(?:_full)?\.jsonl\b/i.test(targetPath)) return true;
187
+ if (/\.gemini[\/\\]+antigravity[\/\\]+brain[\/\\]+.*(?:\$|logs[\/\\]|tasks[\/\\]|steps[\/\\])/i.test(targetPath)) {
188
+ return true;
189
+ }
190
+ // Block hidden segments (e.g. .s*/*/*, .*) or wildcard globs directly under brain/<convId>/ (allowing .agents/)
191
+ if (/\.gemini[\/\\]+antigravity[\/\\]+brain[\/\\]+(?:[^\/\\\s'"]+[\/\\]+)?(?:\.(?!agents(?:$|[\/\\]))|[*?\[])/i.test(targetPath)) {
192
+ return true;
193
+ }
194
+ // Protect Agy-Context-Saver state directory and session/policy JSON files
195
+ if (/\b(?:transcript-access|agy-session-governance-state|agy-rtk-rewrite-cache)\.json\b/i.test(targetPath)) return true;
196
+ if (/\bagy-session-[a-zA-Z0-9_-]+\.json\b/i.test(targetPath)) return true;
197
+ if (/[\/\\]\.local[\/\\]+state[\/\\]+agy-context-saver(?:$|[\/\\'"`\s])/i.test(targetPath)) return true;
198
+ if (/[\/\\]AppData[\/\\]+Local[\/\\]+agy-context-saver(?:$|[\/\\'"`\s])/i.test(targetPath)) return true;
199
+ try {
200
+ const stateDirNorm = getStateDir().replace(/\\/g, "/");
201
+ const targetNorm = targetPath.replace(/\\/g, "/");
202
+ if (stateDirNorm && targetNorm.includes(stateDirNorm)) return true;
203
+ } catch {}
204
+ if (/\bbase64\b[\s\S]*\|\s*(?:[^\s|]+\/)?(?:ba|z|da|a)?sh\b/i.test(targetPath)) return true;
205
+ return false;
206
+ }
207
+
208
+ function isProtectedShellOrScriptOperation(text) {
209
+ if (!text || typeof text !== "string") return false;
210
+ if (isProtectedInternalState(text)) return true;
211
+
212
+ // Non-canonical or wrapped agy-context transcript invocations that did not pass checkAgyContextTranscriptCommand
213
+ if (/(?:^|[\s;|&('"\/\\])agy-context(?:-saver)?(?:\.m?js)?['"]?\s+(?:-[^\s;|&'"]+\s+)*transcript(?:\s|$|['";|&)])/i.test(text)) {
214
+ return true;
215
+ }
216
+
217
+ // Inline interpreter or script execution importing/spawning transcript-core.mjs, bin/agy-context.mjs, or mcp/index.js
218
+ if (
219
+ /(?:\b(?:import|require|spawn|spawnSync|exec|execSync|execFile|execFileSync|popen|system|subprocess)\b|(?:^|[\s;|&(])(?:node|python[23]?|perl|ruby|php|bash|sh|zsh|dash)\b[^\n;|&]*?(?:\s-[a-z]*[ec]\b|<<))[\s\S]*?(?:\btranscript-core(?:\.m?js)?\b|(?:bin[\/\\]+|\b)agy-context(?:-saver)?\.m?js\b|\bmcp[\/\\]+index\.js\b)/i.test(
220
+ text
221
+ )
222
+ ) {
223
+ return true;
224
+ }
225
+
226
+ // Block environment variable redirection of governance state or brain roots inside shell commands/scripts
227
+ if (/(?:^|[\s;|&(])(?:AGY_TEST_PROFILE_DIR|AGY_BRAIN_DIR|XDG_STATE_HOME|LOCALAPPDATA|GEMINI_CONVERSATION_ID)=/i.test(text)) {
228
+ return true;
229
+ }
230
+ if (
231
+ /(?:process\.env|os\.environ)\s*(?:\.\s*(?:AGY_TEST_PROFILE_DIR|AGY_BRAIN_DIR|XDG_STATE_HOME|LOCALAPPDATA|GEMINI_CONVERSATION_ID)\b|\[\s*['"](?:AGY_TEST_PROFILE_DIR|AGY_BRAIN_DIR|XDG_STATE_HOME|LOCALAPPDATA|GEMINI_CONVERSATION_ID)['"]\s*\])/i.test(
232
+ text
233
+ )
234
+ ) {
235
+ return true;
236
+ }
237
+
238
+ // Block symlinks, bulk copies/archives, file reads, or directory enumeration of <brainDir>/<convId> outside /scratch/, /browser_sandbox/, or .agents/ (in either order)
239
+ const hasUnprotectedBrainRef =
240
+ /\.gemini[\/\\]+antigravity[\/\\]+brain[\/\\]+(?![^\s'"]*[\/\\](?:scratch|browser_sandbox|\.agents)(?:$|[\/\\])|[^\s'"]*\.(?:md|txt|json|png|jpg|jpeg|svg|html|css|webp|pdf)\b)/i.test(
241
+ text
242
+ );
243
+ if (
244
+ hasUnprotectedBrainRef &&
245
+ /\b(?:ln|cp|mv|rsync|tar|zip|find|readdir|readdirSync|scandir|listdir|walk|glob|iglob|readFile|readFileSync|createReadStream|open|openSync)\b/i.test(
246
+ text
247
+ )
248
+ ) {
249
+ return true;
250
+ }
251
+
252
+ return false;
253
+ }
254
+
140
255
  /**
141
- * Checks if target path references Antigravity internal state by root directory.
256
+ * Checks if target path or shell operand references Antigravity internal state or obfuscated smuggling
257
+ * (including runtime Base64, URL-safe Base64, Hex, escape, or char-code path decoding).
142
258
  */
143
259
  function isProtectedInternalState(targetPath) {
144
260
  if (!targetPath || typeof targetPath !== "string") return false;
145
- return /\.system_generated(?:$|[\/\\])/i.test(targetPath);
261
+ if (matchesRawProtectedInternalState(targetPath)) return true;
262
+
263
+ // Decode candidate Base64 / URL-safe Base64 substrings (length >= 16) to block runtime path decoding
264
+ // e.g. python3 b64decode(...), node Buffer.from(..., 'base64'), $(echo <b64> | base64 -d)
265
+ const b64Matches = targetPath.match(/[A-Za-z0-9+/_-]{16,}={0,2}/g);
266
+ if (b64Matches) {
267
+ for (const rawTok of b64Matches) {
268
+ const norm = rawTok.replace(/-/g, "+").replace(/_/g, "/");
269
+ for (const offset of [0, 1, 2]) {
270
+ const slice = norm.slice(offset);
271
+ if (slice.length < 16) continue;
272
+ try {
273
+ const decoded = Buffer.from(slice, "base64").toString("utf-8");
274
+ if (
275
+ matchesRawProtectedInternalState(decoded) ||
276
+ /\.system_gen|transcript(?:_full)?\.jsonl/i.test(decoded)
277
+ ) {
278
+ return true;
279
+ }
280
+ } catch {}
281
+ }
282
+ }
283
+ }
284
+
285
+ // Decode candidate Hex substrings (length >= 24) to block Buffer.from(..., 'hex') / bytes.fromhex(...) / xxd -r
286
+ const hexMatches = targetPath.match(/[0-9a-fA-F]{24,}/g);
287
+ if (hexMatches) {
288
+ for (const rawHex of hexMatches) {
289
+ for (const offset of [0, 1]) {
290
+ const slice = rawHex.slice(offset);
291
+ const evenSlice = slice.length % 2 === 0 ? slice : slice.slice(0, -1);
292
+ if (evenSlice.length < 24) continue;
293
+ try {
294
+ const decoded = Buffer.from(evenSlice, "hex").toString("utf-8");
295
+ if (
296
+ matchesRawProtectedInternalState(decoded) ||
297
+ /\.system_gen|transcript(?:_full)?\.jsonl/i.test(decoded)
298
+ ) {
299
+ return true;
300
+ }
301
+ } catch {}
302
+ }
303
+ }
304
+ }
305
+
306
+ // Decode \xNN hex escapes or String.fromCharCode / chr() integer sequences
307
+ if (/\\x[0-9a-fA-F]{2}/.test(targetPath)) {
308
+ const unescaped = targetPath.replace(/\\x([0-9a-fA-F]{2})/g, (_, h) => String.fromCharCode(parseInt(h, 16)));
309
+ if (matchesRawProtectedInternalState(unescaped) || /\.system_gen|transcript(?:_full)?\.jsonl/i.test(unescaped)) {
310
+ return true;
311
+ }
312
+ }
313
+ if (/(?:fromCharCode|\bchr\s*\(|\bbytes\s*\(\s*\[)/i.test(targetPath)) {
314
+ const nums = targetPath.match(/\b(?:3[2-9]|[4-9]\d|1[01]\d|12[0-6])\b/g);
315
+ if (nums && nums.length >= 10) {
316
+ const decoded = nums.map((n) => String.fromCharCode(Number(n))).join("");
317
+ if (matchesRawProtectedInternalState(decoded) || /\.system_gen|transcript(?:_full)?\.jsonl/i.test(decoded)) {
318
+ return true;
319
+ }
320
+ }
321
+ }
322
+
323
+ return false;
146
324
  }
147
325
 
148
326
  /**
@@ -193,35 +371,41 @@ function isProtectedBenchmarkPath(targetPath) {
193
371
  if (!targetPath || typeof targetPath !== "string") return false;
194
372
  const normalized = targetPath.replace(/\\/g, "/");
195
373
  return (
196
- /\/(?:benchmarks?|evaluators?|evaluation|scoring|ground_truth|prompt_templates)\//i.test(normalized) ||
197
- /\/(?:eval_[^\/]+\.(?:py|json|toml)|bench_[^\/]+\.(?:py|json|toml))$/i.test(normalized)
374
+ /(?:^|\/)(?:benchmarks?|evaluators?|evaluation|scoring|ground_truth|prompt_templates)\//i.test(normalized) ||
375
+ /(?:^|\/)(?:eval_[^\/]+\.(?:py|json|toml)|bench_[^\/]+\.(?:py|json|toml))$/i.test(normalized)
198
376
  );
199
377
  }
200
378
 
201
-
202
379
  /**
203
- * Checks if target path references runtime step output spillover file.
380
+ * Checks if target path strictly matches runtime step output spillover:
381
+ * `<prefix>/.system_generated/steps/<digits>/output.txt` (or relative `.system_generated/steps/<digits>/output.txt`).
204
382
  */
205
383
  function isStepSpilloverPath(targetPath) {
206
384
  if (!targetPath || typeof targetPath !== "string") return false;
207
- if (!isProtectedInternalState(targetPath)) return false; // cheap precheck
208
- const normalized = targetPath.replace(/\\/g, "/");
209
- return (
210
- /\.system_generated[/\\]steps[/\\]\d+[/\\]output\.txt/i.test(normalized) ||
211
- (normalized.includes("/.system_generated/steps/") && normalized.endsWith("output.txt"))
212
- );
385
+ const normalized = targetPath.trim().replace(/\\/g, "/");
386
+ return /(?:^|\/)\.system_generated\/steps\/\d+\/output\.txt$/i.test(normalized);
387
+ }
388
+
389
+ /**
390
+ * Checks whether any path token in a command string strictly contains a step spillover path.
391
+ */
392
+ function findStepSpilloverToken(tokens) {
393
+ for (const tok of tokens) {
394
+ const clean = tok.replace(/^file:\/\//i, "");
395
+ if (isStepSpilloverPath(clean)) return clean;
396
+ }
397
+ return null;
213
398
  }
214
399
 
215
400
  /**
216
401
  * Extracts the task-log reference from a target path or command, or null.
217
- * Returns { id, token }: `id` is the bare task id; `token` is the matched path text
218
- * (relative "tasks/task-1.log" or absolute ".../.system_generated/tasks/task-1.log").
402
+ * Returns { id, token }: `id` is the bare task id; `token` is the matched path text.
219
403
  */
220
404
  function getTaskLogMatch(text) {
221
405
  if (!text || typeof text !== "string" || !text.includes(".log")) return null;
222
406
  const normalized = text.replace(/\\/g, "/");
223
407
  if (!normalized.includes("tasks/")) return null;
224
- const m = normalized.match(/(?:^|[\s"'])((?:[^\s"']*\/)?tasks\/(?:task-)?([a-zA-Z0-9_-]+)\.log)/i);
408
+ const m = normalized.match(/(?:^|[\s"'])((?:[^\s"']*\/)?tasks\/(?:task-)?([a-zA-Z0-9_-]+)\.log)(?=$|[\s"'])/i);
225
409
  if (!m) return null;
226
410
  let token = m[1].replace(/^file:\/\//i, "");
227
411
  if (/^\/[A-Za-z]:/.test(token)) token = token.slice(1);
@@ -239,7 +423,6 @@ function getBrainDir() {
239
423
  return process.env.AGY_BRAIN_DIR || path.join(getProfileHomeDir(), ".gemini", "antigravity", "brain");
240
424
  }
241
425
 
242
- /** Reads the last `bytes` of a file. Returns { text, whole } or null. */
243
426
  function readTail(file, bytes) {
244
427
  let fd;
245
428
  try {
@@ -256,22 +439,12 @@ function readTail(file, bytes) {
256
439
  }
257
440
  }
258
441
 
259
- /**
260
- * Looks for the finish / start notices of `taskName` in transcript text using literal needles
261
- * (JSON-escaped and raw variants). Finish: `<conv>/task-N" finished with result`.
262
- * Start: `<conv>/task-N` followed by a newline and "Task Description" or "Task logs".
263
- * Returns "COMPLETED", "RUNNING" or null.
264
- */
265
442
  function scanTaskMarkers(text, taskName) {
266
443
  if (text.includes(`/${taskName}\\" finished`) || text.includes(`/${taskName}" finished`)) return "COMPLETED";
267
444
  if (text.includes(`/${taskName}\\nTask `) || text.includes(`/${taskName}\nTask `)) return "RUNNING";
268
445
  return null;
269
446
  }
270
447
 
271
- /**
272
- * Resolves a background task's lifecycle state: "RUNNING", "COMPLETED" or "UNKNOWN".
273
- * Authority order: transcript notices (tail first, full file only if needed), then log mtime.
274
- */
275
448
  function getTaskState(match, convId) {
276
449
  const taskName = `task-${match.id}`;
277
450
  const token = match.token;
@@ -308,13 +481,38 @@ function getTaskState(match, convId) {
308
481
  return "UNKNOWN";
309
482
  }
310
483
 
484
+ // ============================================================================
485
+ // RTK Resolution & `rtk hook antigravity` Delegation
486
+ // ============================================================================
487
+
311
488
  function isCompatibleRtkVersion(versionStr) {
312
489
  if (!versionStr || typeof versionStr !== "string") return false;
313
490
  const match = versionStr.match(/(\d+)\.(\d+)\.(\d+)/);
314
491
  if (!match) return false;
315
492
  const major = parseInt(match[1], 10);
316
493
  const minor = parseInt(match[2], 10);
317
- return major === 0 && (minor === 50 || minor === 51);
494
+ return major > 1 || (major === 1 && minor >= 1);
495
+ }
496
+
497
+ function findExecutableOnPath(baseName) {
498
+ const pathEnv = process.env.PATH || process.env.Path || "";
499
+ const dirs = pathEnv.split(path.delimiter).filter(Boolean);
500
+ const isWin = os.platform() === "win32";
501
+ const exts = isWin ? [".exe", ".cmd", ".bat"] : [""];
502
+ for (const dir of dirs) {
503
+ for (const ext of exts) {
504
+ const candidate = path.join(dir, `${baseName}${ext}`);
505
+ try {
506
+ const stat = fs.statSync(candidate);
507
+ if (!stat.isFile()) continue;
508
+ if (!isWin) {
509
+ fs.accessSync(candidate, fs.constants.X_OK);
510
+ }
511
+ return candidate;
512
+ } catch {}
513
+ }
514
+ }
515
+ return "";
318
516
  }
319
517
 
320
518
  function getLocalRtkCandidate() {
@@ -331,241 +529,2534 @@ function getLocalRtkCandidate() {
331
529
  return "";
332
530
  }
333
531
 
334
- function findFirstRtkOnPath() {
335
- const pathEnv = process.env.PATH || process.env.Path || "";
336
- const dirs = pathEnv.split(path.delimiter).filter(Boolean);
337
- const exts = os.platform() === "win32" ? [".exe", ".cmd", ".bat"] : [""];
338
- for (const dir of dirs) {
339
- for (const ext of exts) {
340
- const candidate = path.join(dir, `rtk${ext}`);
341
- try {
342
- if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
343
- return candidate;
344
- }
345
- } catch {}
346
- }
347
- }
348
- return "";
349
- }
350
-
351
532
  function spawnRtk(bin, args, opts) {
352
533
  if (!bin) return { error: Object.assign(new Error("ENOENT"), { code: "ENOENT" }), status: null, stdout: "", stderr: "" };
353
534
  if (os.platform() === "win32" && /\.(cmd|bat)$/i.test(bin)) {
535
+ const comspec =
536
+ process.env.ComSpec ||
537
+ process.env.COMSPEC ||
538
+ path.join(process.env.SystemRoot || process.env.windir || "C:\\Windows", "System32", "cmd.exe");
354
539
  const quotedBin = /\s/.test(bin) ? `"${bin}"` : bin;
355
540
  const quotedArgs = args.map((a) => (/\s|["&|<>^]/.test(a) ? `"${a.replace(/"/g, '\\"')}"` : a)).join(" ");
356
- return spawnSync("cmd.exe", ["/d", "/s", "/c", `"${quotedBin} ${quotedArgs}"`], { ...opts, windowsVerbatimArguments: true });
541
+ return spawnSync(comspec, ["/d", "/s", "/c", `"${quotedBin} ${quotedArgs}"`], { ...opts, windowsVerbatimArguments: true });
357
542
  }
358
543
  return spawnSync(bin, args, opts);
359
544
  }
360
545
 
361
- const NON_REWRITABLE_BINARIES = new Set([
362
- "node", "node.exe",
363
- "powershell", "powershell.exe",
364
- "pwsh", "pwsh.exe",
365
- "cmd", "cmd.exe",
366
- "dir",
367
- "echo",
368
- "cd",
369
- "del",
370
- "mkdir",
371
- "rmdir",
372
- "copy",
373
- "move",
374
- "type",
375
- "cls",
376
- "clear",
377
- "set",
378
- "export"
546
+ let cachedRtkKey = null;
547
+ let cachedRtkValue = undefined;
548
+
549
+ function resolveCompatibleRtk() {
550
+ const localCandidate = getLocalRtkCandidate();
551
+ const pathCandidate = findExecutableOnPath("rtk");
552
+ const key = `${pathCandidate}::${localCandidate}`;
553
+ if (cachedRtkKey === key && cachedRtkValue !== undefined) {
554
+ return cachedRtkValue;
555
+ }
556
+
557
+ const opts = { encoding: "utf-8", timeout: 2000, windowsHide: true };
558
+ let resolved = null;
559
+
560
+ // Prefer PATH rtk (>= 1.1.0) before local binaries
561
+ if (pathCandidate) {
562
+ const pathRes = spawnRtk(pathCandidate, ["--version"], opts);
563
+ if (!pathRes.error && pathRes.status === 0 && isCompatibleRtkVersion(pathRes.stdout)) {
564
+ resolved = { exe: pathCandidate, prefix: "rtk" };
565
+ }
566
+ }
567
+
568
+ if (!resolved && localCandidate) {
569
+ const localRes = spawnRtk(localCandidate, ["--version"], opts);
570
+ if (!localRes.error && localRes.status === 0 && isCompatibleRtkVersion(localRes.stdout)) {
571
+ const normalizedLocal = localCandidate.replace(/\\/g, "/");
572
+ const prefix = /\s/.test(normalizedLocal) ? `"${normalizedLocal}"` : normalizedLocal;
573
+ resolved = { exe: localCandidate, prefix };
574
+ }
575
+ }
576
+
577
+ cachedRtkKey = key;
578
+ cachedRtkValue = resolved;
579
+ return resolved;
580
+ }
581
+
582
+ // ============================================================================
583
+ // Quote- & Heredoc-Aware Shell Segment Parser & Tokenizer
584
+ // ============================================================================
585
+
586
+ const SHELL_ESCAPE_CHARS = new Set([" ", "\t", "\n", "\r", ";", "|", "&", "<", ">", "(", ")", '"', "'", "\\", "$", "`"]);
587
+
588
+ function parseHeredocTag(cmd, pos) {
589
+ // Assumes cmd[pos] === '<' && cmd[pos + 1] === '<' && cmd[pos + 2] !== '<'
590
+ let i = pos + 2;
591
+ let stripTabs = false;
592
+ if (cmd[i] === "-") {
593
+ stripTabs = true;
594
+ i++;
595
+ }
596
+ while (i < cmd.length && (cmd[i] === " " || cmd[i] === "\t")) i++;
597
+ if (i >= cmd.length) return null;
598
+
599
+ let delim = "";
600
+ let quoted = false;
601
+ const ch = cmd[i];
602
+ if (ch === "'" || ch === '"') {
603
+ quoted = true;
604
+ const q = ch;
605
+ i++;
606
+ while (i < cmd.length && cmd[i] !== q && cmd[i] !== "\n" && cmd[i] !== "\r") {
607
+ if (q === '"' && cmd[i] === "\\" && i + 1 < cmd.length) {
608
+ i++;
609
+ }
610
+ delim += cmd[i++];
611
+ }
612
+ if (i < cmd.length && cmd[i] === q) i++;
613
+ } else {
614
+ while (i < cmd.length && !/[\s;|&<>()"']/.test(cmd[i])) {
615
+ if (cmd[i] === "\\" && i + 1 < cmd.length) {
616
+ quoted = true;
617
+ i++;
618
+ }
619
+ delim += cmd[i++];
620
+ }
621
+ }
622
+
623
+ if (!delim) return null;
624
+ return { delim, stripTabs, quoted, endIndex: i };
625
+ }
626
+
627
+ function readBalancedSubshell(cmd, startPos) {
628
+ // startPos points right after "$("
629
+ let i = startPos;
630
+ let depth = 1;
631
+ let inSingle = false;
632
+ let inDouble = false;
633
+
634
+ while (i < cmd.length && depth > 0) {
635
+ const c = cmd[i];
636
+ if (inSingle) {
637
+ if (c === "'") inSingle = false;
638
+ i++;
639
+ continue;
640
+ }
641
+ if (inDouble) {
642
+ if (c === "\\" && i + 1 < cmd.length) {
643
+ i += 2;
644
+ continue;
645
+ }
646
+ if (c === '"') inDouble = false;
647
+ else if (c === "$" && cmd[i + 1] === "(") {
648
+ depth++;
649
+ i += 2;
650
+ continue;
651
+ }
652
+ i++;
653
+ continue;
654
+ }
655
+ if (c === "\\" && i + 1 < cmd.length && SHELL_ESCAPE_CHARS.has(cmd[i + 1])) {
656
+ i += 2;
657
+ continue;
658
+ }
659
+ if (c === "'") {
660
+ inSingle = true;
661
+ i++;
662
+ } else if (c === '"') {
663
+ inDouble = true;
664
+ i++;
665
+ } else if (c === "(") {
666
+ depth++;
667
+ i++;
668
+ } else if (c === ")") {
669
+ depth--;
670
+ i++;
671
+ } else {
672
+ i++;
673
+ }
674
+ }
675
+
676
+ const endInner = depth === 0 ? i - 1 : i;
677
+ return { inner: cmd.slice(startPos, endInner), nextIndex: i };
678
+ }
679
+
680
+ function readBacktickSubshell(cmd, startPos) {
681
+ // startPos points right after opening "`"
682
+ let i = startPos;
683
+ let inner = "";
684
+ while (i < cmd.length) {
685
+ const c = cmd[i];
686
+ if (c === "\\" && i + 1 < cmd.length) {
687
+ inner += cmd[i + 1];
688
+ i += 2;
689
+ continue;
690
+ }
691
+ if (c === "`") {
692
+ return { inner, nextIndex: i + 1 };
693
+ }
694
+ inner += c;
695
+ i++;
696
+ }
697
+ return { inner, nextIndex: i };
698
+ }
699
+
700
+ /**
701
+ * Splits a shell command string into segments while respecting single/double quotes,
702
+ * backslash escapes, `$()` / backtick subshells, process substitutions, and `<<DELIM ... DELIM`
703
+ * heredoc bodies.
704
+ */
705
+ function splitShellSegments(command) {
706
+ if (!command || typeof command !== "string") return [];
707
+
708
+ const segments = [];
709
+ const pendingHeredocs = [];
710
+ let inSingle = false;
711
+ let inDouble = false;
712
+
713
+ let segStart = 0;
714
+ let leadingSep = "";
715
+ let hasRedirect = false;
716
+ let hasWriteRedirect = false;
717
+ let hasHeredoc = false;
718
+ let hasSubshell = false;
719
+ let subshells = [];
720
+ let heredocBodies = [];
721
+ let firstHeredocSegIdx = -1;
722
+
723
+ const pushSegment = (endOffset) => {
724
+ const raw = command.slice(segStart, endOffset);
725
+ const trimmed = raw.trim();
726
+ if (trimmed || hasHeredoc) {
727
+ const leadWS = raw.length - raw.trimStart().length;
728
+ const trailWS = raw.length - raw.trimEnd().length;
729
+ segments.push({
730
+ text: trimmed,
731
+ raw,
732
+ start: segStart + leadWS,
733
+ end: Math.max(segStart + leadWS, endOffset - trailWS),
734
+ leadingSep,
735
+ hasRedirect,
736
+ hasWriteRedirect,
737
+ hasHeredoc,
738
+ heredocBodies,
739
+ hasSubshell,
740
+ subshells
741
+ });
742
+ if (hasHeredoc && firstHeredocSegIdx === -1) {
743
+ firstHeredocSegIdx = segments.length - 1;
744
+ }
745
+ }
746
+ leadingSep = "";
747
+ hasRedirect = false;
748
+ hasWriteRedirect = false;
749
+ hasHeredoc = false;
750
+ hasSubshell = false;
751
+ subshells = [];
752
+ heredocBodies = [];
753
+ };
754
+
755
+ let i = 0;
756
+ while (i < command.length) {
757
+ const c = command[i];
758
+ const next = command[i + 1];
759
+
760
+ if (inSingle) {
761
+ if (c === "'") inSingle = false;
762
+ i++;
763
+ continue;
764
+ }
765
+
766
+ if (inDouble) {
767
+ if (c === "\\" && i + 1 < command.length) {
768
+ i += 2;
769
+ continue;
770
+ }
771
+ if (c === '"') {
772
+ inDouble = false;
773
+ i++;
774
+ } else if (c === "$" && next === "(") {
775
+ hasSubshell = true;
776
+ const sub = readBalancedSubshell(command, i + 2);
777
+ subshells.push(sub.inner);
778
+ i = sub.nextIndex;
779
+ } else if (c === "`") {
780
+ hasSubshell = true;
781
+ const sub = readBacktickSubshell(command, i + 1);
782
+ subshells.push(sub.inner);
783
+ i = sub.nextIndex;
784
+ } else {
785
+ i++;
786
+ }
787
+ continue;
788
+ }
789
+
790
+ // Backslash escape outside quotes (preserve Windows paths like C:\Users\...)
791
+ if (c === "\\") {
792
+ if (next === "\r" && command[i + 2] === "\n") {
793
+ i += 3;
794
+ continue;
795
+ }
796
+ if (next !== undefined && SHELL_ESCAPE_CHARS.has(next)) {
797
+ i += 2;
798
+ continue;
799
+ }
800
+ i++;
801
+ continue;
802
+ }
803
+
804
+ if (c === "'") {
805
+ inSingle = true;
806
+ i++;
807
+ continue;
808
+ }
809
+
810
+ if (c === '"') {
811
+ inDouble = true;
812
+ i++;
813
+ continue;
814
+ }
815
+
816
+ // Comments (# at start of segment or after whitespace)
817
+ if (c === "#" && (i === 0 || /\s/.test(command[i - 1]))) {
818
+ while (i < command.length && command[i] !== "\n") i++;
819
+ continue;
820
+ }
821
+
822
+ // Subshells & process substitutions
823
+ if ((c === "$" || c === "<" || c === ">") && next === "(") {
824
+ hasSubshell = true;
825
+ if (c === "<" || c === ">") hasRedirect = true;
826
+ const sub = readBalancedSubshell(command, i + 2);
827
+ subshells.push(sub.inner);
828
+ i = sub.nextIndex;
829
+ continue;
830
+ }
831
+
832
+ if (c === "(") {
833
+ hasSubshell = true;
834
+ const sub = readBalancedSubshell(command, i + 1);
835
+ subshells.push(sub.inner);
836
+ i = sub.nextIndex;
837
+ continue;
838
+ }
839
+
840
+ if (c === "`") {
841
+ hasSubshell = true;
842
+ const sub = readBacktickSubshell(command, i + 1);
843
+ subshells.push(sub.inner);
844
+ i = sub.nextIndex;
845
+ continue;
846
+ }
847
+
848
+ // Heredocs << / <<- vs here-string <<< vs input redirect <
849
+ if (c === "<") {
850
+ if (next === "<" && command[i + 2] !== "<") {
851
+ const tag = parseHeredocTag(command, i);
852
+ if (tag) {
853
+ hasHeredoc = true;
854
+ pendingHeredocs.push(tag);
855
+ i = tag.endIndex;
856
+ continue;
857
+ }
858
+ }
859
+ hasRedirect = true;
860
+ i += next === "<" ? (command[i + 2] === "<" ? 3 : 2) : 1;
861
+ continue;
862
+ }
863
+
864
+ if (c === ">") {
865
+ hasRedirect = true;
866
+ hasWriteRedirect = true;
867
+ i += next === ">" ? 2 : 1;
868
+ continue;
869
+ }
870
+
871
+ // Newline: flush current segment, then consume any pending heredoc bodies
872
+ if (c === "\n" || (c === "\r" && next === "\n")) {
873
+ const nlLen = c === "\r" ? 2 : 1;
874
+ pushSegment(i);
875
+ i += nlLen;
876
+
877
+ if (pendingHeredocs.length > 0) {
878
+ const targetIdx = firstHeredocSegIdx !== -1 ? firstHeredocSegIdx : segments.length - 1;
879
+ while (pendingHeredocs.length > 0) {
880
+ const hd = pendingHeredocs.shift();
881
+ const bodyLines = [];
882
+ while (i < command.length) {
883
+ const nlPos = command.indexOf("\n", i);
884
+ const lineEnd = nlPos === -1 ? command.length : nlPos;
885
+ const rawLine = command.slice(i, lineEnd).replace(/\r$/, "");
886
+ const checkLine = hd.stripTabs ? rawLine.replace(/^\t+/, "") : rawLine;
887
+ i = nlPos === -1 ? command.length : nlPos + 1;
888
+ if (checkLine === hd.delim) {
889
+ break;
890
+ }
891
+ bodyLines.push(rawLine);
892
+ }
893
+ if (targetIdx >= 0 && segments[targetIdx]) {
894
+ segments[targetIdx].heredocBodies.push(bodyLines.join("\n"));
895
+ }
896
+ }
897
+ firstHeredocSegIdx = -1;
898
+ }
899
+
900
+ segStart = i;
901
+ leadingSep = "\n";
902
+ continue;
903
+ }
904
+
905
+ // Operators: &&, ||, |&, |, ;, &
906
+ if (c === "&" && next === "&") {
907
+ pushSegment(i);
908
+ i += 2;
909
+ segStart = i;
910
+ leadingSep = "&&";
911
+ continue;
912
+ }
913
+ if (c === "|" && next === "|") {
914
+ pushSegment(i);
915
+ i += 2;
916
+ segStart = i;
917
+ leadingSep = "||";
918
+ continue;
919
+ }
920
+ if (c === "|" && next === "&") {
921
+ pushSegment(i);
922
+ i += 2;
923
+ segStart = i;
924
+ leadingSep = "|&";
925
+ continue;
926
+ }
927
+ if (c === "|") {
928
+ pushSegment(i);
929
+ i += 1;
930
+ segStart = i;
931
+ leadingSep = "|";
932
+ continue;
933
+ }
934
+ if (c === ";") {
935
+ pushSegment(i);
936
+ i += 1;
937
+ segStart = i;
938
+ leadingSep = ";";
939
+ continue;
940
+ }
941
+ if (c === "&") {
942
+ // Exclude fd redirects like >&2, 2>&1, <&0, &>file
943
+ const prevChar = i > 0 ? command[i - 1] : "";
944
+ if (prevChar === ">" || prevChar === "<") {
945
+ hasRedirect = true;
946
+ i++;
947
+ continue;
948
+ }
949
+ if (next === ">") {
950
+ hasRedirect = true;
951
+ hasWriteRedirect = true;
952
+ i += 2;
953
+ continue;
954
+ }
955
+ pushSegment(i);
956
+ i += 1;
957
+ segStart = i;
958
+ leadingSep = "&";
959
+ continue;
960
+ }
961
+
962
+ i++;
963
+ }
964
+
965
+ pushSegment(command.length);
966
+ return segments;
967
+ }
968
+
969
+ /**
970
+ * Tokenizes a single shell segment into words (preserving raw token text & unquoted value).
971
+ */
972
+ const REDIRECT_OP_REGEX = /^(?:&>>?|(?:\d+)?(?:>>?|<<<?|<<-|<&|>&|>\||<>))$/;
973
+ const REDIRECT_ATTACHED_REGEX = /^(?:\d+)?(?:>>?|<<?)(.+)$/;
974
+
975
+ function tokenizeShellWordsDetailed(text) {
976
+ const tokens = [];
977
+ let i = 0;
978
+
979
+ while (i < text.length) {
980
+ while (i < text.length && /\s/.test(text[i])) i++;
981
+ if (i >= text.length) break;
982
+
983
+ const start = i;
984
+ let value = "";
985
+ let quoted = false;
986
+
987
+ while (i < text.length && !/\s/.test(text[i])) {
988
+ const c = text[i];
989
+ if (c === ">" || c === "<") {
990
+ if (i > start) {
991
+ const prefixRaw = text.slice(start, i);
992
+ const isFdOrAmpPrefix = !quoted && (/^\d+$/.test(prefixRaw) || (c === ">" && prefixRaw === "&"));
993
+ if (!isFdOrAmpPrefix) {
994
+ break;
995
+ }
996
+ }
997
+ i++;
998
+ if (i < text.length && (text[i] === c || text[i] === "&" || (c === ">" && text[i] === "|") || (c === "<" && text[i] === ">"))) {
999
+ i++;
1000
+ if (i < text.length && (text[i] === "<" || text[i] === ">" || text[i] === "-")) {
1001
+ i++;
1002
+ }
1003
+ }
1004
+ value = text.slice(start, i);
1005
+ break;
1006
+ } else if (c === "&" && i + 1 < text.length && text[i + 1] === ">" && i === start) {
1007
+ i += 2;
1008
+ if (i < text.length && text[i] === ">") i++;
1009
+ value = text.slice(start, i);
1010
+ break;
1011
+ } else if (c === "'") {
1012
+ quoted = true;
1013
+ i++;
1014
+ while (i < text.length && text[i] !== "'") {
1015
+ value += text[i++];
1016
+ }
1017
+ if (i < text.length && text[i] === "'") i++;
1018
+ } else if (c === '"') {
1019
+ quoted = true;
1020
+ i++;
1021
+ while (i < text.length && text[i] !== '"') {
1022
+ if (text[i] === "\\" && i + 1 < text.length && (text[i + 1] === '"' || text[i + 1] === "\\" || text[i + 1] === "$" || text[i + 1] === "`")) {
1023
+ value += text[i + 1];
1024
+ i += 2;
1025
+ } else {
1026
+ value += text[i++];
1027
+ }
1028
+ }
1029
+ if (i < text.length && text[i] === '"') i++;
1030
+ } else if (c === "\\" && i + 1 < text.length && SHELL_ESCAPE_CHARS.has(text[i + 1])) {
1031
+ value += text[i + 1];
1032
+ i += 2;
1033
+ } else {
1034
+ value += c;
1035
+ i++;
1036
+ }
1037
+ }
1038
+
1039
+ tokens.push({ raw: text.slice(start, i), value, quoted, start, end: i });
1040
+ }
1041
+
1042
+ return tokens;
1043
+ }
1044
+
1045
+ const ENV_VALUE_FLAGS = new Set(["-u", "--unset", "-C", "--chdir", "-S", "--split-string"]);
1046
+
1047
+ function splitEnvPrefix(text) {
1048
+ const tokens = tokenizeShellWordsDetailed(text);
1049
+ let idx = 0;
1050
+
1051
+ while (idx < tokens.length) {
1052
+ const tok = tokens[idx];
1053
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(tok.raw)) {
1054
+ idx++;
1055
+ continue;
1056
+ }
1057
+ if (normalizeCmdName(tok.value) === "env") {
1058
+ idx++;
1059
+ while (idx < tokens.length) {
1060
+ const sub = tokens[idx];
1061
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(sub.raw)) {
1062
+ idx++;
1063
+ continue;
1064
+ }
1065
+ if (sub.value.startsWith("-") && sub.value !== "-") {
1066
+ idx++;
1067
+ if (ENV_VALUE_FLAGS.has(sub.value) && idx < tokens.length) {
1068
+ idx++;
1069
+ }
1070
+ continue;
1071
+ }
1072
+ break;
1073
+ }
1074
+ continue;
1075
+ }
1076
+ break;
1077
+ }
1078
+
1079
+ if (idx === 0) return { prefix: "", body: text };
1080
+ if (idx >= tokens.length) return { prefix: text, body: "" };
1081
+ return {
1082
+ prefix: text.slice(0, tokens[idx].start),
1083
+ body: text.slice(tokens[idx].start)
1084
+ };
1085
+ }
1086
+
1087
+ function tokenizeShellWords(text) {
1088
+ return tokenizeShellWordsDetailed(text).map((t) => t.value);
1089
+ }
1090
+
1091
+ function hasDotDotTraversal(str) {
1092
+ if (!str || typeof str !== "string") return false;
1093
+ return /(?:^|[\/\\])\.\.(?:$|[\/\\])/.test(str);
1094
+ }
1095
+
1096
+ function tryRealpath(p) {
1097
+ try {
1098
+ return fs.realpathSync(p);
1099
+ } catch {
1100
+ try {
1101
+ const parent = path.dirname(p);
1102
+ const base = path.basename(p);
1103
+ return path.join(fs.realpathSync(parent), base);
1104
+ } catch {
1105
+ return path.resolve(p);
1106
+ }
1107
+ }
1108
+ }
1109
+
1110
+ function isPathInsideDir(candidatePath, baseDir) {
1111
+ const norm = (p) => (os.platform() === "win32" ? p.toLowerCase() : p);
1112
+ const resolvedCandidate = norm(tryRealpath(path.resolve(candidatePath)));
1113
+ const resolvedBase = norm(tryRealpath(path.resolve(baseDir)));
1114
+ const prefix = resolvedBase.endsWith(path.sep) ? resolvedBase : resolvedBase + path.sep;
1115
+ return resolvedCandidate.startsWith(prefix);
1116
+ }
1117
+
1118
+ function normalizeCmdName(token) {
1119
+ const base = path.basename(token.replace(/\\/g, "/")).toLowerCase();
1120
+ return base.replace(/\.(exe|cmd|bat|mjs|cjs|js)$/i, "");
1121
+ }
1122
+
1123
+ // ============================================================================
1124
+ // Internal State & `agy-context transcript` Governance
1125
+ // ============================================================================
1126
+
1127
+ const TRANSCRIPT_READ_SUBCOMMANDS = new Set([
1128
+ "read",
1129
+ "query",
1130
+ "step",
1131
+ "health",
1132
+ "list",
1133
+ "ls",
1134
+ "grep",
1135
+ "search",
1136
+ "find",
1137
+ "stats",
1138
+ "info",
1139
+ "tools",
1140
+ "outline",
1141
+ "summary"
1142
+ ]);
1143
+ const TRANSCRIPT_HELP_SUBCOMMANDS = new Set(["help", "--help", "-h"]);
1144
+ const TRANSCRIPT_ADMIN_SUBCOMMANDS = new Set(["access", "policy"]);
1145
+ const COMMAND_WRAPPERS = new Set([
1146
+ "command",
1147
+ "builtin",
1148
+ "exec",
1149
+ "nohup",
1150
+ "time",
1151
+ "nice",
1152
+ "timeout",
1153
+ "stdbuf",
1154
+ "setsid",
1155
+ "chrt",
1156
+ "ionice",
1157
+ "taskset",
1158
+ "xargs"
379
1159
  ]);
380
1160
 
381
- const REWRITE_CACHE_FILE = path.join(getStateDir(), "agy-rtk-rewrite-cache.json");
382
- const REWRITE_CACHE_MAX = 100;
383
- let inMemoryRewriteCache = null;
1161
+ function isAllowedAgyContextScriptPath(scriptToken) {
1162
+ if (!scriptToken || hasDotDotTraversal(scriptToken)) return false;
1163
+ const normalized = scriptToken.replace(/\\/g, "/");
1164
+ if (!normalized.endsWith("/bin/agy-context.mjs") && normalized !== "bin/agy-context.mjs") {
1165
+ return false;
1166
+ }
1167
+
1168
+ const hookDir = path.dirname(fileURLToPath(import.meta.url));
1169
+ const repoOrPluginRoot = path.resolve(hookDir, "..");
1170
+ const installedPluginRoot = path.join(getProfileHomeDir(), ".gemini", "config", "plugins", "agy-context-saver");
1171
+ const allowedTargets = new Set([
1172
+ tryRealpath(path.join(repoOrPluginRoot, "bin", "agy-context.mjs")),
1173
+ tryRealpath(path.join(installedPluginRoot, "bin", "agy-context.mjs"))
1174
+ ]);
1175
+ const resolvedArg = tryRealpath(path.resolve(scriptToken));
1176
+ if (allowedTargets.has(resolvedArg)) return true;
1177
+
1178
+ // Also allow relative "bin/agy-context.mjs" when invoked from repo/plugin root
1179
+ if (normalized === "bin/agy-context.mjs") return true;
1180
+ return false;
1181
+ }
1182
+
1183
+ function isAllowedAgyContextBinaryToken(binToken) {
1184
+ if (!binToken || hasDotDotTraversal(binToken)) return false;
1185
+ const normalized = binToken.replace(/\\/g, "/");
1186
+ if (!normalized.includes("/")) {
1187
+ const base = normalizeCmdName(normalized);
1188
+ return base === "agy-context" || base === "agy-context-saver";
1189
+ }
1190
+ if (isAllowedAgyContextScriptPath(binToken)) return true;
1191
+ if (/\/\.gemini\/antigravity\/bin\/agy-context(?:-saver)?(?:\.(?:exe|cmd|bat))?$/i.test(normalized)) {
1192
+ return true;
1193
+ }
1194
+ return false;
1195
+ }
1196
+
1197
+ function extractWrappedScriptInfo(segText) {
1198
+ const trimmed = (segText || "").trim();
1199
+ if (!trimmed) return null;
1200
+
1201
+ // Unpack `env -S "..."` or `env --split-string="..."`
1202
+ const rawTokens = tokenizeShellWordsDetailed(trimmed);
1203
+ for (let i = 0; i < rawTokens.length; i++) {
1204
+ const tok = rawTokens[i];
1205
+ if (normalizeCmdName(tok.value) === "env") {
1206
+ for (let j = i + 1; j < rawTokens.length; j++) {
1207
+ const sub = rawTokens[j].value;
1208
+ if ((sub === "-S" || sub === "--split-string") && rawTokens[j + 1]) {
1209
+ const restAfter = rawTokens.slice(j + 2).map((t) => t.value).join(" ");
1210
+ return {
1211
+ hadEnvPrefix: true,
1212
+ innerScript: `${rawTokens[j + 1].value}${restAfter ? " " + restAfter : ""}`.trim()
1213
+ };
1214
+ }
1215
+ if (sub.startsWith("-S") && sub.length > 2) {
1216
+ const restAfter = rawTokens.slice(j + 1).map((t) => t.value).join(" ");
1217
+ return {
1218
+ hadEnvPrefix: true,
1219
+ innerScript: `${sub.slice(2)}${restAfter ? " " + restAfter : ""}`.trim()
1220
+ };
1221
+ }
1222
+ if (sub.startsWith("--split-string=")) {
1223
+ const restAfter = rawTokens.slice(j + 1).map((t) => t.value).join(" ");
1224
+ return {
1225
+ hadEnvPrefix: true,
1226
+ innerScript: `${sub.slice("--split-string=".length)}${restAfter ? " " + restAfter : ""}`.trim()
1227
+ };
1228
+ }
1229
+ }
1230
+ }
1231
+ }
1232
+
1233
+ const { prefix: envPrefix, body } = splitEnvPrefix(trimmed);
1234
+ const effective = (body || trimmed).trim();
1235
+ const detailed = tokenizeShellWordsDetailed(effective);
1236
+ if (detailed.length === 0) return null;
1237
+ const cmd0 = normalizeCmdName(detailed[0].value);
1238
+ if (cmd0 === "eval" && detailed.length >= 2) {
1239
+ return {
1240
+ hadEnvPrefix: Boolean(envPrefix.trim()),
1241
+ innerScript: detailed.slice(1).map((t) => t.value).join(" ")
1242
+ };
1243
+ }
1244
+ if ((cmd0 === "bash" || cmd0 === "sh" || cmd0 === "zsh" || cmd0 === "dash") && detailed.length >= 3) {
1245
+ for (let i = 1; i < detailed.length - 1; i++) {
1246
+ const flag = detailed[i].value;
1247
+ if (flag === "--") break;
1248
+ if (/^-[a-z]*c$/i.test(flag)) {
1249
+ return {
1250
+ hadEnvPrefix: Boolean(envPrefix.trim()),
1251
+ innerScript: detailed[i + 1].value
1252
+ };
1253
+ }
1254
+ }
1255
+ }
1256
+ if (COMMAND_WRAPPERS.has(cmd0) && detailed.length >= 2) {
1257
+ let subStart = 1;
1258
+ while (subStart < detailed.length && detailed[subStart].value.startsWith("-")) {
1259
+ const fl = detailed[subStart].value;
1260
+ subStart++;
1261
+ if ((cmd0 === "nice" && fl === "-n") || (cmd0 === "timeout" && (fl === "-k" || fl === "-s"))) {
1262
+ if (subStart < detailed.length) subStart++;
1263
+ }
1264
+ }
1265
+ if (cmd0 === "timeout" && subStart < detailed.length && /^\d/.test(detailed[subStart].value)) {
1266
+ subStart++;
1267
+ }
1268
+ if (subStart < detailed.length) {
1269
+ return {
1270
+ hadEnvPrefix: true,
1271
+ innerScript: detailed.slice(subStart).map((t) => t.value).join(" ")
1272
+ };
1273
+ }
1274
+ }
1275
+ return null;
1276
+ }
1277
+
1278
+ function parseAgyContextTranscriptTokens(tokens) {
1279
+ if (!Array.isArray(tokens) || tokens.length === 0) return null;
1280
+ const cmd0 = normalizeCmdName(tokens[0]);
1281
+ let subOffset = -1;
1282
+ let hasNodeFlags = false;
1283
+ let invalidBinaryPath = false;
1284
+
1285
+ if ((cmd0 === "agy-context" || cmd0 === "agy-context-saver") && tokens[1] === "transcript") {
1286
+ subOffset = 2;
1287
+ if (!isAllowedAgyContextBinaryToken(tokens[0])) {
1288
+ invalidBinaryPath = true;
1289
+ }
1290
+ } else if (cmd0 === "node" && tokens.length >= 3) {
1291
+ for (let i = 1; i < tokens.length - 1; i++) {
1292
+ const tok = tokens[i];
1293
+ if (
1294
+ (isAllowedAgyContextScriptPath(tok) || normalizeCmdName(tok) === "agy-context" || normalizeCmdName(tok) === "agy-context-saver") &&
1295
+ tokens[i + 1] === "transcript"
1296
+ ) {
1297
+ subOffset = i + 2;
1298
+ hasNodeFlags = i !== 1;
1299
+ if (!isAllowedAgyContextScriptPath(tok)) {
1300
+ invalidBinaryPath = true;
1301
+ }
1302
+ break;
1303
+ }
1304
+ }
1305
+ }
1306
+
1307
+ if (subOffset === -1) return null;
1308
+ const rawSub = tokens[subOffset];
1309
+ const subcommand = rawSub !== undefined ? String(rawSub).trim() : "help";
1310
+ const isReadSub = TRANSCRIPT_READ_SUBCOMMANDS.has(subcommand);
1311
+ const isHelpSub = TRANSCRIPT_HELP_SUBCOMMANDS.has(subcommand);
1312
+ const isAdminSub = TRANSCRIPT_ADMIN_SUBCOMMANDS.has(subcommand);
1313
+ const isKnownSubcommand = isReadSub || isHelpSub || isAdminSub;
1314
+
1315
+ return {
1316
+ subOffset,
1317
+ subcommand,
1318
+ isReadSub,
1319
+ isHelpSub,
1320
+ isAdminSub,
1321
+ isKnownSubcommand,
1322
+ hasNodeFlags,
1323
+ invalidBinaryPath,
1324
+ tokens
1325
+ };
1326
+ }
1327
+
1328
+ function findAgyContextTranscriptUsage(segments, depth = 0) {
1329
+ if (depth > 4) return null;
1330
+ for (let idx = 0; idx < segments.length; idx++) {
1331
+ const seg = segments[idx];
1332
+ let text = (seg.text || "").trim();
1333
+ let wrappedInSubshell = false;
1334
+ while (text.startsWith("(") && text.endsWith(")")) {
1335
+ wrappedInSubshell = true;
1336
+ text = text.slice(1, -1).trim();
1337
+ }
1338
+ const { prefix: envPrefix, body } = splitEnvPrefix(text);
1339
+ const hadWrapper = wrappedInSubshell || Boolean(envPrefix.trim());
1340
+ const directTokens = tokenizeShellWords(body || text);
1341
+ const parsedDirect = parseAgyContextTranscriptTokens(directTokens);
1342
+ if (parsedDirect) {
1343
+ return { ...parsedDirect, seg, segIndex: idx, totalSegs: segments.length, hadWrapper };
1344
+ }
1345
+ const wrapped = extractWrappedScriptInfo(text);
1346
+ if (wrapped && wrapped.innerScript) {
1347
+ const innerMatch = findAgyContextTranscriptUsage(splitShellSegments(wrapped.innerScript), depth + 1);
1348
+ if (innerMatch) {
1349
+ return { ...innerMatch, hadWrapper: true, totalSegs: Math.max(segments.length, innerMatch.totalSegs) };
1350
+ }
1351
+ }
1352
+ if (Array.isArray(seg.subshells)) {
1353
+ for (const sub of seg.subshells) {
1354
+ const subMatch = findAgyContextTranscriptUsage(splitShellSegments(sub), depth + 1);
1355
+ if (subMatch) {
1356
+ return { ...subMatch, hadWrapper: true, totalSegs: Math.max(segments.length, 2) };
1357
+ }
1358
+ }
1359
+ }
1360
+ }
1361
+ return null;
1362
+ }
1363
+
1364
+ // ============================================================================
1365
+ // Transcript Access Policy & Lineage Engine (Refined Approach D)
1366
+ // ============================================================================
1367
+
1368
+ const HOOK_GRANT_TTL_MS = 10000;
1369
+ const STANDALONE_DIRECTIVE_LINE_RE = /^(?:@[a-zA-Z0-9_-]+\s+)?\[agy-transcript-access:\s*(deny|self-only|allow)\]\s*$/i;
1370
+
1371
+ function getTranscriptAccessPolicyPath() {
1372
+ return path.join(getStateDir(), "transcript-access.json");
1373
+ }
1374
+
1375
+ function normalizePatternList(rawList) {
1376
+ if (!rawList) return [];
1377
+ const arr = Array.isArray(rawList)
1378
+ ? rawList
1379
+ : String(rawList)
1380
+ .split(",")
1381
+ .map((s) => s.trim())
1382
+ .filter(Boolean);
1383
+ return Array.from(new Set(arr.map((s) => String(s).trim()).filter(Boolean)));
1384
+ }
1385
+
1386
+ function readTranscriptAccessStore() {
1387
+ const filePath = getTranscriptAccessPolicyPath();
1388
+ const now = Date.now();
1389
+ const baseStore = {
1390
+ version: 1,
1391
+ activeOwnerId: null,
1392
+ policies: {},
1393
+ lastHookGrant: null,
1394
+ lastAccessMutationGrant: null
1395
+ };
1396
+ try {
1397
+ if (!fs.existsSync(filePath)) return baseStore;
1398
+ const parsed = JSON.parse(fs.readFileSync(filePath, "utf-8"));
1399
+ if (!parsed || typeof parsed !== "object") return baseStore;
1400
+
1401
+ const policies = {};
1402
+ const rawPolicies = parsed.policies && typeof parsed.policies === "object" ? parsed.policies : {};
1403
+ for (const [ownerId, pol] of Object.entries(rawPolicies)) {
1404
+ if (!pol || typeof pol !== "object") continue;
1405
+ const updatedAt = Number(pol.updatedAt || pol.createdAt || 0);
1406
+ const ttlMs = Number(pol.ttlMs) > 0 ? Number(pol.ttlMs) : STATE_TTL_MS;
1407
+ if (updatedAt > 0 && (now - updatedAt) < ttlMs) {
1408
+ policies[ownerId] = {
1409
+ ownerId: String(pol.ownerId || ownerId),
1410
+ mode: ["open", "allowlist", "self-only", "deny"].includes(pol.mode) ? pol.mode : "open",
1411
+ allow: normalizePatternList(pol.allow),
1412
+ deny: normalizePatternList(pol.deny),
1413
+ createdAt: Number(pol.createdAt || updatedAt),
1414
+ updatedAt,
1415
+ ttlMs
1416
+ };
1417
+ }
1418
+ }
1419
+
1420
+ let activeOwnerId = parsed.activeOwnerId && policies[parsed.activeOwnerId] ? parsed.activeOwnerId : null;
1421
+ if (!activeOwnerId) {
1422
+ const sortedOwners = Object.values(policies).sort((a, b) => b.updatedAt - a.updatedAt);
1423
+ if (sortedOwners.length > 0) activeOwnerId = sortedOwners[0].ownerId;
1424
+ }
1425
+
1426
+ const lastHookGrant =
1427
+ parsed.lastHookGrant &&
1428
+ typeof parsed.lastHookGrant === "object" &&
1429
+ typeof parsed.lastHookGrant.ts === "number" &&
1430
+ (now - parsed.lastHookGrant.ts) < HOOK_GRANT_TTL_MS
1431
+ ? parsed.lastHookGrant
1432
+ : null;
1433
+
1434
+ const lastAccessMutationGrant =
1435
+ parsed.lastAccessMutationGrant &&
1436
+ typeof parsed.lastAccessMutationGrant === "object" &&
1437
+ typeof parsed.lastAccessMutationGrant.ts === "number" &&
1438
+ (now - parsed.lastAccessMutationGrant.ts) < HOOK_GRANT_TTL_MS
1439
+ ? parsed.lastAccessMutationGrant
1440
+ : null;
1441
+
1442
+ return {
1443
+ version: 1,
1444
+ activeOwnerId,
1445
+ policies,
1446
+ lastHookGrant,
1447
+ lastAccessMutationGrant
1448
+ };
1449
+ } catch {
1450
+ return baseStore;
1451
+ }
1452
+ }
1453
+
1454
+ function writeTranscriptAccessStore(store) {
1455
+ ensureStateDir();
1456
+ const filePath = getTranscriptAccessPolicyPath();
1457
+ try {
1458
+ const payload = JSON.stringify(
1459
+ {
1460
+ version: 1,
1461
+ activeOwnerId: store.activeOwnerId || null,
1462
+ policies: store.policies || {},
1463
+ lastHookGrant: store.lastHookGrant || null,
1464
+ lastAccessMutationGrant: store.lastAccessMutationGrant || null
1465
+ },
1466
+ null,
1467
+ 2
1468
+ );
1469
+ const tmpFile = `${filePath}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
1470
+ fs.writeFileSync(tmpFile, payload, "utf-8");
1471
+ try {
1472
+ fs.renameSync(tmpFile, filePath);
1473
+ } catch {
1474
+ fs.writeFileSync(filePath, payload, "utf-8");
1475
+ try { fs.unlinkSync(tmpFile); } catch {}
1476
+ }
1477
+ } catch {}
1478
+ }
1479
+
1480
+ function recordTranscriptHookGrant({
1481
+ callerConvId = "global",
1482
+ subcommand = "read",
1483
+ targetConvId = null,
1484
+ mode = "open",
1485
+ isMutation = false
1486
+ } = {}) {
1487
+ const store = readTranscriptAccessStore();
1488
+ const now = Date.now();
1489
+ if (isMutation) {
1490
+ store.lastAccessMutationGrant = {
1491
+ callerConvId,
1492
+ ts: now
1493
+ };
1494
+ } else {
1495
+ store.lastHookGrant = {
1496
+ callerConvId,
1497
+ subcommand,
1498
+ targetConvId,
1499
+ mode,
1500
+ ts: now
1501
+ };
1502
+ }
1503
+ writeTranscriptAccessStore(store);
1504
+ }
1505
+
1506
+ function extractFirstLineDirective(bodyText) {
1507
+ if (!bodyText || typeof bodyText !== "string") return null;
1508
+ let cleaned = bodyText.replace(/<\/SYSTEM_MESSAGE>[\s\S]*$/i, "");
1509
+ cleaned = cleaned.replace(/^\s*<USER_REQUEST>\s*/i, "");
1510
+ const lines = cleaned.split(/\r?\n/);
1511
+ for (const line of lines) {
1512
+ const trimmed = line.trim();
1513
+ if (!trimmed) continue;
1514
+ const m = trimmed.match(STANDALONE_DIRECTIVE_LINE_RE);
1515
+ return m ? m[1].toLowerCase() : null;
1516
+ }
1517
+ return null;
1518
+ }
1519
+
1520
+ /**
1521
+ * Inspects Steps 0–2 of `<brainDir>/<convId>/.system_generated/logs/transcript.jsonl`
1522
+ * to deterministically classify top-level user chats vs. `/swarm` workers vs. subagents,
1523
+ * extract `parentConvId` and `@rosterName`, and parse any first-line `[agy-transcript-access: ...]` directive.
1524
+ */
1525
+ function getCallerLineage(convId) {
1526
+ if (!convId || convId === "global") {
1527
+ return {
1528
+ transcriptFound: false,
1529
+ isTopLevelUserSession: false,
1530
+ isChildSession: false,
1531
+ isSwarmChild: false,
1532
+ parentConvId: null,
1533
+ rosterName: null,
1534
+ launchDirective: null,
1535
+ birthTimeMs: 0
1536
+ };
1537
+ }
1538
+
1539
+ const session = getSessionState(convId);
1540
+ if (session.lineage && session.lineage.transcriptFound) {
1541
+ return session.lineage;
1542
+ }
1543
+
1544
+ const brainDir = getBrainDir();
1545
+ const compactPath = path.join(brainDir, convId, ".system_generated", "logs", "transcript.jsonl");
1546
+ const fullPath = path.join(brainDir, convId, ".system_generated", "logs", "transcript_full.jsonl");
1547
+ const targetFile = fs.existsSync(compactPath) ? compactPath : (fs.existsSync(fullPath) ? fullPath : null);
1548
+
1549
+ if (!targetFile) {
1550
+ return {
1551
+ transcriptFound: false,
1552
+ isTopLevelUserSession: false,
1553
+ isChildSession: false,
1554
+ isSwarmChild: false,
1555
+ parentConvId: null,
1556
+ rosterName: null,
1557
+ launchDirective: null,
1558
+ birthTimeMs: 0
1559
+ };
1560
+ }
1561
+
1562
+ let birthTimeMs = Date.now();
1563
+ let headStr = "";
1564
+ try {
1565
+ const st = fs.statSync(targetFile);
1566
+ const candidates = [st.birthtimeMs, st.ctimeMs, st.mtimeMs].filter((n) => Number.isFinite(n) && n > 0);
1567
+ if (candidates.length > 0) birthTimeMs = Math.min(...candidates);
1568
+
1569
+ const fd = fs.openSync(targetFile, "r");
1570
+ try {
1571
+ const buf = Buffer.alloc(16384);
1572
+ const bytesRead = fs.readSync(fd, buf, 0, buf.length, 0);
1573
+ headStr = buf.toString("utf-8", 0, bytesRead);
1574
+ } finally {
1575
+ fs.closeSync(fd);
1576
+ }
1577
+ } catch {}
1578
+
1579
+ let isTopLevelUserSession = false;
1580
+ let isChildSession = false;
1581
+ let isSwarmChild = false;
1582
+ let parentConvId = null;
1583
+ let rosterName = null;
1584
+ let launchDirective = null;
1585
+
1586
+ const lines = headStr.split(/\r?\n/).filter((l) => l.trim()).slice(0, 4);
1587
+ for (let i = 0; i < lines.length; i++) {
1588
+ let item = null;
1589
+ try {
1590
+ item = JSON.parse(lines[i]);
1591
+ } catch {
1592
+ continue;
1593
+ }
1594
+ const content = typeof item?.content === "string" ? item.content : "";
1595
+ if (i === 0 && (item?.type === "USER_INPUT" || item?.source === "USER_EXPLICIT")) {
1596
+ isTopLevelUserSession = true;
1597
+ launchDirective = extractFirstLineDirective(content);
1598
+ break;
1599
+ }
1600
+
1601
+ if (item?.type === "SYSTEM_MESSAGE" || item?.source === "SYSTEM") {
1602
+ const senderMatch = content.match(/\[Message\][^\n]*\bsender=([a-zA-Z0-9_-]{4,})\b/);
1603
+ if (senderMatch) {
1604
+ isChildSession = true;
1605
+ if (!parentConvId) parentConvId = senderMatch[1];
1606
+ }
1607
+
1608
+ const swarmAssignMatch = content.match(
1609
+ /Your assignment \(recorded on the board as #\d+ for @([a-zA-Z0-9_-]+)\):([\s\S]*)$/
1610
+ );
1611
+ if (swarmAssignMatch) {
1612
+ isChildSession = true;
1613
+ isSwarmChild = true;
1614
+ rosterName = swarmAssignMatch[1];
1615
+ const dir = extractFirstLineDirective(swarmAssignMatch[2]);
1616
+ if (dir) launchDirective = dir;
1617
+ break;
1618
+ }
1619
+
1620
+ if (senderMatch && i === 0 && !content.includes("content=[swarm] new board message")) {
1621
+ const msgBodyMatch = content.match(/\bpriority=MESSAGE_PRIORITY_[A-Z_]+\s+content=([\s\S]*)$/);
1622
+ if (msgBodyMatch) {
1623
+ const dir = extractFirstLineDirective(msgBodyMatch[1]);
1624
+ if (dir) launchDirective = dir;
1625
+ }
1626
+ }
1627
+ }
1628
+ }
1629
+
1630
+ const lineage = {
1631
+ transcriptFound: lines.length > 0,
1632
+ isTopLevelUserSession,
1633
+ isChildSession,
1634
+ isSwarmChild,
1635
+ parentConvId,
1636
+ rosterName,
1637
+ launchDirective,
1638
+ birthTimeMs
1639
+ };
1640
+
1641
+ if (lineage.transcriptFound) {
1642
+ session.lineage = lineage;
1643
+ saveSessionState(convId, session, { updatePollTime: false });
1644
+ }
1645
+ return lineage;
1646
+ }
1647
+
1648
+ function matchesAgentPattern(pattern, callerConvId, rosterName) {
1649
+ if (!pattern) return false;
1650
+ const rawPat = String(pattern).trim().toLowerCase();
1651
+ if (!rawPat) return false;
1652
+ const isRosterPat = rawPat.startsWith("@");
1653
+ const cleanPat = isRosterPat ? rawPat.slice(1) : rawPat;
1654
+ const isGlob = cleanPat.endsWith("*");
1655
+ const prefix = isGlob ? cleanPat.slice(0, -1) : cleanPat;
1656
+
1657
+ if (rosterName) {
1658
+ const cleanRoster = String(rosterName).trim().toLowerCase().replace(/^@/, "");
1659
+ if (isGlob ? cleanRoster.startsWith(prefix) : cleanRoster === cleanPat) {
1660
+ return true;
1661
+ }
1662
+ }
1663
+ if (!isRosterPat && callerConvId) {
1664
+ const cleanId = String(callerConvId).trim().toLowerCase();
1665
+ if (isGlob ? cleanId.startsWith(prefix) : cleanId === cleanPat) {
1666
+ return true;
1667
+ }
1668
+ }
1669
+ return false;
1670
+ }
1671
+
1672
+ function matchesAgentList(list, callerConvId, rosterName) {
1673
+ if (!Array.isArray(list) || list.length === 0) return false;
1674
+ return list.some((pat) => matchesAgentPattern(pat, callerConvId, rosterName));
1675
+ }
1676
+
1677
+ function findGoverningPolicyForCaller(store, convId, lineage) {
1678
+ const policies = store?.policies || {};
1679
+ const policyList = Object.values(policies).sort((a, b) => b.updatedAt - a.updatedAt);
1680
+ if (policyList.length === 0) return null;
1681
+
1682
+ // 1. If caller is an owner itself, return its own policy
1683
+ if (convId && policies[convId]) {
1684
+ return policies[convId];
1685
+ }
1686
+
1687
+ // 2. If caller is explicitly listed in any active policy's deny or allow list
1688
+ for (const pol of policyList) {
1689
+ if (matchesAgentList(pol.deny, convId, lineage.rosterName) || matchesAgentList(pol.allow, convId, lineage.rosterName)) {
1690
+ return pol;
1691
+ }
1692
+ }
1693
+
1694
+ // 3. Verified top-level user conversation (`USER_INPUT` at Step 0) is NEVER subject to another session's child lock
1695
+ if (lineage.isTopLevelUserSession && !lineage.isChildSession) {
1696
+ return null;
1697
+ }
1698
+
1699
+ // 4. Child session with explicit `sender=<parentConvId>` in Step 0/1
1700
+ if (lineage.isChildSession && lineage.parentConvId) {
1701
+ return policies[lineage.parentConvId] || policies["local-operator"] || policies["global"] || null;
1702
+ }
1703
+
1704
+ // 5. Fallback for synthetic test conversationIds (when transcript file does not exist on disk)
1705
+ if (!lineage.transcriptFound) {
1706
+ return (store.activeOwnerId && policies[store.activeOwnerId]) || policyList[0] || null;
1707
+ }
1708
+
1709
+ return null;
1710
+ }
1711
+
1712
+ function evaluateCallerTranscriptAccess(convId) {
1713
+ const lineage = getCallerLineage(convId);
1714
+ const store = readTranscriptAccessStore();
1715
+ const policy = findGoverningPolicyForCaller(store, convId, lineage);
1716
+
1717
+ // Tier 1: Policy owner
1718
+ if (policy && convId && policy.ownerId === convId) {
1719
+ return {
1720
+ allowed: true,
1721
+ mode: "open",
1722
+ source: "owner",
1723
+ isOwner: true,
1724
+ isRestrictedChild: false,
1725
+ rosterName: lineage.rosterName,
1726
+ ownerId: policy.ownerId
1727
+ };
1728
+ }
1729
+
1730
+ // Tier 2: Explicit CLI --deny match
1731
+ if (policy && matchesAgentList(policy.deny, convId, lineage.rosterName)) {
1732
+ return {
1733
+ allowed: false,
1734
+ mode: "deny",
1735
+ source: "cli-deny-list",
1736
+ isOwner: false,
1737
+ isRestrictedChild: true,
1738
+ rosterName: lineage.rosterName,
1739
+ ownerId: policy.ownerId
1740
+ };
1741
+ }
1742
+
1743
+ // Tier 3: Explicit CLI --allow match
1744
+ if (policy && matchesAgentList(policy.allow, convId, lineage.rosterName)) {
1745
+ return {
1746
+ allowed: true,
1747
+ mode: "open",
1748
+ source: "cli-allow-list",
1749
+ isOwner: false,
1750
+ isRestrictedChild: false,
1751
+ rosterName: lineage.rosterName,
1752
+ ownerId: policy.ownerId
1753
+ };
1754
+ }
1755
+
1756
+ // Tier 4: First-line launch directive (`[agy-transcript-access: allow|self-only|deny]`)
1757
+ if (lineage.launchDirective) {
1758
+ if (lineage.launchDirective === "deny") {
1759
+ return {
1760
+ allowed: false,
1761
+ mode: "deny",
1762
+ source: "launch-directive",
1763
+ isOwner: false,
1764
+ isRestrictedChild: true,
1765
+ rosterName: lineage.rosterName,
1766
+ ownerId: policy ? policy.ownerId : lineage.parentConvId
1767
+ };
1768
+ }
1769
+ // If parent updated policy to deny/allowlist AFTER child launch, parent mid-session lock overrides
1770
+ const parentOverrodeMidSession =
1771
+ Boolean(policy) &&
1772
+ (policy.mode === "deny" || policy.mode === "allowlist") &&
1773
+ lineage.birthTimeMs > 0 &&
1774
+ policy.updatedAt > lineage.birthTimeMs + 1000;
1775
+
1776
+ if (parentOverrodeMidSession) {
1777
+ return {
1778
+ allowed: false,
1779
+ mode: policy.mode,
1780
+ source: "cli-policy-override",
1781
+ isOwner: false,
1782
+ isRestrictedChild: true,
1783
+ rosterName: lineage.rosterName,
1784
+ ownerId: policy.ownerId
1785
+ };
1786
+ }
1787
+
1788
+ if (lineage.launchDirective === "self-only") {
1789
+ return {
1790
+ allowed: true,
1791
+ mode: "self-only",
1792
+ source: "launch-directive",
1793
+ isOwner: false,
1794
+ isRestrictedChild: true,
1795
+ rosterName: lineage.rosterName,
1796
+ ownerId: policy ? policy.ownerId : lineage.parentConvId
1797
+ };
1798
+ }
1799
+ if (lineage.launchDirective === "allow") {
1800
+ return {
1801
+ allowed: true,
1802
+ mode: "open",
1803
+ source: "launch-directive",
1804
+ isOwner: false,
1805
+ isRestrictedChild: false,
1806
+ rosterName: lineage.rosterName,
1807
+ ownerId: policy ? policy.ownerId : lineage.parentConvId
1808
+ };
1809
+ }
1810
+ }
1811
+
1812
+ // Tier 5: Cohort default mode from governing parent policy
1813
+ if (policy) {
1814
+ if (policy.mode === "deny" || policy.mode === "allowlist") {
1815
+ return {
1816
+ allowed: false,
1817
+ mode: policy.mode,
1818
+ source: "cli-policy",
1819
+ isOwner: false,
1820
+ isRestrictedChild: true,
1821
+ rosterName: lineage.rosterName,
1822
+ ownerId: policy.ownerId
1823
+ };
1824
+ }
1825
+ if (policy.mode === "self-only") {
1826
+ return {
1827
+ allowed: true,
1828
+ mode: "self-only",
1829
+ source: "cli-policy",
1830
+ isOwner: false,
1831
+ isRestrictedChild: true,
1832
+ rosterName: lineage.rosterName,
1833
+ ownerId: policy.ownerId
1834
+ };
1835
+ }
1836
+ }
1837
+
1838
+ return {
1839
+ allowed: true,
1840
+ mode: "open",
1841
+ source: "default",
1842
+ isOwner: !lineage.isChildSession,
1843
+ isRestrictedChild: false,
1844
+ rosterName: lineage.rosterName,
1845
+ ownerId: policy ? policy.ownerId : null
1846
+ };
1847
+ }
1848
+
1849
+ function canCallerMutateTranscriptAccess(convId) {
1850
+ const lineage = getCallerLineage(convId);
1851
+ if (lineage.isChildSession || lineage.isSwarmChild) {
1852
+ return false;
1853
+ }
1854
+ if (lineage.launchDirective === "deny" || lineage.launchDirective === "self-only") {
1855
+ return false;
1856
+ }
1857
+ const store = readTranscriptAccessStore();
1858
+ const policy = findGoverningPolicyForCaller(store, convId, lineage);
1859
+ if (policy && convId && policy.ownerId !== convId && !lineage.isTopLevelUserSession) {
1860
+ return false;
1861
+ }
1862
+ return true;
1863
+ }
1864
+
1865
+ function extractTargetConvIdFromTranscriptTokens(subcommand, restTokens) {
1866
+ const READ_VALUE_FLAGS = new Set(["id", "mode", "last-turns", "last", "tail", "offset", "limit", "start", "end", "file"]);
1867
+ const QUERY_VALUE_FLAGS = new Set([
1868
+ "id",
1869
+ "query",
1870
+ "roles",
1871
+ "mode",
1872
+ "start",
1873
+ "end",
1874
+ "last-turns",
1875
+ "last",
1876
+ "tail",
1877
+ "limit",
1878
+ "field",
1879
+ "file"
1880
+ ]);
1881
+ const STEP_VALUE_FLAGS = new Set([
1882
+ "id",
1883
+ "step",
1884
+ "start",
1885
+ "end",
1886
+ "field",
1887
+ "mode",
1888
+ "tail",
1889
+ "tail-lines",
1890
+ "head",
1891
+ "head-lines",
1892
+ "file"
1893
+ ]);
1894
+ const HEALTH_VALUE_FLAGS = new Set(["id", "file"]);
1895
+
1896
+ let valueFlags = READ_VALUE_FLAGS;
1897
+ if (subcommand === "query" || subcommand === "grep" || subcommand === "search" || subcommand === "find" || subcommand === "tools") {
1898
+ valueFlags = QUERY_VALUE_FLAGS;
1899
+ } else if (subcommand === "step") {
1900
+ valueFlags = STEP_VALUE_FLAGS;
1901
+ } else if (subcommand === "health" || subcommand === "stats" || subcommand === "info") {
1902
+ valueFlags = HEALTH_VALUE_FLAGS;
1903
+ }
1904
+
1905
+ const opts = {};
1906
+ const positionals = [];
1907
+ for (let i = 0; i < restTokens.length; i++) {
1908
+ const tok = restTokens[i];
1909
+ if (!tok.startsWith("-") || tok === "-") {
1910
+ positionals.push(tok);
1911
+ continue;
1912
+ }
1913
+ if (!tok.startsWith("--")) continue;
1914
+ const eqIdx = tok.indexOf("=");
1915
+ const key = eqIdx === -1 ? tok.slice(2) : tok.slice(2, eqIdx);
1916
+ const inlineVal = eqIdx === -1 ? null : tok.slice(eqIdx + 1);
1917
+ if (valueFlags.has(key)) {
1918
+ if (inlineVal !== null) {
1919
+ opts[key] = inlineVal;
1920
+ } else if (i + 1 < restTokens.length) {
1921
+ opts[key] = restTokens[++i];
1922
+ }
1923
+ }
1924
+ }
1925
+
1926
+ const posCopy = [...positionals];
1927
+ if (subcommand === "query" || subcommand === "grep" || subcommand === "search" || subcommand === "find" || subcommand === "tools") {
1928
+ if (opts.query === undefined && posCopy.length >= 1) {
1929
+ opts.query = posCopy.shift();
1930
+ }
1931
+ if (opts.id === undefined && posCopy.length >= 1) {
1932
+ opts.id = posCopy.shift();
1933
+ }
1934
+ } else if (subcommand === "step") {
1935
+ if (opts.step === undefined && posCopy.length >= 1) {
1936
+ opts.step = posCopy.shift();
1937
+ }
1938
+ if (opts.id === undefined && posCopy.length >= 1) {
1939
+ opts.id = posCopy.shift();
1940
+ }
1941
+ } else if (
1942
+ subcommand === "read" ||
1943
+ subcommand === "outline" ||
1944
+ subcommand === "summary" ||
1945
+ subcommand === "health" ||
1946
+ subcommand === "stats" ||
1947
+ subcommand === "info"
1948
+ ) {
1949
+ if (opts.id === undefined && posCopy.length >= 1) {
1950
+ opts.id = posCopy.shift();
1951
+ }
1952
+ }
1953
+
1954
+ if (posCopy.length > 0) {
1955
+ return {
1956
+ error: `Antigravity Execution Governance: Unexpected positional arguments '${posCopy.join(" ")}' for 'agy-context transcript ${subcommand}'.`
1957
+ };
1958
+ }
1959
+
1960
+ return {
1961
+ targetId: opts.id ? String(opts.id).trim() : null,
1962
+ explicitFile: opts.file ? String(opts.file).trim() : null
1963
+ };
1964
+ }
1965
+
1966
+ function buildTranscriptAccessDenial({ subcommand, mode, source, callerConvId, rosterName, detail }) {
1967
+ const label = rosterName ? `@${rosterName} (${callerConvId})` : callerConvId;
1968
+ const extra = detail ? ` ${detail}` : "";
1969
+ return (
1970
+ `Antigravity Execution Governance: 'agy-context transcript ${subcommand}' is restricted for this session (${label}) by parent governance [mode=${mode}, source=${source}].${extra} ` +
1971
+ `Do NOT retry transcript inspection commands or attempt direct '.system_generated' file/shell reads; proceed with your assigned task using your workspace files and task brief.`
1972
+ );
1973
+ }
1974
+
1975
+ /**
1976
+ * Checks if a command is a valid, strictly single, unchained `agy-context transcript ...` invocation
1977
+ * and enforces parent-scoped transcript access governance.
1978
+ */
1979
+ function checkAgyContextTranscriptCommand(segments, convId = "global") {
1980
+ if (segments.length === 0) return { matched: false, allowed: false };
1981
+ const match = findAgyContextTranscriptUsage(segments);
1982
+ if (!match) {
1983
+ return { matched: false, allowed: false };
1984
+ }
1985
+
1986
+ const {
1987
+ seg,
1988
+ segIndex,
1989
+ totalSegs,
1990
+ hadWrapper,
1991
+ subOffset,
1992
+ subcommand,
1993
+ isReadSub,
1994
+ isHelpSub,
1995
+ isAdminSub,
1996
+ isKnownSubcommand,
1997
+ hasNodeFlags,
1998
+ invalidBinaryPath,
1999
+ tokens
2000
+ } = match;
2001
+
2002
+ // Must be strictly single segment at index 0, unchained, unwrapped, no redirects/heredocs/subshells/node-flags
2003
+ if (
2004
+ totalSegs !== 1 ||
2005
+ segIndex !== 0 ||
2006
+ hadWrapper ||
2007
+ hasNodeFlags ||
2008
+ invalidBinaryPath ||
2009
+ seg.hasRedirect ||
2010
+ seg.hasHeredoc ||
2011
+ seg.hasSubshell
2012
+ ) {
2013
+ return {
2014
+ matched: true,
2015
+ allowed: false,
2016
+ reason:
2017
+ "Antigravity Execution Governance: 'agy-context transcript' access to .system_generated must be strictly single and unchained (no chaining, wrappers, env prefixes, node flags, redirects, heredocs, or subshells)."
2018
+ };
2019
+ }
2020
+
2021
+ if (!isKnownSubcommand) {
2022
+ return {
2023
+ matched: true,
2024
+ allowed: false,
2025
+ reason: `Antigravity Execution Governance: Unknown 'agy-context transcript' subcommand '${subcommand}'. Expected read, query, step, health, list, or access.`
2026
+ };
2027
+ }
2028
+
2029
+ // Validate flags: disallow `..` traversal and `--file` outside <brainDir>
2030
+ for (let i = subOffset + 1; i < tokens.length; i++) {
2031
+ const tok = tokens[i];
2032
+ if (tok === "--file") {
2033
+ const val = tokens[i + 1];
2034
+ if (!val || hasDotDotTraversal(val) || !isPathInsideDir(val, getBrainDir())) {
2035
+ return {
2036
+ matched: true,
2037
+ allowed: false,
2038
+ reason: `Antigravity Execution Governance: '--file' in 'agy-context transcript' must stay inside '${getBrainDir()}' without '..' path traversal.`
2039
+ };
2040
+ }
2041
+ i++;
2042
+ continue;
2043
+ }
2044
+ if (tok.startsWith("--file=")) {
2045
+ const val = tok.slice("--file=".length);
2046
+ if (!val || hasDotDotTraversal(val) || !isPathInsideDir(val, getBrainDir())) {
2047
+ return {
2048
+ matched: true,
2049
+ allowed: false,
2050
+ reason: `Antigravity Execution Governance: '--file' in 'agy-context transcript' must stay inside '${getBrainDir()}' without '..' path traversal.`
2051
+ };
2052
+ }
2053
+ continue;
2054
+ }
2055
+ if (hasDotDotTraversal(tok)) {
2056
+ return {
2057
+ matched: true,
2058
+ allowed: false,
2059
+ reason: "Antigravity Execution Governance: Path traversal ('..') is prohibited in 'agy-context transcript' commands."
2060
+ };
2061
+ }
2062
+ }
2063
+
2064
+ if (isHelpSub) {
2065
+ return { matched: true, allowed: true, subcommand, mode: "open", needsReadGrant: false };
2066
+ }
2067
+
2068
+ const restTokens = tokens.slice(subOffset + 1);
2069
+ if (restTokens.length === 1 && (restTokens[0] === "--help" || restTokens[0] === "-h")) {
2070
+ return { matched: true, allowed: true, subcommand, mode: "open", needsReadGrant: false };
2071
+ }
2072
+
2073
+ if (isAdminSub) {
2074
+ const hasMutationToken = restTokens.some((t) => {
2075
+ const low = String(t).toLowerCase();
2076
+ return (
2077
+ low === "--off" ||
2078
+ low === "--deny-children" ||
2079
+ low === "--on" ||
2080
+ low === "--reset" ||
2081
+ low === "--self-only" ||
2082
+ low === "--mode" ||
2083
+ low.startsWith("--mode=") ||
2084
+ low === "--allow" ||
2085
+ low.startsWith("--allow=") ||
2086
+ low === "--deny" ||
2087
+ low.startsWith("--deny=") ||
2088
+ low === "--ttl" ||
2089
+ low.startsWith("--ttl=") ||
2090
+ low === "off" ||
2091
+ low === "deny" ||
2092
+ low === "lock" ||
2093
+ low === "on" ||
2094
+ low === "open" ||
2095
+ low === "reset" ||
2096
+ low === "unlock" ||
2097
+ low === "self-only"
2098
+ );
2099
+ });
2100
+
2101
+ if (hasMutationToken && !canCallerMutateTranscriptAccess(convId)) {
2102
+ return {
2103
+ matched: true,
2104
+ allowed: false,
2105
+ isAccessDenial: true,
2106
+ reason:
2107
+ "Antigravity Execution Governance: Only the parent/coordinator session may modify 'agy-context transcript access' policy. Child sessions cannot unlock or alter transcript governance."
2108
+ };
2109
+ }
2110
+
2111
+ return {
2112
+ matched: true,
2113
+ allowed: true,
2114
+ subcommand,
2115
+ mode: "open",
2116
+ needsReadGrant: false,
2117
+ isAccessMutation: hasMutationToken
2118
+ };
2119
+ }
2120
+
2121
+ if (isReadSub) {
2122
+ const accessEval = evaluateCallerTranscriptAccess(convId);
2123
+ if (!accessEval.allowed) {
2124
+ return {
2125
+ matched: true,
2126
+ allowed: false,
2127
+ isAccessDenial: true,
2128
+ reason: buildTranscriptAccessDenial({
2129
+ subcommand,
2130
+ mode: accessEval.mode,
2131
+ source: accessEval.source,
2132
+ callerConvId: convId,
2133
+ rosterName: accessEval.rosterName
2134
+ })
2135
+ };
2136
+ }
2137
+
2138
+ const targetInfo = extractTargetConvIdFromTranscriptTokens(subcommand, restTokens);
2139
+ if (targetInfo.error) {
2140
+ return {
2141
+ matched: true,
2142
+ allowed: false,
2143
+ reason: targetInfo.error
2144
+ };
2145
+ }
2146
+
2147
+ if (accessEval.mode === "self-only") {
2148
+ if (subcommand === "list" || subcommand === "ls") {
2149
+ return {
2150
+ matched: true,
2151
+ allowed: false,
2152
+ isAccessDenial: true,
2153
+ reason: buildTranscriptAccessDenial({
2154
+ subcommand,
2155
+ mode: "self-only",
2156
+ source: accessEval.source,
2157
+ callerConvId: convId,
2158
+ rosterName: accessEval.rosterName,
2159
+ detail: "'list'/'ls' across sessions is disabled in self-only mode."
2160
+ })
2161
+ };
2162
+ }
2163
+
2164
+ if (targetInfo.explicitFile) {
2165
+ const selfBrainDir = path.join(getBrainDir(), convId);
2166
+ if (!isPathInsideDir(targetInfo.explicitFile, selfBrainDir)) {
2167
+ return {
2168
+ matched: true,
2169
+ allowed: false,
2170
+ isAccessDenial: true,
2171
+ reason: buildTranscriptAccessDenial({
2172
+ subcommand,
2173
+ mode: "self-only",
2174
+ source: accessEval.source,
2175
+ callerConvId: convId,
2176
+ rosterName: accessEval.rosterName,
2177
+ detail: `Cross-session '--file' access outside '${convId}' is disabled in self-only mode.`
2178
+ })
2179
+ };
2180
+ }
2181
+ }
2182
+
2183
+ if (targetInfo.targetId && targetInfo.targetId.toLowerCase() !== "current" && targetInfo.targetId !== convId) {
2184
+ return {
2185
+ matched: true,
2186
+ allowed: false,
2187
+ isAccessDenial: true,
2188
+ reason: buildTranscriptAccessDenial({
2189
+ subcommand,
2190
+ mode: "self-only",
2191
+ source: accessEval.source,
2192
+ callerConvId: convId,
2193
+ rosterName: accessEval.rosterName,
2194
+ detail: `Cross-session inspection of '${targetInfo.targetId}' is disabled in self-only mode (you may only inspect your own session '${convId}').`
2195
+ })
2196
+ };
2197
+ }
2198
+
2199
+ const needsSelfIdInjection =
2200
+ !targetInfo.explicitFile &&
2201
+ (!targetInfo.targetId || targetInfo.targetId.toLowerCase() === "current") &&
2202
+ Boolean(convId && convId !== "global");
2203
+
2204
+ return {
2205
+ matched: true,
2206
+ allowed: true,
2207
+ subcommand,
2208
+ mode: "self-only",
2209
+ targetConvId: convId,
2210
+ needsReadGrant: true,
2211
+ injectSelfId: needsSelfIdInjection ? convId : null
2212
+ };
2213
+ }
2214
+
2215
+ let resolvedTargetConvId = targetInfo.targetId || null;
2216
+ if (targetInfo.explicitFile) {
2217
+ if (accessEval.ownerId && !accessEval.isOwner && !isPathInsideDir(targetInfo.explicitFile, getBrainDir())) {
2218
+ return {
2219
+ matched: true,
2220
+ allowed: false,
2221
+ isAccessDenial: true,
2222
+ reason: buildTranscriptAccessDenial({
2223
+ subcommand,
2224
+ mode: accessEval.mode,
2225
+ source: accessEval.source,
2226
+ callerConvId: convId,
2227
+ rosterName: accessEval.rosterName,
2228
+ detail: `'--file' paths outside '${getBrainDir()}' are disabled while a parent transcript access policy is active.`
2229
+ })
2230
+ };
2231
+ }
2232
+ if (isPathInsideDir(targetInfo.explicitFile, getBrainDir())) {
2233
+ const rel = path.relative(
2234
+ tryRealpath(path.resolve(getBrainDir())),
2235
+ tryRealpath(path.resolve(targetInfo.explicitFile))
2236
+ );
2237
+ const firstSeg = rel.split(path.sep)[0];
2238
+ if (firstSeg && firstSeg !== "." && firstSeg !== "..") {
2239
+ resolvedTargetConvId = firstSeg;
2240
+ }
2241
+ }
2242
+ }
2243
+
2244
+ return {
2245
+ matched: true,
2246
+ allowed: true,
2247
+ subcommand,
2248
+ mode: "open",
2249
+ needsReadGrant: true,
2250
+ targetConvId: resolvedTargetConvId
2251
+ };
2252
+ }
2253
+
2254
+ return { matched: true, allowed: true, subcommand, mode: "open", needsReadGrant: false };
2255
+ }
2256
+
2257
+ /**
2258
+ * Extracts redirect target paths from a segment's unquoted tokens.
2259
+ */
2260
+ function extractRedirectTargets(tokens) {
2261
+ const targets = [];
2262
+ for (let i = 0; i < tokens.length; i++) {
2263
+ const tok = tokens[i];
2264
+ if (!tok.quoted && REDIRECT_OP_REGEX.test(tok.raw)) {
2265
+ if (tokens[i + 1]) targets.push(tokens[i + 1].value);
2266
+ continue;
2267
+ }
2268
+ const attached = tok.raw.match(REDIRECT_ATTACHED_REGEX);
2269
+ if (attached && !tok.quoted) {
2270
+ targets.push(attached[1].replace(/^["']|["']$/g, ""));
2271
+ }
2272
+ }
2273
+ return targets;
2274
+ }
2275
+
2276
+ const SEARCH_COMMANDS = new Set(["grep", "egrep", "fgrep", "rg", "ripgrep", "ag", "ack", "select-string"]);
2277
+ const SEARCH_VALUE_FLAGS = new Set([
2278
+ "-e", "--regexp", "-f", "--file", "-m", "--max-count",
2279
+ "-A", "--after-context", "-B", "--before-context", "-C", "--context",
2280
+ "-t", "--type", "-T", "--type-not", "-g", "--glob", "--iglob",
2281
+ "-M", "--max-columns", "--max-filesize", "--color", "-j", "--threads",
2282
+ "-r", "--replace", "--sort", "--sortr"
2283
+ ]);
2284
+
2285
+ /**
2286
+ * Extracts inspectable path tokens for a segment, ignoring benign echo/printf text
2287
+ * and ignoring literal search patterns on grep/rg commands (inspecting only target paths).
2288
+ */
2289
+ function getInspectableTextForSegment(seg, opts = {}) {
2290
+ const { body } = splitEnvPrefix(seg.text);
2291
+ const allTokens = tokenizeShellWordsDetailed(seg.text);
2292
+ const detailed = body ? tokenizeShellWordsDetailed(body) : allTokens;
2293
+ if (detailed.length === 0) return "";
2294
+
2295
+ const cmd0 = normalizeCmdName(detailed[0].value);
2296
+ const cmd1 = detailed[1] ? normalizeCmdName(detailed[1].value) : "";
2297
+
2298
+ // Benign standalone echo / printf: only inspect redirect targets (never printed prose),
2299
+ // UNLESS piped into a downstream command (e.g. `echo <b64> | base64 -d | xargs head`)
2300
+ if (!opts.hasDownstreamPipe && (cmd0 === "echo" || cmd0 === "printf" || cmd0 === "write-output")) {
2301
+ const redirects = extractRedirectTargets(allTokens);
2302
+ return redirects.join(" ");
2303
+ }
2304
+
2305
+ // Search commands: skip pattern argument and inspect only target paths / -f files / redirects
2306
+ let argStart = -1;
2307
+ if (SEARCH_COMMANDS.has(cmd0)) {
2308
+ argStart = 1;
2309
+ } else if ((cmd0 === "rtk" && (cmd1 === "grep" || cmd1 === "rg")) || (cmd0 === "git" && cmd1 === "grep")) {
2310
+ argStart = 2;
2311
+ }
2312
+
2313
+ if (argStart !== -1) {
2314
+ const targets = extractRedirectTargets(allTokens);
2315
+ let hasExplicitPattern = false;
2316
+ let afterDoubleDash = false;
2317
+ const positionals = [];
2318
+
2319
+ for (let i = argStart; i < detailed.length; i++) {
2320
+ const tok = detailed[i];
2321
+ if (!tok.quoted && REDIRECT_OP_REGEX.test(tok.raw)) {
2322
+ i++; // skip redirect target (already in targets)
2323
+ continue;
2324
+ }
2325
+ if (!tok.quoted && REDIRECT_ATTACHED_REGEX.test(tok.raw)) {
2326
+ continue;
2327
+ }
2328
+ const val = tok.value;
2329
+ if (!afterDoubleDash && val === "--") {
2330
+ afterDoubleDash = true;
2331
+ continue;
2332
+ }
2333
+ if (!afterDoubleDash && val.startsWith("-") && val !== "-") {
2334
+ if (val === "--regexp" || val.startsWith("--regexp=")) {
2335
+ hasExplicitPattern = true;
2336
+ if (val === "--regexp" && i + 1 < detailed.length) i++;
2337
+ continue;
2338
+ }
2339
+ if (val === "-f" || val === "--file" || (!val.startsWith("--") && /^-[^-]*f$/.test(val))) {
2340
+ if (detailed[i + 1]) targets.push(detailed[i + 1].value);
2341
+ i++;
2342
+ hasExplicitPattern = true;
2343
+ continue;
2344
+ }
2345
+ if (!val.startsWith("--") && /^-([^-]*)f(.+)$/.test(val)) {
2346
+ const fIdx = val.indexOf("f");
2347
+ targets.push(val.slice(fIdx + 1));
2348
+ hasExplicitPattern = true;
2349
+ continue;
2350
+ }
2351
+ if (val.startsWith("--file=")) {
2352
+ targets.push(val.slice("--file=".length));
2353
+ hasExplicitPattern = true;
2354
+ continue;
2355
+ }
2356
+ if (!val.startsWith("--") && /^-[^-]*e/.test(val)) {
2357
+ hasExplicitPattern = true;
2358
+ if (/^-[^-]*e$/.test(val) && i + 1 < detailed.length) i++;
2359
+ continue;
2360
+ }
2361
+ if (SEARCH_VALUE_FLAGS.has(val) && i + 1 < detailed.length) {
2362
+ i++;
2363
+ }
2364
+ continue;
2365
+ }
2366
+ positionals.push(val);
2367
+ }
2368
+
2369
+ // First positional is the pattern unless -e / -f was provided
2370
+ const filePositionals = hasExplicitPattern ? positionals : positionals.slice(1);
2371
+ targets.push(...filePositionals);
2372
+ return targets.join(" ");
2373
+ }
2374
+
2375
+ const unquotedJoined = allTokens.map((t) => t.value).join(" ");
2376
+ return unquotedJoined && unquotedJoined !== seg.text ? `${seg.text}\n${unquotedJoined}` : seg.text;
2377
+ }
2378
+
2379
+ const SHELL_INTERPRETERS = new Set(["bash", "sh", "zsh", "dash", "ksh", "eval"]);
2380
+ const CODE_INTERPRETERS = new Set(["python", "python2", "python3", "node", "perl", "ruby", "php", "pwsh", "powershell"]);
2381
+ const CODE_FILE_ACCESS_REGEX = /\b(?:open|read|readFile|readFileSync|createReadStream|readdir|listdir|scandir|walk|glob|iglob|readlines|Path|subprocess|popen|system|exec|spawn|eval|require|import|from\s+\w+\s+import|__import__|getattr|b64decode|atob|fs\.|os\.|io\.|path\.)\b|Buffer\.from/i;
2382
+
2383
+ function getSegmentCmdName(seg) {
2384
+ const { body } = splitEnvPrefix(seg.text || "");
2385
+ const tokens = tokenizeShellWords(body || seg.text || "");
2386
+ return tokens.length > 0 ? normalizeCmdName(tokens[0]) : "";
2387
+ }
2388
+
2389
+ /**
2390
+ * Recursively collects inspectable command texts (main segment, unquoted forms,
2391
+ * bash/sh/eval inner scripts, nested subshells, and interpreter/xargs heredoc bodies).
2392
+ * Plain documentation heredocs (e.g. `cat > file <<'EOF'`) and non-code prompt text remain exempt.
2393
+ */
2394
+ function collectInspectableTexts(segments, depth = 0, options = {}) {
2395
+ if (depth > 4) return [];
2396
+ const texts = [];
2397
+ const isRestrictedChild = Boolean(options.isRestrictedChild);
2398
+ const osTmpNorm = os.tmpdir().replace(/\\/g, "/");
2399
+
2400
+ for (let idx = 0; idx < segments.length; idx++) {
2401
+ const seg = segments[idx];
2402
+ const segCmd = getSegmentCmdName(seg);
2403
+ const pipelineCmds = [segCmd];
2404
+ for (let j = idx + 1; j < segments.length; j++) {
2405
+ const nextSeg = segments[j];
2406
+ if (nextSeg.leadingSep !== "|" && nextSeg.leadingSep !== "|&") break;
2407
+ pipelineCmds.push(getSegmentCmdName(nextSeg));
2408
+ }
2409
+ const downstreamCmds = pipelineCmds.slice(1);
2410
+
2411
+ const inspectable = getInspectableTextForSegment(seg, { hasDownstreamPipe: downstreamCmds.length > 0 });
2412
+ if (inspectable) texts.push(inspectable);
2413
+
2414
+ const wrapped = extractWrappedScriptInfo(seg.text);
2415
+ if (wrapped && wrapped.innerScript) {
2416
+ texts.push(...collectInspectableTexts(splitShellSegments(wrapped.innerScript), depth + 1, options));
2417
+ }
2418
+
2419
+ if (Array.isArray(seg.subshells)) {
2420
+ for (const sub of seg.subshells) {
2421
+ const subSegs = splitShellSegments(sub);
2422
+ texts.push(...collectInspectableTexts(subSegs, depth + 1, options));
2423
+ }
2424
+ }
2425
+
2426
+ if (segCmd === "base64" && downstreamCmds.some((c) => SHELL_INTERPRETERS.has(c) || CODE_INTERPRETERS.has(c))) {
2427
+ texts.push("base64 | sh");
2428
+ }
2429
+
2430
+ // Inspect on-disk script files executed by shell/code interpreters (when in /tmp, /scratch, non-script extension, or restricted child)
2431
+ if (SHELL_INTERPRETERS.has(segCmd) || CODE_INTERPRETERS.has(segCmd)) {
2432
+ const segTokens = tokenizeShellWords(seg.text || "");
2433
+ for (let tIdx = 1; tIdx < segTokens.length; tIdx++) {
2434
+ let tok = String(segTokens[tIdx] || "").trim();
2435
+ if (!tok || tok.startsWith("-")) continue;
2436
+ if (tok.startsWith("<")) tok = tok.slice(1).trim();
2437
+ if (!tok || isAllowedAgyContextScriptPath(tok)) continue;
2438
+ try {
2439
+ const resolvedFile = path.resolve(tok);
2440
+ const normFile = resolvedFile.replace(/\\/g, "/");
2441
+ const isTmpOrScratchFile =
2442
+ normFile.startsWith("/tmp/") ||
2443
+ normFile.startsWith("/var/tmp/") ||
2444
+ normFile.startsWith(osTmpNorm + "/") ||
2445
+ normFile.includes("/scratch/");
2446
+ const isNonScriptExt = !isExecutableScriptPath(normFile);
2447
+ if (isTmpOrScratchFile || isNonScriptExt || isRestrictedChild) {
2448
+ if (fs.existsSync(resolvedFile)) {
2449
+ const st = fs.statSync(resolvedFile);
2450
+ if (st.isFile() && st.size > 0 && st.size <= 131072) {
2451
+ texts.push(fs.readFileSync(resolvedFile, "utf-8"));
2452
+ }
2453
+ }
2454
+ }
2455
+ } catch {}
2456
+ }
2457
+ }
2458
+
2459
+ if (Array.isArray(seg.heredocBodies) && seg.heredocBodies.length > 0) {
2460
+ const hasShellConsumer = pipelineCmds.some((c) => SHELL_INTERPRETERS.has(c));
2461
+ const hasXargsConsumer = pipelineCmds.some((c) => c === "xargs");
2462
+ const hasCodeConsumer = !seg.hasWriteRedirect && pipelineCmds.some((c) => CODE_INTERPRETERS.has(c));
2463
+ let writesExecutableOrTmpScript = false;
2464
+ if (seg.hasWriteRedirect) {
2465
+ const redirectTargets = extractRedirectTargets(tokenizeShellWordsDetailed(seg.text || ""));
2466
+ writesExecutableOrTmpScript = redirectTargets.some((t) => {
2467
+ const normT = String(t || "").replace(/\\/g, "/");
2468
+ return (
2469
+ isExecutableScriptPath(normT) ||
2470
+ normT.startsWith("/tmp/") ||
2471
+ normT.startsWith("/var/tmp/") ||
2472
+ normT.startsWith(osTmpNorm + "/")
2473
+ );
2474
+ });
2475
+ }
2476
+
2477
+ for (const hdBody of seg.heredocBodies) {
2478
+ if (!hdBody) continue;
2479
+ if (hasShellConsumer) {
2480
+ texts.push(...collectInspectableTexts(splitShellSegments(hdBody), depth + 1, options));
2481
+ } else if (hasXargsConsumer) {
2482
+ texts.push(hdBody);
2483
+ } else if (hasCodeConsumer && CODE_FILE_ACCESS_REGEX.test(hdBody)) {
2484
+ texts.push(hdBody);
2485
+ } else if (writesExecutableOrTmpScript || /^\s*#!/.test(hdBody)) {
2486
+ texts.push(hdBody);
2487
+ }
2488
+ }
2489
+ }
2490
+ }
2491
+ return texts;
2492
+ }
2493
+
2494
+ function isRtkReadCommand(cmd) {
2495
+ const tokens = tokenizeShellWords(cmd);
2496
+ return tokens.length >= 2 && normalizeCmdName(tokens[0]) === "rtk" && tokens[1].toLowerCase() === "read";
2497
+ }
2498
+
2499
+ function isSingleUnchainedShellCmd(segments) {
2500
+ if (segments.length !== 1) return false;
2501
+ const seg = segments[0];
2502
+ return !seg.hasRedirect && !seg.hasHeredoc && !seg.hasSubshell && !hasDotDotTraversal(seg.text);
2503
+ }
2504
+
2505
+ // ============================================================================
2506
+ // RTK Post-Processing (wc/ls/diff revert & rg -> rtk grep fallback)
2507
+ // ============================================================================
2508
+
2509
+ const RG_GREP_ZERO_ARG_FLAGS = new Map([
2510
+ ["-i", "-i"], ["--ignore-case", "-i"],
2511
+ ["-n", "-n"], ["--line-number", "-n"],
2512
+ ["-l", "-l"], ["--files-with-matches", "-l"],
2513
+ ["-c", "-c"], ["--count", "-c"],
2514
+ ["-v", "-v"], ["--invert-match", "-v"],
2515
+ ["-w", "-w"], ["--word-regexp", "-w"],
2516
+ ["-x", "-x"], ["--line-regexp", "-x"],
2517
+ ["-F", "-F"], ["--fixed-strings", "-F"],
2518
+ ["-E", "-E"], ["--extended-regexp", "-E"],
2519
+ ["-P", "-P"], ["--pcre2", "-P"],
2520
+ ["-a", "-a"], ["--text", "-a"],
2521
+ ["-0", "-Z"], ["--null", "-Z"],
2522
+ ["-H", "-H"], ["--with-filename", "-H"],
2523
+ ["-h", "-h"], ["--no-filename", "-h"], ["-I", "-h"],
2524
+ ["-q", "-q"], ["--quiet", "-q"],
2525
+ ["-s", "-s"], ["--no-messages", "-s"],
2526
+ ["-o", "-o"], ["--only-matching", "-o"]
2527
+ ]);
2528
+
2529
+ const RG_NOOP_FLAGS = new Set([
2530
+ "--no-heading", "--heading", "-p", "--pretty", "--line-buffered",
2531
+ "-U", "--multiline", "--multiline-dotall",
2532
+ "-u", "-uu", "-uuu", "--unrestricted",
2533
+ "--no-ignore", "--no-ignore-vcs", "--no-ignore-parent", "--no-ignore-global",
2534
+ "--hidden", "-.", "--column", "--vimgrep", "--stats", "--json",
2535
+ "--passthru", "--passthrough", "--no-config", "--one-file-system", "--sort-files"
2536
+ ]);
2537
+
2538
+ const RG_GREP_VALUE_FLAGS = new Map([
2539
+ ["-m", "-m"], ["--max-count", "--max-count"],
2540
+ ["-A", "-A"], ["--after-context", "-A"],
2541
+ ["-B", "-B"], ["--before-context", "-B"],
2542
+ ["-C", "-C"], ["--context", "-C"],
2543
+ ["-e", "-e"], ["--regexp", "-e"]
2544
+ ]);
2545
+
2546
+ function hasPcreOnlyPattern(pattern) {
2547
+ if (!pattern) return false;
2548
+ return /\\[dDsSwW]|\(\?[=!<:#]|\\[pP]\{/.test(pattern);
2549
+ }
2550
+
2551
+ const RG_TYPE_INCLUDE_MAP = {
2552
+ js: ["*.js", "*.mjs", "*.cjs", "*.jsx"],
2553
+ mjs: ["*.mjs"],
2554
+ cjs: ["*.cjs"],
2555
+ ts: ["*.ts", "*.mts", "*.cts", "*.tsx"],
2556
+ py: ["*.py", "*.pyi"],
2557
+ json: ["*.json"],
2558
+ md: ["*.md"],
2559
+ sh: ["*.sh", "*.bash"],
2560
+ yaml: ["*.yml", "*.yaml"],
2561
+ yml: ["*.yml", "*.yaml"],
2562
+ txt: ["*.txt"],
2563
+ css: ["*.css"],
2564
+ html: ["*.html", "*.htm"],
2565
+ rs: ["*.rs"],
2566
+ go: ["*.go"]
2567
+ };
2568
+
2569
+ function quotePosixGrepFlagVal(val) {
2570
+ if (/^[A-Za-z0-9_.*?,/-]+$/.test(val)) return val;
2571
+ return `'${val.replace(/'/g, "'\\''")}'`;
2572
+ }
2573
+
2574
+ function rewriteRtkRgSegment(rtkPrefixWord, restAfterRtk, { inPipeline = false } = {}) {
2575
+ const afterRgMatch = restAfterRtk.match(/^rg(?:\.exe)?\s+([\s\S]+)$/i);
2576
+ if (!afterRgMatch) return restAfterRtk;
2577
+
2578
+ const tokens = tokenizeShellWordsDetailed(afterRgMatch[1]);
2579
+ const grepFlags = [];
2580
+ const positionals = [];
2581
+ const patterns = [];
2582
+ let hasExtended = false;
2583
+ let hasFixed = false;
2584
+ let hasSmartCase = false;
2585
+ let hasIgnoreCase = false;
2586
+ let hasFollow = false;
2587
+ let filesOnly = false;
2588
+ let hasExplicitPattern = false;
2589
+ let afterDoubleDash = false;
2590
+
2591
+ for (let i = 0; i < tokens.length; i++) {
2592
+ const tok = tokens[i];
2593
+ const val = tok.value;
2594
+
2595
+ if (!afterDoubleDash && val === "--") {
2596
+ afterDoubleDash = true;
2597
+ continue;
2598
+ }
2599
+
2600
+ if (!afterDoubleDash && val.startsWith("-") && val !== "-") {
2601
+ if (val === "--files") {
2602
+ filesOnly = true;
2603
+ continue;
2604
+ }
2605
+ if (val === "-S" || val === "--smart-case") {
2606
+ hasSmartCase = true;
2607
+ continue;
2608
+ }
2609
+ if (val === "-L" || val === "--follow") {
2610
+ hasFollow = true;
2611
+ continue;
2612
+ }
2613
+ if (RG_NOOP_FLAGS.has(val)) {
2614
+ continue;
2615
+ }
2616
+ if (RG_GREP_ZERO_ARG_FLAGS.has(val)) {
2617
+ const mapped = RG_GREP_ZERO_ARG_FLAGS.get(val);
2618
+ if (mapped === "-i") hasIgnoreCase = true;
2619
+ if (mapped === "-E" || mapped === "-P") hasExtended = true;
2620
+ if (mapped === "-F") hasFixed = true;
2621
+ grepFlags.push(mapped);
2622
+ continue;
2623
+ }
2624
+
2625
+ if (RG_GREP_VALUE_FLAGS.has(val)) {
2626
+ if (i + 1 >= tokens.length) return restAfterRtk;
2627
+ const nextTok = tokens[++i];
2628
+ const mapped = RG_GREP_VALUE_FLAGS.get(val);
2629
+ if (mapped === "-e") {
2630
+ hasExplicitPattern = true;
2631
+ patterns.push(nextTok.value);
2632
+ }
2633
+ grepFlags.push(mapped, nextTok.raw);
2634
+ continue;
2635
+ }
2636
+
2637
+ if (val === "-g" || val === "--glob" || val.startsWith("--glob=")) {
2638
+ let globVal;
2639
+ if (val.startsWith("--glob=")) {
2640
+ globVal = val.slice("--glob=".length);
2641
+ } else {
2642
+ if (i + 1 >= tokens.length) return restAfterRtk;
2643
+ globVal = tokens[++i].value;
2644
+ }
2645
+ if (globVal.startsWith("!")) {
2646
+ grepFlags.push(`--exclude=${quotePosixGrepFlagVal(globVal.slice(1))}`);
2647
+ } else {
2648
+ grepFlags.push(`--include=${quotePosixGrepFlagVal(globVal)}`);
2649
+ }
2650
+ continue;
2651
+ }
2652
+
2653
+ if (val === "-t" || val === "--type" || val.startsWith("--type=") || val === "-T" || val === "--type-not" || val.startsWith("--type-not=")) {
2654
+ const isNot = val === "-T" || val.startsWith("--type-not");
2655
+ let typeVal;
2656
+ if (val.includes("=")) {
2657
+ typeVal = val.slice(val.indexOf("=") + 1).toLowerCase();
2658
+ } else {
2659
+ if (i + 1 >= tokens.length) return restAfterRtk;
2660
+ typeVal = tokens[++i].value.toLowerCase();
2661
+ }
2662
+ const exts = RG_TYPE_INCLUDE_MAP[typeVal];
2663
+ if (!exts) return restAfterRtk;
2664
+ for (const ext of exts) {
2665
+ grepFlags.push(`${isNot ? "--exclude" : "--include"}=${ext}`);
2666
+ }
2667
+ continue;
2668
+ }
2669
+
2670
+ // Attached short flags like -in, -ni, -lw, -nS, -nU (exclude -r which is --replace in rg!)
2671
+ if (/^-[inlcvwxFEHhqsoSLPaUup]+$/.test(val)) {
2672
+ let clean = "-";
2673
+ for (const ch of val.slice(1)) {
2674
+ if (ch === "S") {
2675
+ hasSmartCase = true;
2676
+ continue;
2677
+ }
2678
+ if (ch === "L") {
2679
+ hasFollow = true;
2680
+ continue;
2681
+ }
2682
+ if (ch === "U" || ch === "u" || ch === "p") {
2683
+ continue;
2684
+ }
2685
+ if (ch === "i") hasIgnoreCase = true;
2686
+ if (ch === "E" || ch === "P") hasExtended = true;
2687
+ if (ch === "F") hasFixed = true;
2688
+ clean += ch;
2689
+ }
2690
+ if (clean.length > 1) grepFlags.push(clean);
2691
+ continue;
2692
+ }
2693
+
2694
+ const eqIdx = val.indexOf("=");
2695
+ if (eqIdx !== -1) {
2696
+ const flagName = val.slice(0, eqIdx);
2697
+ const flagVal = val.slice(eqIdx + 1);
2698
+ if (RG_GREP_VALUE_FLAGS.has(flagName)) {
2699
+ if (flagName === "--regexp") {
2700
+ hasExplicitPattern = true;
2701
+ patterns.push(flagVal);
2702
+ }
2703
+ grepFlags.push(tok.raw);
2704
+ continue;
2705
+ }
2706
+ if (flagName === "--color") {
2707
+ grepFlags.push(tok.raw);
2708
+ continue;
2709
+ }
2710
+ }
2711
+
2712
+ return restAfterRtk;
2713
+ }
2714
+
2715
+ positionals.push(tok);
2716
+ }
2717
+
2718
+ if (filesOnly) {
2719
+ const findTargets = positionals.map((p) => p.raw).join(" ") || ".";
2720
+ return `${rtkPrefixWord}find ${findTargets}`;
2721
+ }
2722
+
2723
+ if (!hasExplicitPattern) {
2724
+ if (positionals.length === 0) return restAfterRtk;
2725
+ patterns.push(positionals[0].value);
2726
+ }
2727
+
2728
+ const needsPcre = !hasFixed && patterns.some(hasPcreOnlyPattern);
2729
+ if (needsPcre && os.platform() !== "linux") {
2730
+ return restAfterRtk;
2731
+ }
2732
+
2733
+ const outArgs = [];
2734
+ if (!inPipeline) {
2735
+ outArgs.push(hasFollow ? "-R" : "-r");
2736
+ }
2737
+ if (hasSmartCase && !hasIgnoreCase && !patterns.some((p) => /[A-Z]/.test(p))) {
2738
+ outArgs.push("-i");
2739
+ }
2740
+ if (needsPcre) {
2741
+ outArgs.push("-P");
2742
+ } else if (!hasFixed && !hasExtended) {
2743
+ outArgs.push("-E");
2744
+ }
2745
+ outArgs.push(...grepFlags);
2746
+
2747
+ if (afterDoubleDash) {
2748
+ outArgs.push("--");
2749
+ }
2750
+
2751
+ for (const p of positionals) {
2752
+ outArgs.push(p.raw);
2753
+ }
2754
+
2755
+ const hasExplicitPath = hasExplicitPattern ? positionals.length > 0 : positionals.length > 1;
2756
+ if (!inPipeline && !hasExplicitPath) {
2757
+ outArgs.push(".");
2758
+ }
2759
+
2760
+ return `${rtkPrefixWord}grep ${outArgs.join(" ")}`;
2761
+ }
2762
+
2763
+ const GREP_VALUE_FLAGS = new Set([
2764
+ "-e", "--regexp", "-f", "--file", "-m", "--max-count",
2765
+ "-A", "--after-context", "-B", "--before-context", "-C", "--context",
2766
+ "-d", "--directories", "--include", "--exclude", "--exclude-dir", "--color", "-D", "--devices"
2767
+ ]);
2768
+
2769
+ /**
2770
+ * Normalizes `rtk grep` when host `rg` is absent so GNU `grep` receives `-r` (when targeting dirs/omitted)
2771
+ * and `-E` (for alternation/quantifiers like `a|b`) instead of failing with exit 2 ("Is a directory")
2772
+ * or exit 1 (BRE literal `|`).
2773
+ */
2774
+ function rewriteRtkGrepSegment(rtkPrefixWord, restAfterRtk, { inPipeline = false } = {}) {
2775
+ const afterGrepMatch = restAfterRtk.match(/^grep(?:\.exe)?\s+([\s\S]+)$/i);
2776
+ if (!afterGrepMatch) return `${rtkPrefixWord}${restAfterRtk}`;
2777
+
2778
+ const tokens = tokenizeShellWordsDetailed(afterGrepMatch[1]);
2779
+ let hasRecursive = false;
2780
+ let hasRegexMode = false;
2781
+ let hasExplicitPattern = false;
2782
+ let afterDoubleDash = false;
2783
+ const positionals = [];
2784
+ const patterns = [];
2785
+
2786
+ for (let i = 0; i < tokens.length; i++) {
2787
+ const val = tokens[i].value;
2788
+ if (!afterDoubleDash && val === "--") {
2789
+ afterDoubleDash = true;
2790
+ continue;
2791
+ }
2792
+ if (!afterDoubleDash && val.startsWith("-") && val !== "-") {
2793
+ if (val === "--recursive" || val === "--dereference-recursive" || val === "--directories" || val.startsWith("--directories=")) {
2794
+ hasRecursive = true;
2795
+ }
2796
+ if (
2797
+ val === "--extended-regexp" ||
2798
+ val === "--fixed-strings" ||
2799
+ val === "--basic-regexp" ||
2800
+ val === "--perl-regexp"
2801
+ ) {
2802
+ hasRegexMode = true;
2803
+ }
2804
+ if (val === "--regexp" || val.startsWith("--regexp=") || val === "--file" || val.startsWith("--file=")) {
2805
+ hasExplicitPattern = true;
2806
+ if (val.startsWith("--regexp=")) patterns.push(val.slice("--regexp=".length));
2807
+ }
2808
+ if (!val.startsWith("--")) {
2809
+ if (/[rR]/.test(val.slice(1))) hasRecursive = true;
2810
+ if (/[EFGP]/.test(val.slice(1))) hasRegexMode = true;
2811
+ if (/[ef]/.test(val.slice(1))) hasExplicitPattern = true;
2812
+ const lastCh = val[val.length - 1];
2813
+ if ((lastCh === "e" || lastCh === "f" || lastCh === "m" || lastCh === "A" || lastCh === "B" || lastCh === "C" || lastCh === "d" || lastCh === "D") && i + 1 < tokens.length) {
2814
+ if (lastCh === "e") patterns.push(tokens[i + 1].value);
2815
+ i++;
2816
+ continue;
2817
+ }
2818
+ } else if (GREP_VALUE_FLAGS.has(val) && i + 1 < tokens.length) {
2819
+ if (val === "--regexp") patterns.push(tokens[i + 1].value);
2820
+ i++;
2821
+ }
2822
+ continue;
2823
+ }
2824
+ positionals.push(tokens[i]);
2825
+ }
2826
+
2827
+ if (!hasExplicitPattern && positionals.length > 0) {
2828
+ patterns.push(positionals[0].value);
2829
+ }
2830
+ if (patterns.length === 0) {
2831
+ return `${rtkPrefixWord}${restAfterRtk}`;
2832
+ }
2833
+
2834
+ const pathPositionals = hasExplicitPattern ? positionals : positionals.slice(1);
2835
+ const needsExplicitDot = !inPipeline && pathPositionals.length === 0;
2836
+ let allExistingRegularFiles = false;
2837
+ if (!inPipeline && pathPositionals.length > 0) {
2838
+ allExistingRegularFiles = pathPositionals.every((p) => {
2839
+ try {
2840
+ return fs.statSync(p.value).isFile();
2841
+ } catch {
2842
+ return false;
2843
+ }
2844
+ });
2845
+ }
2846
+
2847
+ const injectFlags = [];
2848
+ if (!inPipeline && !hasRecursive && !allExistingRegularFiles) {
2849
+ injectFlags.push("-r");
2850
+ }
2851
+ if (!hasRegexMode) {
2852
+ if (os.platform() === "linux" && patterns.some(hasPcreOnlyPattern)) {
2853
+ injectFlags.push("-P");
2854
+ } else {
2855
+ injectFlags.push("-E");
2856
+ }
2857
+ }
2858
+
2859
+ if (injectFlags.length === 0 && !needsExplicitDot) {
2860
+ return `${rtkPrefixWord}${restAfterRtk}`;
2861
+ }
384
2862
 
385
- function readRewriteCacheFile() {
386
- try {
387
- const data = JSON.parse(fs.readFileSync(REWRITE_CACHE_FILE, "utf-8"));
388
- if (data && typeof data === "object" && !Array.isArray(data)) return data;
389
- } catch {}
390
- return {};
2863
+ const flagPrefix = injectFlags.length > 0 ? `${injectFlags.join(" ")} ` : "";
2864
+ const dotSuffix = needsExplicitDot ? " ." : "";
2865
+ return `${rtkPrefixWord}grep ${flagPrefix}${afterGrepMatch[1]}${dotSuffix}`;
391
2866
  }
392
2867
 
393
- function loadRewriteCache() {
394
- if (!inMemoryRewriteCache) inMemoryRewriteCache = readRewriteCacheFile();
395
- return inMemoryRewriteCache;
2868
+ function unwrapShellWrapper(segText) {
2869
+ const m = segText.match(
2870
+ /^((?:cmd(?:\.exe)?\s+\/[cC]|(?:powershell|pwsh)(?:\.exe)?\s+(?:-Command|-c)|(?:bash|sh)\s+-c)\s+)(["'])([\s\S]*)\2$/i
2871
+ );
2872
+ if (!m) return null;
2873
+ return { prefix: m[1], quote: m[2], inner: m[3] };
396
2874
  }
397
2875
 
398
- function resolveCompatibleRtk() {
399
- const localCandidate = getLocalRtkCandidate();
400
- const pathCandidate = findFirstRtkOnPath();
401
- const envSig = `${pathCandidate}::${localCandidate}`;
402
- const cache = loadRewriteCache();
403
- if (cache.__envSig === envSig && cache.__rtkResolved !== undefined) {
404
- return cache.__rtkResolved;
2876
+ function postProcessSegmentText(rawSegText, effSegText, { rgAvailable, inPipeline, rtkPrefix }) {
2877
+ const trimmedEff = effSegText.trim();
2878
+ const trimmedRaw = (rawSegText || "").trim();
2879
+
2880
+ // Recursively handle shell wrappers (e.g., bash -c "rtk wc -l foo")
2881
+ const effWrap = unwrapShellWrapper(trimmedEff);
2882
+ if (effWrap) {
2883
+ const rawWrap = unwrapShellWrapper(trimmedRaw);
2884
+ const processedInner = postProcessCommand(
2885
+ rawWrap ? rawWrap.inner : effWrap.inner,
2886
+ effWrap.inner,
2887
+ { rgAvailable, rtkPrefix }
2888
+ );
2889
+ return `${effWrap.prefix}${effWrap.quote}${processedInner}${effWrap.quote}`;
405
2890
  }
406
2891
 
407
- for (const k of Object.keys(cache)) delete cache[k];
408
-
409
- const opts = { encoding: "utf-8", timeout: 2000, windowsHide: true };
410
- let resolved = null;
411
-
412
- const pathRes = pathCandidate ? spawnRtk(pathCandidate, ["--version"], opts) : { status: 1, stdout: "" };
413
- const pathCompatible = !pathRes.error && pathRes.status === 0 && isCompatibleRtkVersion(pathRes.stdout);
414
-
415
- if (pathCompatible) {
416
- resolved = { exe: pathCandidate, prefix: "rtk" };
417
- } else if (localCandidate) {
418
- const localRes = spawnRtk(localCandidate, ["--version"], opts);
419
- const localCompatible = !localRes.error && localRes.status === 0 && isCompatibleRtkVersion(localRes.stdout);
420
- if (localCompatible) {
421
- const normalizedLocal = localCandidate.replace(/\\/g, "/");
422
- const prefix = /\s/.test(normalizedLocal) ? `"${normalizedLocal}"` : normalizedLocal;
423
- resolved = { exe: localCandidate, prefix };
2892
+ const { prefix: effEnvPrefix, body: effBody } = splitEnvPrefix(trimmedEff);
2893
+ const { body: rawBody } = splitEnvPrefix(trimmedRaw);
2894
+ const effMatchTarget = effBody || trimmedEff;
2895
+ const rawMatchTarget = rawBody || trimmedRaw;
2896
+
2897
+ const rtkMatch = effMatchTarget.match(/^((?:["']?[^\s"']*[/\\])?rtk(?:\.exe|\.cmd|\.bat)?["']?\s+)([\s\S]+)$/i);
2898
+ if (!rtkMatch) {
2899
+ // When host `rg` is absent, also rewrite bare `rg` in pipelines or `bash -c` subshells to `grep`/`find`
2900
+ if (!rgAvailable && /^rg(?:\.exe)?(?=\s|$)/i.test(effMatchTarget)) {
2901
+ const mapped = rewriteRtkRgSegment("", effMatchTarget, { inPipeline });
2902
+ if (mapped !== effMatchTarget) {
2903
+ return `${effEnvPrefix}${mapped}`;
2904
+ }
424
2905
  }
2906
+ return trimmedEff;
425
2907
  }
426
2908
 
427
- cache.__envSig = envSig;
428
- cache.__rtkResolved = resolved;
429
- ensureStateDir();
430
- try {
431
- const tmp = `${REWRITE_CACHE_FILE}.${process.pid}.tmp`;
432
- fs.writeFileSync(tmp, JSON.stringify(cache), "utf-8");
433
- try {
434
- fs.renameSync(tmp, REWRITE_CACHE_FILE);
435
- } catch {
436
- fs.writeFileSync(REWRITE_CACHE_FILE, JSON.stringify(cache), "utf-8");
437
- try { fs.unlinkSync(tmp); } catch {}
438
- }
439
- } catch {}
440
-
441
- return resolved;
442
- }
443
-
444
- function runRtkRewrite(cmd, resolvedRtk) {
445
- const rtk = resolvedRtk || resolveCompatibleRtk();
446
- if (!rtk) return { status: 1, stdout: "", stderr: "No compatible RTK binary available" };
447
- const opts = { encoding: "utf-8", timeout: 2000, windowsHide: true };
448
- return spawnRtk(rtk.exe, ["rewrite", cmd], opts);
449
- }
2909
+ const rtkWord = rtkMatch[1];
2910
+ const rest = rtkMatch[2];
2911
+ const subcmd = (rest.match(/^([^\s"']+)/)?.[1] || "").toLowerCase();
450
2912
 
451
- /**
452
- * Persists one rewrite result. Re-reads the file first so concurrent hook processes merge instead
453
- * of overwriting each other, trims to the newest entries, and writes atomically (temp + rename).
454
- */
455
- function rememberRewrite(cmd, rewritten) {
456
- const cache = loadRewriteCache();
457
- cache[cmd] = rewritten;
458
- try {
459
- const disk = readRewriteCacheFile();
460
- const envSig = cache.__envSig ?? disk.__envSig;
461
- const rtkResolved = cache.__rtkResolved ?? disk.__rtkResolved;
462
- delete disk.__envSig;
463
- delete disk.__rtkResolved;
464
- delete disk[cmd];
465
- disk[cmd] = rewritten;
466
- const keys = Object.keys(disk);
467
- for (const k of keys.slice(0, Math.max(0, keys.length - REWRITE_CACHE_MAX))) delete disk[k];
468
- if (envSig !== undefined) disk.__envSig = envSig;
469
- if (rtkResolved !== undefined) disk.__rtkResolved = rtkResolved;
470
- ensureStateDir();
471
- const tmp = `${REWRITE_CACHE_FILE}.${process.pid}.tmp`;
472
- fs.writeFileSync(tmp, JSON.stringify(disk), "utf-8");
473
- try {
474
- fs.renameSync(tmp, REWRITE_CACHE_FILE);
475
- } catch {
476
- fs.writeFileSync(REWRITE_CACHE_FILE, JSON.stringify(disk), "utf-8");
477
- try { fs.unlinkSync(tmp); } catch {}
2913
+ // Revert lossy `rtk wc` / `rtk ls` / `rtk diff` back to raw unless caller explicitly typed it
2914
+ if (subcmd === "wc" || subcmd === "ls" || subcmd === "diff") {
2915
+ const explicitPattern = new RegExp(`^(?:["']?[^\\s"']*[/\\\\])?rtk(?:\\.exe|\\.cmd|\\.bat)?["']?\\s+${subcmd}\\b`, "i");
2916
+ if (!explicitPattern.test(rawMatchTarget)) {
2917
+ return `${effEnvPrefix}${rest}`;
478
2918
  }
479
- } catch {}
480
- }
481
-
482
- /**
483
- * Rewrite raw shell command using RTK (Rust Token Killer).
484
- */
485
- function rewriteCommandWithRtk(rawCmd) {
486
- if (!rawCmd || typeof rawCmd !== "string") return rawCmd;
487
- const trimmed = rawCmd.trim();
488
- if (!trimmed) return rawCmd;
2919
+ }
489
2920
 
490
- // Already prefixed with rtk
491
- if (/^rtk(?:\.exe)?(?=\s|$)/i.test(trimmed)) {
492
- const rtk = resolveCompatibleRtk();
493
- if (rtk && rtk.prefix !== "rtk") {
494
- return trimmed.replace(/^rtk(?:\.exe)?(?=\s|$)/i, rtk.prefix);
2921
+ // Never emit `rtk rg` when `rg` is absent on PATH
2922
+ if ((subcmd === "rg" || subcmd === "rg.exe") && !rgAvailable) {
2923
+ let mapped = rewriteRtkRgSegment(rtkWord, rest, { inPipeline });
2924
+ if (mapped !== rest && rtkPrefix && rtkPrefix !== "rtk" && /^rtk\s+/i.test(mapped)) {
2925
+ mapped = mapped.replace(/^rtk(?=\s)/i, rtkPrefix);
495
2926
  }
496
- return trimmed;
2927
+ return `${effEnvPrefix}${mapped}`;
497
2928
  }
498
2929
 
499
- // Unwrap shell wrappers (e.g. cmd /c "...", powershell -Command "...", bash -c "...") (LH-01-B)
500
- const shellWrapperMatch = trimmed.match(
501
- /^(cmd(?:\.exe)?\s+\/[cC]|(?:powershell|pwsh)(?:\.exe)?\s+(?:-Command|-c)|(?:bash|sh)\s+-c)\s+["']?(.+?)["']?$/i
502
- );
503
- if (shellWrapperMatch) {
504
- const wrapperPrefix = shellWrapperMatch[1];
505
- const innerCmd = shellWrapperMatch[2].trim();
506
- const rewrittenInner = rewriteCommandWithRtk(innerCmd);
507
- if (rewrittenInner !== innerCmd) {
508
- return `${wrapperPrefix} "${rewrittenInner}"`;
2930
+ // Normalize `rtk grep` when host `rg` is absent so directories & `a|b` alternation work reliably
2931
+ if ((subcmd === "grep" || subcmd === "grep.exe") && !rgAvailable) {
2932
+ let mapped = rewriteRtkGrepSegment(rtkWord, rest, { inPipeline });
2933
+ if (rtkPrefix && rtkPrefix !== "rtk" && /^rtk\s+/i.test(mapped)) {
2934
+ mapped = mapped.replace(/^rtk(?=\s)/i, rtkPrefix);
509
2935
  }
510
- return trimmed;
2936
+ return `${effEnvPrefix}${mapped}`;
511
2937
  }
512
2938
 
513
- // Fast-path bypass for shell builtins, non-rewritable binaries, and powershell cmdlets
514
- const firstWord = (trimmed.match(/^[^\s"']+/)?.[0] || "").toLowerCase();
515
- if (
516
- NON_REWRITABLE_BINARIES.has(firstWord) ||
517
- firstWord.startsWith("get-") ||
518
- firstWord.startsWith("set-") ||
519
- firstWord.startsWith("start-") ||
520
- firstWord.startsWith("stop-") ||
521
- firstWord.startsWith("new-") ||
522
- firstWord.startsWith("remove-") ||
523
- firstWord.startsWith("test-")
524
- ) {
525
- return trimmed;
2939
+ // If RTK came from a local binary not on PATH, rewrite bare `rtk ` prefix to explicit path
2940
+ if (rtkPrefix && rtkPrefix !== "rtk" && /^rtk\s+/i.test(effMatchTarget) && !/^rtk\s+/i.test(rawMatchTarget)) {
2941
+ return `${effEnvPrefix}${effMatchTarget.replace(/^rtk(?=\s)/i, rtkPrefix)}`;
526
2942
  }
527
2943
 
528
- const rtk = resolveCompatibleRtk();
529
- if (!rtk) return trimmed;
2944
+ return trimmedEff;
2945
+ }
530
2946
 
531
- // Check persistent cache
532
- const cache = loadRewriteCache();
533
- if (cache[trimmed] !== undefined) {
534
- return cache[trimmed];
2947
+ function postProcessCommand(rawCmd, effCmd, opts) {
2948
+ const rawSegs = splitShellSegments(rawCmd);
2949
+ const effSegs = splitShellSegments(effCmd);
2950
+ if (effSegs.length === 0) return effCmd;
2951
+
2952
+ const sameCount = rawSegs.length === effSegs.length;
2953
+ let result = effCmd;
2954
+
2955
+ // Splice from end to start so earlier offsets remain valid
2956
+ for (let i = effSegs.length - 1; i >= 0; i--) {
2957
+ const seg = effSegs[i];
2958
+ const rawSegText = sameCount ? rawSegs[i].text : rawCmd;
2959
+ const inPipeline = seg.leadingSep === "|" || seg.leadingSep === "|&";
2960
+ const replacement = postProcessSegmentText(rawSegText, seg.text, {
2961
+ ...opts,
2962
+ inPipeline
2963
+ });
2964
+ if (replacement !== seg.text) {
2965
+ result = result.slice(0, seg.start) + replacement + result.slice(seg.end);
2966
+ }
535
2967
  }
536
2968
 
537
- let rewritten = trimmed;
538
- let cacheable = false;
539
- try {
540
- const res = runRtkRewrite(trimmed, rtk);
541
- // Never cache timeouts, spawn errors or signals: a transient failure must not pin a command
542
- // to "no rewrite" for the lifetime of the cache.
543
- cacheable = !res.error && !res.signal && typeof res.status === "number" && res.status >= 0 && res.status <= 3;
2969
+ return result;
2970
+ }
544
2971
 
545
- // RTK exits 0 or 3 when rewritten, with new command on stdout
546
- if ((res.status === 0 || res.status === 3) && res.stdout && res.stdout.trim()) {
547
- rewritten = res.stdout.trim();
548
- if (rtk.prefix !== "rtk") {
549
- rewritten = rewritten.replace(/^rtk(?:\.exe)?(?=\s|$)/i, rtk.prefix);
2972
+ function delegateRewriteToRtk(rawCmd, rtk) {
2973
+ const opts = {
2974
+ input: JSON.stringify({
2975
+ toolCall: {
2976
+ name: "run_command",
2977
+ args: { CommandLine: rawCmd }
550
2978
  }
2979
+ }),
2980
+ encoding: "utf-8",
2981
+ timeout: 2000,
2982
+ windowsHide: true
2983
+ };
2984
+ const res = spawnRtk(rtk.exe, ["hook", "antigravity"], opts);
2985
+ if (res.error || res.status !== 0 || !res.stdout) {
2986
+ return { decision: "allow", commandLine: rawCmd };
2987
+ }
2988
+ try {
2989
+ const parsed = JSON.parse(res.stdout.trim());
2990
+ if (parsed && (parsed.decision === "deny" || parsed.decision === "force_ask")) {
2991
+ return {
2992
+ decision: parsed.decision,
2993
+ reason: parsed.reason || "Denied by RTK hook."
2994
+ };
551
2995
  }
552
- } catch {}
553
-
554
- if (cacheable) rememberRewrite(trimmed, rewritten);
555
- return rewritten;
2996
+ const rewritten = parsed?.overwrite?.CommandLine;
2997
+ return {
2998
+ decision: "allow",
2999
+ commandLine: typeof rewritten === "string" && rewritten.trim() ? rewritten : rawCmd
3000
+ };
3001
+ } catch {
3002
+ return { decision: "allow", commandLine: rawCmd };
3003
+ }
556
3004
  }
557
3005
 
3006
+ // ============================================================================
3007
+ // Proportional Backoff & Strict Task-Polling Schedule Detection
3008
+ // ============================================================================
3009
+
558
3010
  /**
559
3011
  * Calculates the required minimum cooldown interval (in seconds)
560
3012
  * proportional to the number of diagnostic status inspections already performed.
561
3013
  * Formula: Math.min(600, Math.round(30 * Math.pow(2.5, Math.max(0, pollCount - 1))))
562
3014
  * Levels: Level 0 -> 0s (baseline), Level 1 -> 30s, Level 2 -> 75s, Level 3 -> 188s, Level 4 -> 469s, Level 5+ -> 600s
3015
+ * Keep identical to lib/transcript-core.mjs `getRequiredBackoffSeconds`.
563
3016
  */
564
3017
  function getRequiredBackoffSeconds(pollCount) {
565
3018
  if (pollCount <= 0) return 0;
566
3019
  return Math.min(600, Math.round(30 * Math.pow(2.5, Math.max(0, pollCount - 1))));
567
3020
  }
568
3021
 
3022
+ /**
3023
+ * Strict task-polling detection for `schedule` tool calls.
3024
+ * Keep identical to lib/transcript-core.mjs `isTaskPollSchedule`.
3025
+ */
3026
+ function isTaskPollSchedule(args = {}) {
3027
+ const prompt = String(args.Prompt || args.prompt || "").replace(/^["']|["']$/g, "").trim().toLowerCase();
3028
+ const cond = String(args.TimerCondition || args.timerCondition || "").replace(/^["']|["']$/g, "").trim().toLowerCase();
3029
+ return (
3030
+ cond.startsWith("task-") ||
3031
+ cond.includes("task") ||
3032
+ prompt.includes("check on") ||
3033
+ prompt.includes("check task") ||
3034
+ prompt.includes("background task") ||
3035
+ prompt.includes("still running") ||
3036
+ prompt.includes("test suite") ||
3037
+ ((prompt.includes("check") || prompt.includes("status") || prompt.includes("poll") || prompt.includes("monitor") || prompt.includes("wait")) &&
3038
+ (prompt.includes("task") || prompt.includes("process") || prompt.includes("job") || prompt.includes("test") || prompt.includes("command") || prompt.includes("suite")))
3039
+ );
3040
+ }
3041
+
3042
+ /**
3043
+ * Detects one-shot `schedule` timers used to poll a `/swarm` or subagent instead of
3044
+ * relying on `swarm_wait` or native Reactive Wakeup.
3045
+ * Exempts the dedicated `swarm_monitor` daemon cron (`CronExpression` + `IsDaemon: true`).
3046
+ */
3047
+ function isSwarmPollSchedule(args = {}) {
3048
+ const isDaemon = args.IsDaemon === true || args.isDaemon === true;
3049
+ const cron = String(args.CronExpression || args.cronExpression || "").trim();
3050
+ if (isDaemon && cron) return false;
3051
+
3052
+ const prompt = String(args.Prompt || args.prompt || "").replace(/^["']|["']$/g, "").trim().toLowerCase();
3053
+ const cond = String(args.TimerCondition || args.timerCondition || "").replace(/^["']|["']$/g, "").trim().toLowerCase();
3054
+ return (
3055
+ /\b(?:swarm|subagent|subagents|squad|board)\b/i.test(prompt) ||
3056
+ (cond === "any" && /\b(?:check|status|poll|monitor|wait|worker|lead|auditor)\b/i.test(prompt))
3057
+ );
3058
+ }
3059
+
569
3060
  /**
570
3061
  * Denies reading the log of an actively RUNNING task: records the denial, trips the circuit
571
3062
  * breaker at 5 cumulative denials, otherwise answers with the Proportional Backoff message.
@@ -594,6 +3085,140 @@ function denyRunningTask(taskId, convId) {
594
3085
  });
595
3086
  }
596
3087
 
3088
+ // ============================================================================
3089
+ // Denial Helpers & File Metrics
3090
+ // ============================================================================
3091
+
3092
+ function buildInternalStateShellDenial(convId = "global") {
3093
+ const accessEval = evaluateCallerTranscriptAccess(convId);
3094
+ if (!accessEval.allowed) {
3095
+ return (
3096
+ "Antigravity Execution Governance: Direct shell access to internal Antigravity execution state (.system_generated and governance state) is strictly prohibited.\n" +
3097
+ "- Transcript inspection ('agy-context transcript' and '.system_generated/logs/') is DISABLED for this session by parent governance (L6 Transcript Isolation). Do NOT retry transcript commands or alternative file/shell reads; proceed using only your assigned workspace files and task brief.\n" +
3098
+ "- To inspect background tasks: Yield execution turn and rely on native Reactive Wakeup (<SYSTEM_MESSAGE>). Do not read internal task logs."
3099
+ );
3100
+ }
3101
+ if (accessEval.mode === "self-only") {
3102
+ return (
3103
+ "Antigravity Execution Governance: Direct shell access to internal Antigravity execution state (.system_generated and governance state) is strictly prohibited.\n" +
3104
+ `- Cross-session transcript inspection is DISABLED for this session (mode: self-only). You may only inspect your own transcript via 'agy-context transcript read --id ${convId} --mode compact' via run_command.\n` +
3105
+ "- To inspect background tasks: Yield execution turn and rely on native Reactive Wakeup (<SYSTEM_MESSAGE>). Do not read internal task logs."
3106
+ );
3107
+ }
3108
+ return (
3109
+ "Antigravity Execution Governance: Direct shell access to internal Antigravity execution state (.system_generated) is strictly prohibited to prevent transcript bloat and polling loops.\n" +
3110
+ "- To inspect conversation transcripts: Run 'agy-context transcript read --id <uuid> --mode compact' or 'agy-context transcript query --id <uuid> --query \"<pattern>\" --summary' via run_command.\n" +
3111
+ "- To inspect background tasks: Yield execution turn and rely on native Reactive Wakeup (<SYSTEM_MESSAGE>). Do not read internal task logs."
3112
+ );
3113
+ }
3114
+
3115
+ function buildInternalStateToolDenial(convId = "global") {
3116
+ const accessEval = evaluateCallerTranscriptAccess(convId);
3117
+ if (!accessEval.allowed) {
3118
+ return (
3119
+ "Antigravity Execution Governance: Direct access to internal Antigravity execution state (.system_generated and governance state) is strictly prohibited.\n" +
3120
+ "- Transcript inspection ('agy-context transcript' and '.system_generated/logs/') is DISABLED for this session by parent governance (L6 Transcript Isolation). Do NOT retry transcript commands or alternative file/shell reads; proceed using only your assigned workspace files and task brief.\n" +
3121
+ "- To inspect background tasks: Yield execution turn and rely on native Reactive Wakeup (<SYSTEM_MESSAGE>). Do not read internal task logs."
3122
+ );
3123
+ }
3124
+ if (accessEval.mode === "self-only") {
3125
+ return (
3126
+ "Antigravity Execution Governance: Direct access to internal Antigravity execution state (.system_generated and governance state) is strictly prohibited.\n" +
3127
+ `- Cross-session transcript inspection is DISABLED for this session (mode: self-only). You may only inspect your own transcript via 'agy-context transcript read --id ${convId} --mode compact' via run_command.\n` +
3128
+ "- To inspect background tasks: Yield execution turn and rely on native Reactive Wakeup (<SYSTEM_MESSAGE>). Do not read internal task logs."
3129
+ );
3130
+ }
3131
+ return (
3132
+ "Antigravity Execution Governance: Direct access to internal Antigravity execution state (.system_generated) is strictly prohibited to prevent transcript bloat and polling loops.\n" +
3133
+ "- To inspect conversation transcripts: Run 'agy-context transcript read --id <uuid> --mode compact' or 'agy-context transcript query --id <uuid> --query \"<pattern>\" --summary' via run_command.\n" +
3134
+ "- To inspect background tasks: Yield execution turn and rely on native Reactive Wakeup (<SYSTEM_MESSAGE>). Do not read internal task logs."
3135
+ );
3136
+ }
3137
+
3138
+ function isProtectedGovernanceFileMutation(targetPath, convId = "global") {
3139
+ if (!targetPath || typeof targetPath !== "string") return false;
3140
+ const norm = targetPath.replace(/\\/g, "/");
3141
+ // Installed hook, plugin, & hooks.json in ~/.gemini/config are always protected from direct agent edit/rm
3142
+ if (
3143
+ /\/\.gemini\/(?:config|antigravity)\/hooks\.json(?:\.bak)?$/i.test(norm) ||
3144
+ /\/\.gemini\/config\/scripts\/execution-guard-hook\.mjs$/i.test(norm) ||
3145
+ /\/\.gemini\/config\/plugins\/agy-context-saver(?:$|[\/\\])/i.test(norm) ||
3146
+ /\/\.gemini\/antigravity\/bin\/agy-context(?:-saver)?(?:\.(?:cmd|exe|bat))?$/i.test(norm)
3147
+ ) {
3148
+ return true;
3149
+ }
3150
+ // When caller is a restricted child session, also block mutating repo governance source files
3151
+ const accessEval = evaluateCallerTranscriptAccess(convId);
3152
+ if (accessEval.isRestrictedChild) {
3153
+ if (
3154
+ /(?:^|[\/\\])(?:execution-guard-hook\.mjs|transcript-core\.mjs|agy-context\.mjs|state-dir\.mjs|install-register\.mjs|hooks\.json)$/i.test(
3155
+ norm
3156
+ )
3157
+ ) {
3158
+ return true;
3159
+ }
3160
+ }
3161
+ return false;
3162
+ }
3163
+
3164
+ function hasGovernanceMutationInText(text, convId = "global") {
3165
+ if (!text || typeof text !== "string") return false;
3166
+ const norm = text.replace(/\\/g, "/");
3167
+ const hasMutationOp =
3168
+ /\b(?:rm|del|remove-item|mv|cp|tee|truncate|shred|install|sed|perl|chmod|chown|ln|dd|patch|writeFile|writeFileSync|appendFile|appendFileSync|unlink|unlinkSync|rmSync|rmdir|rmdirSync|rename|renameSync|truncateSync|chmodSync|copyFile|copyFileSync|WriteAllText|Delete|Remove)\b|>/i.test(
3169
+ norm
3170
+ );
3171
+ if (!hasMutationOp) return false;
3172
+
3173
+ if (
3174
+ /\/\.gemini\/(?:config|antigravity)\/hooks\.json(?:\.bak)?\b/i.test(norm) ||
3175
+ /\/\.gemini\/config\/scripts\/execution-guard-hook\.mjs\b/i.test(norm) ||
3176
+ /\/\.gemini\/config\/plugins\/agy-context-saver(?:$|[\/\\\s'"])/i.test(norm) ||
3177
+ /\/\.gemini\/antigravity\/bin\/agy-context(?:-saver)?(?:\.(?:cmd|exe|bat))?\b/i.test(norm)
3178
+ ) {
3179
+ return true;
3180
+ }
3181
+
3182
+ if (evaluateCallerTranscriptAccess(convId).isRestrictedChild) {
3183
+ if (
3184
+ /\b(?:execution-guard-hook\.mjs|transcript-core\.mjs|agy-context\.mjs|state-dir\.mjs|install-register\.mjs|hooks\.json)\b/i.test(
3185
+ norm
3186
+ )
3187
+ ) {
3188
+ return true;
3189
+ }
3190
+ }
3191
+ return false;
3192
+ }
3193
+
3194
+ function isExecutableScriptPath(targetPath) {
3195
+ if (!targetPath || typeof targetPath !== "string") return false;
3196
+ return /\.(?:m?js|cjs|ts|mts|cts|py|sh|bash|zsh|pl|rb|php|ps1|cmd|bat)$/i.test(targetPath.trim());
3197
+ }
3198
+
3199
+ function escapeDoubleQuotedArg(str) {
3200
+ return String(str).replace(/["\\$`]/g, "\\$&");
3201
+ }
3202
+
3203
+ function getFileMetrics(filePath) {
3204
+ if (!filePath || typeof filePath !== "string") return null;
3205
+ try {
3206
+ if (!fs.existsSync(filePath)) return null;
3207
+ const stat = fs.statSync(filePath);
3208
+ if (!stat.isFile()) return null;
3209
+ const bytes = stat.size;
3210
+ const content = fs.readFileSync(filePath, "utf-8");
3211
+ if (content.length === 0) {
3212
+ return { bytes: 0, lineCount: 0, rawSplitCount: 1 };
3213
+ }
3214
+ const rawSplitCount = content.split(/\r?\n/).length;
3215
+ const lineCount = /\r?\n$/.test(content) ? Math.max(1, rawSplitCount - 1) : rawSplitCount;
3216
+ return { bytes, lineCount, rawSplitCount };
3217
+ } catch {
3218
+ return null;
3219
+ }
3220
+ }
3221
+
597
3222
  // --- Main Hook Entrypoint ---
598
3223
  let isGovernedTool = false;
599
3224
 
@@ -616,7 +3241,10 @@ try {
616
3241
  respond({ decision: "allow" });
617
3242
  }
618
3243
 
619
- isGovernedTool = true;
3244
+ // Only pre-classify as governed before JSON.parse when the tool name field itself matches a governed tool
3245
+ isGovernedTool = /"(?:name|tool_name)"\s*:\s*"(?:run_command|read_file|view_file|read_many_files|grep_search|find_by_name|list_dir|manage_task|schedule|replace_file_content|write_to_file|write_file|edit_file)"/i.test(
3246
+ inputRaw
3247
+ );
620
3248
  const payload = JSON.parse(inputRaw);
621
3249
  const toolCall = payload.toolCall || payload.tool_call || {};
622
3250
  const toolName = String(toolCall.name || toolCall.tool_name || "").trim().toLowerCase();
@@ -629,8 +3257,50 @@ try {
629
3257
 
630
3258
  isGovernedTool = true;
631
3259
 
3260
+ // Dual-hook idempotency deduplication when payload.conversationId is present (coordinator plugin + global hooks.json)
3261
+ if (payload.conversationId) {
3262
+ const callId = String(toolCall.id || payload.toolUseId || payload.tool_use_id || "").trim();
3263
+ const stepIdx = payload.stepIdx !== undefined ? payload.stepIdx : payload.step_index;
3264
+ const inSig = JSON.stringify(args || {});
3265
+ let currentScriptPath = "";
3266
+ try {
3267
+ currentScriptPath = tryRealpath(fileURLToPath(import.meta.url));
3268
+ } catch {}
3269
+ const now = Date.now();
3270
+ const prevState = getSessionState(convId);
3271
+ const recent = Array.isArray(prevState.recentHookCalls) ? prevState.recentHookCalls : [];
3272
+ const matchedCall = recent.find((e) => {
3273
+ if (!e || typeof e.ts !== "number" || (now - e.ts) >= HOOK_DEDUP_TTL_MS) return false;
3274
+ if (callId && e.callId && e.callId === callId) return true;
3275
+ const argMatch = inSig === e.rawSig || inSig === e.outSig;
3276
+ if (!argMatch || e.toolName !== toolName) return false;
3277
+ if (stepIdx !== undefined && stepIdx !== null && e.stepIdx === stepIdx) return true;
3278
+ if (currentScriptPath && e.scriptPath && e.scriptPath !== currentScriptPath && (now - e.ts) < HOOK_SAME_ARG_CROSS_SCRIPT_MS) {
3279
+ return true;
3280
+ }
3281
+ return false;
3282
+ });
3283
+ if (matchedCall && matchedCall.response) {
3284
+ const replay =
3285
+ inSig === matchedCall.outSig &&
3286
+ matchedCall.rawSig !== matchedCall.outSig &&
3287
+ matchedCall.response.decision === "allow"
3288
+ ? { decision: "allow" }
3289
+ : matchedCall.response;
3290
+ respond(replay);
3291
+ }
3292
+ dedupRecordCtx = {
3293
+ convId,
3294
+ callId,
3295
+ stepIdx: stepIdx !== undefined ? stepIdx : null,
3296
+ toolName,
3297
+ scriptPath: currentScriptPath,
3298
+ rawSig: inSig
3299
+ };
3300
+ }
3301
+
632
3302
  // =========================================================================
633
- // 1. run_command: Transparent RTK Command Optimization & Synchronous Wait
3303
+ // 1. run_command: Raw Safety Gates + `rtk hook antigravity` Delegation
634
3304
  // =========================================================================
635
3305
  if (toolName === "run_command") {
636
3306
  const rawCmd = args.CommandLine !== undefined ? args.CommandLine : args.command;
@@ -641,73 +3311,240 @@ try {
641
3311
  });
642
3312
  }
643
3313
 
3314
+ const segments = splitShellSegments(rawCmd);
3315
+
3316
+ // LH-09-B: Shell command mutation of protected benchmark & governance artifacts
3317
+ const SHELL_MUTATION_VERBS = new Set([
3318
+ "rm",
3319
+ "del",
3320
+ "remove-item",
3321
+ "mv",
3322
+ "cp",
3323
+ "tee",
3324
+ "truncate",
3325
+ "shred",
3326
+ "install",
3327
+ "sed",
3328
+ "perl",
3329
+ "chmod",
3330
+ "chown",
3331
+ "ln",
3332
+ "dd",
3333
+ "patch"
3334
+ ]);
3335
+ for (const seg of segments) {
3336
+ const segTexts = [seg.text, ...(seg.subshells || [])];
3337
+ for (const st of segTexts) {
3338
+ const detailed = tokenizeShellWordsDetailed(st);
3339
+ if (detailed.length === 0) continue;
3340
+ let cmdIdx = 0;
3341
+ while (
3342
+ cmdIdx < detailed.length &&
3343
+ (/^[A-Za-z_][A-Za-z0-9_]*=/.test(detailed[cmdIdx].value) ||
3344
+ detailed[cmdIdx].value === "env" ||
3345
+ detailed[cmdIdx].value === "sudo")
3346
+ ) {
3347
+ cmdIdx++;
3348
+ }
3349
+ const verb = cmdIdx < detailed.length ? normalizeCmdName(detailed[cmdIdx].value) : "";
3350
+ const isMutatingVerb = SHELL_MUTATION_VERBS.has(verb);
3351
+ if (seg.hasWriteRedirect || isMutatingVerb) {
3352
+ const redirectTargets = extractRedirectTargets(detailed);
3353
+ const argCandidates = isMutatingVerb
3354
+ ? detailed.slice(cmdIdx + 1).map((t) => t.value).filter((v) => !v.includes("\n"))
3355
+ : [];
3356
+ const candidateTokens = [...redirectTargets, ...argCandidates];
3357
+ if (candidateTokens.some((tok) => isProtectedGovernanceFileMutation(tok, convId))) {
3358
+ respond({
3359
+ decision: "deny",
3360
+ reason:
3361
+ "Antigravity Execution Governance: Shell mutation of protected Antigravity hook, shim, or governance source files is strictly prohibited."
3362
+ });
3363
+ }
3364
+ if (candidateTokens.some((tok) => isProtectedBenchmarkPath(tok))) {
3365
+ respond({
3366
+ decision: "force_ask",
3367
+ reason:
3368
+ "[BENCHMARK MUTATION DETECTED] Shell command mutation of protected evaluator/benchmark artifact requires explicit user confirmation to prevent benchmark contamination."
3369
+ });
3370
+ }
3371
+ }
3372
+ }
3373
+ }
644
3374
 
645
- // LH-09-B: Shell command mutation of protected benchmark artifacts
646
- if (
647
- (rawCmd.includes(">") || /\b(?:rm|del|Remove-Item)\b/i.test(rawCmd)) &&
648
- isProtectedBenchmarkPath(rawCmd)
649
- ) {
3375
+ // Check agy-context transcript CLI exemption & parent-scoped access governance
3376
+ const agyTranscriptCheck = checkAgyContextTranscriptCommand(segments, convId);
3377
+ if (agyTranscriptCheck.matched && !agyTranscriptCheck.allowed) {
3378
+ if (agyTranscriptCheck.isAccessDenial) {
3379
+ const session = getSessionState(convId);
3380
+ session.transcriptDenials = (session.transcriptDenials || 0) + 1;
3381
+ saveSessionState(convId, session, { updatePollTime: false });
3382
+ if (session.transcriptDenials >= 5) {
3383
+ respond({
3384
+ decision: "force_ask",
3385
+ reason: `[CIRCUIT BREAKER ACTIVATED] Autonomous loop suspended: restricted session '${convId}' attempted forbidden 'agy-context transcript' access ${session.transcriptDenials} times. ${agyTranscriptCheck.reason}`
3386
+ });
3387
+ }
3388
+ }
650
3389
  respond({
651
- decision: "force_ask",
652
- reason:
653
- "[BENCHMARK MUTATION DETECTED] Shell command mutation of protected evaluator/benchmark artifact requires explicit user confirmation to prevent benchmark contamination."
3390
+ decision: "deny",
3391
+ reason: agyTranscriptCheck.reason
654
3392
  });
655
3393
  }
656
3394
 
657
- // Raw-command governance gates are pure string/fs checks: run them BEFORE the rtk rewrite
658
- // spawn so denied commands never pay for a subprocess.
659
- const isSpillover = isStepSpilloverPath(rawCmd);
660
- const taskMatch = isSpillover ? null : getTaskLogMatch(rawCmd);
3395
+ // Raw-command internal state checks (skipping heredoc bodies, echo/printf text, and grep patterns)
3396
+ let isSpillover = false;
3397
+ let taskMatch = null;
3398
+ if (!agyTranscriptCheck.allowed) {
3399
+ const callerAccess = evaluateCallerTranscriptAccess(convId);
3400
+ const inspectableTexts = collectInspectableTexts(segments, 0, {
3401
+ isRestrictedChild: callerAccess.isRestrictedChild
3402
+ });
3403
+ const inspectableCombined = inspectableTexts.join("\n");
3404
+ const singleTokens = segments.length === 1 ? tokenizeShellWords(segments[0].text) : [];
3405
+ const spilloverTok = findStepSpilloverToken(singleTokens);
3406
+ isSpillover = Boolean(spilloverTok);
3407
+ taskMatch = getTaskLogMatch(inspectableCombined);
3408
+
3409
+ // Block unwrapped shell/interpreter mutations of governance/hook files
3410
+ if (hasGovernanceMutationInText(inspectableCombined, convId)) {
3411
+ respond({
3412
+ decision: "deny",
3413
+ reason:
3414
+ "Antigravity Execution Governance: Shell mutation of protected Antigravity hook, shim, or governance source files is strictly prohibited."
3415
+ });
3416
+ }
661
3417
 
662
- // Check 1: Other protected internal state (transcripts, scheduler, progress)
663
- if (!isSpillover && !taskMatch && isProtectedInternalState(rawCmd)) {
664
- respond({
665
- decision: "deny",
666
- reason:
667
- "Antigravity Execution Governance: Direct shell access to internal Antigravity execution state (.system_generated) is strictly prohibited to prevent transcript bloat and polling loops.\n" +
668
- "- To inspect conversation transcripts: Call MCP tool read_transcript(conversationId=\"...\", mode=\"compact\") or query_transcript(...).\n" +
669
- "- To inspect background tasks: Yield execution turn and rely on native Reactive Wakeup (<SYSTEM_MESSAGE>). Do not read internal task logs."
3418
+ // Block restricted child sessions from running setup/install/uninstall/restore-backups lifecycle commands
3419
+ if (
3420
+ /\bagy-context(?:-saver)?(?:\.m?js)?\s+(?:--?(?:setup|install|uninstall|restore-backups)|setup|install|uninstall|restore-backups)\b|\bnpm\s+run\s+(?:setup|uninstall)\b|\binstall-register\.mjs\b/i.test(
3421
+ inspectableCombined
3422
+ ) &&
3423
+ callerAccess.isRestrictedChild
3424
+ ) {
3425
+ respond({
3426
+ decision: "deny",
3427
+ reason:
3428
+ "Antigravity Execution Governance: Restricted child sessions cannot execute plugin lifecycle setup or uninstall commands."
3429
+ });
3430
+ }
3431
+
3432
+ // Check 1: Other protected internal state (transcripts, governance state, non-canonical agy-context transcript, interpreter imports)
3433
+ if (!isSpillover && !taskMatch && isProtectedShellOrScriptOperation(inspectableCombined)) {
3434
+ respond({
3435
+ decision: "deny",
3436
+ reason: buildInternalStateShellDenial(convId)
3437
+ });
3438
+ }
3439
+
3440
+ // If spillover or task log is chained, redirected, uses heredocs, subshells, or `..` traversal -> DENY
3441
+ if ((isSpillover || taskMatch) && !isSingleUnchainedShellCmd(segments)) {
3442
+ respond({
3443
+ decision: "deny",
3444
+ reason: isSpillover
3445
+ ? "Antigravity Execution Governance: Runtime step output spillover files must be inspected using a single unchained 'rtk read <path>' command (no chaining, redirects, subshells, or '..' traversal)."
3446
+ : buildInternalStateShellDenial(convId)
3447
+ });
3448
+ }
3449
+
3450
+ // Check 2: Task Log Inspection (Lifecycle-Gated): RUNNING => Proportional Backoff
3451
+ if (taskMatch && getTaskState(taskMatch, convId) === "RUNNING") {
3452
+ denyRunningTask(taskMatch.id, convId);
3453
+ }
3454
+ }
3455
+
3456
+ if (agyTranscriptCheck.allowed && (agyTranscriptCheck.needsReadGrant || agyTranscriptCheck.isAccessMutation)) {
3457
+ recordTranscriptHookGrant({
3458
+ callerConvId: convId,
3459
+ subcommand: agyTranscriptCheck.subcommand,
3460
+ targetConvId: agyTranscriptCheck.targetConvId || null,
3461
+ mode: agyTranscriptCheck.mode || "open",
3462
+ isMutation: Boolean(agyTranscriptCheck.isAccessMutation)
3463
+ });
3464
+ }
3465
+
3466
+ // Delegate command rewriting to `rtk hook antigravity` (>= 1.1.0)
3467
+ const rtk = resolveCompatibleRtk();
3468
+ const rgAvailable = Boolean(findExecutableOnPath("rg"));
3469
+ let effectiveCmd = rawCmd;
3470
+
3471
+ if (rtk) {
3472
+ const delegated = delegateRewriteToRtk(rawCmd, rtk);
3473
+ if (delegated.decision !== "allow") {
3474
+ respond({ decision: delegated.decision, reason: delegated.reason });
3475
+ }
3476
+ effectiveCmd = postProcessCommand(rawCmd, delegated.commandLine, {
3477
+ rgAvailable,
3478
+ rtkPrefix: rtk.prefix
3479
+ });
3480
+ } else if (!rgAvailable) {
3481
+ effectiveCmd = postProcessCommand(rawCmd, rawCmd, {
3482
+ rgAvailable: false,
3483
+ rtkPrefix: "rtk"
670
3484
  });
671
3485
  }
672
3486
 
673
- // Check 2: Task Log Inspection (Lifecycle-Gated, 0 New Tools): RUNNING => Proportional Backoff
674
- if (taskMatch && getTaskState(taskMatch, convId) === "RUNNING") {
675
- denyRunningTask(taskMatch.id, convId);
3487
+ // In self-only mode, deterministically inject --id <convId> when --id and --file were omitted (or --id current / positional current)
3488
+ if (agyTranscriptCheck.allowed && agyTranscriptCheck.injectSelfId) {
3489
+ if (/--id(?:=|\s+)["']?current["']?\b/i.test(effectiveCmd)) {
3490
+ effectiveCmd = effectiveCmd.replace(/--id(?:=|\s+)["']?current["']?\b/i, `--id ${agyTranscriptCheck.injectSelfId}`);
3491
+ } else if (/\s+["']?current["']?\s*$/i.test(effectiveCmd)) {
3492
+ effectiveCmd = effectiveCmd.replace(/\s+["']?current["']?\s*$/i, ` --id ${agyTranscriptCheck.injectSelfId}`);
3493
+ } else {
3494
+ effectiveCmd = `${effectiveCmd.trim()} --id ${agyTranscriptCheck.injectSelfId}`;
3495
+ }
676
3496
  }
677
3497
 
678
- const rewritten = rewriteCommandWithRtk(rawCmd);
679
- const effectiveCmd = rewritten;
680
- const isRtkRead = /^(?:["']?[^\s"']*[/\\])?rtk(?:\.exe|\.cmd|\.bat)?["']?\s+read\b/i.test(effectiveCmd);
3498
+ const isRtkRead = isRtkReadCommand(effectiveCmd);
3499
+ const isExplicitRtkRead = isRtkReadCommand(rawCmd) && isRtkRead;
681
3500
 
682
- // Check 3: Step Output Spillover Safe Harbor (rtk read only; it clamps + windows output)
3501
+ // Check 3: Step Output Spillover Safe Harbor (strictly single unchained `rtk read` only)
683
3502
  if (isSpillover && !isRtkRead) {
684
3503
  respond({
685
3504
  decision: "deny",
686
3505
  reason:
687
- "Antigravity Execution Governance: Runtime step output spillover files must be inspected using 'rtk read <path>' via run_command to prevent context blowouts."
3506
+ "Antigravity Execution Governance: Runtime step output spillover files must be inspected using 'rtk read <path>' via run_command (or bounded 'read_file' <= 150 lines) to prevent context blowouts."
688
3507
  });
689
3508
  }
690
3509
 
691
- // Check 4: Terminated / unknown-state task log: allow only through rtk read
692
- if (taskMatch && !isRtkRead) {
3510
+ // Check 4: Completed / unknown-state task log: allow only through strictly single unchained `rtk read`
3511
+ if (taskMatch && !isExplicitRtkRead) {
693
3512
  respond({
694
3513
  decision: "deny",
695
3514
  reason:
696
3515
  "Antigravity Execution Governance: Direct shell access to internal Antigravity execution state (.system_generated) is strictly prohibited to prevent transcript bloat and polling loops.\n" +
697
- `- Once completed, inspect output using 'rtk read <path>' via run_command.\n` +
3516
+ "- Once completed, inspect output using 'rtk read <path>' via run_command.\n" +
698
3517
  "- If actively running, yield execution turn and rely on native Reactive Wakeup (<SYSTEM_MESSAGE>)."
699
3518
  });
700
3519
  }
701
3520
 
3521
+ // Prevent cascading secondary spillover when `rtk read` is invoked without line bounds on a >45 KiB spillover/task log
3522
+ if (isRtkRead && (isSpillover || taskMatch)) {
3523
+ const hasLineBoundFlag = /(?:^|\s)(?:--head-lines|--tail-lines|--max-lines|-m|--level|-l)(?:=|\s|$)/.test(effectiveCmd);
3524
+ if (!hasLineBoundFlag) {
3525
+ const rtkTokens = tokenizeShellWords(effectiveCmd);
3526
+ const pathTok = rtkTokens.slice(2).find((t) => !t.startsWith("-"));
3527
+ if (pathTok) {
3528
+ try {
3529
+ const st = fs.statSync(pathTok);
3530
+ if (st.isFile() && st.size > 46080) {
3531
+ effectiveCmd = effectiveCmd.replace(/^(\S+\s+read\b)/i, "$1 --tail-lines 150");
3532
+ }
3533
+ } catch {}
3534
+ }
3535
+ }
3536
+ }
3537
+
702
3538
  const isDaemon = args.IsDaemon === true || args.isDaemon === true;
703
- const waitMs = args.WaitMsBeforeAsync !== undefined ? args.WaitMsBeforeAsync : args.waitMsBeforeAsync;
3539
+ const rawWaitMs = args.WaitMsBeforeAsync !== undefined ? args.WaitMsBeforeAsync : args.waitMsBeforeAsync;
3540
+ const waitMs = rawWaitMs === null ? undefined : rawWaitMs;
704
3541
  const targetMaxWait = Number(process.env.AGY_MAX_WAIT_MS) || 10000;
705
3542
 
706
3543
  const overwrite = {};
707
- if (rewritten !== rawCmd) {
708
- overwrite.CommandLine = rewritten;
3544
+ if (effectiveCmd !== rawCmd) {
3545
+ overwrite.CommandLine = effectiveCmd;
709
3546
  }
710
- if (!isDaemon && (waitMs === undefined || waitMs < targetMaxWait)) {
3547
+ if (!isDaemon && waitMs === undefined) {
711
3548
  overwrite.WaitMsBeforeAsync = targetMaxWait;
712
3549
  }
713
3550
 
@@ -718,7 +3555,7 @@ try {
718
3555
  }
719
3556
 
720
3557
  // =========================================================================
721
- // 2. Native File & Inspection Tools: Root Protection & RTK Hard-Routing
3558
+ // 2. Native File & Inspection Tools: Root Protection, Hybrid Slice & RTK
722
3559
  // =========================================================================
723
3560
  const INSPECTION_TOOLS = new Set([
724
3561
  "view_file",
@@ -731,22 +3568,48 @@ try {
731
3568
 
732
3569
  if (INSPECTION_TOOLS.has(toolName)) {
733
3570
  const rawTarget =
3571
+ args.Path ||
3572
+ args.path ||
734
3573
  args.AbsolutePath ||
735
3574
  args.absolutePath ||
3575
+ args.TargetFile ||
736
3576
  args.targetFile ||
737
3577
  args.target_file ||
738
- args.path ||
739
3578
  args.dirPath ||
740
3579
  args.dir_path ||
741
3580
  args.directory ||
3581
+ args.SearchPath ||
3582
+ args.searchPath ||
742
3583
  "";
743
3584
  const target = String(rawTarget).trim();
744
3585
 
745
- // 2a. Step Output Spillover Safe Harbor (hard-route to rtk read)
3586
+ const MAX_SAFE_SLICE_LINES = 150;
3587
+ const MAX_FULL_FILE_LINES = 800;
3588
+ const MAX_FULL_FILE_BYTES = 45 * 1024; // 45 KiB (46,080 bytes)
3589
+
3590
+ const rawStart = args.StartLine !== undefined ? args.StartLine : (args.startLine !== undefined ? args.startLine : args.start_line);
3591
+ const rawEnd = args.EndLine !== undefined ? args.EndLine : (args.endLine !== undefined ? args.endLine : args.end_line);
3592
+ const hasByteOffset = args.ByteOffset !== undefined || args.byteOffset !== undefined || args.byte_offset !== undefined;
3593
+ const hasSliceParams = rawStart !== undefined || rawEnd !== undefined;
3594
+
3595
+ // 2a. Step Output Spillover Safe Harbor: bounded read_file (<= 150 lines) allowed; else route to rtk read / bounded read_file
746
3596
  if (isStepSpilloverPath(target)) {
3597
+ if (toolName === "read_file" && rawStart !== undefined && rawEnd !== undefined) {
3598
+ const startLine = Number(rawStart);
3599
+ const endLine = Number(rawEnd);
3600
+ if (
3601
+ Number.isInteger(startLine) &&
3602
+ Number.isInteger(endLine) &&
3603
+ startLine > 0 &&
3604
+ endLine >= startLine &&
3605
+ (endLine - startLine + 1) <= MAX_SAFE_SLICE_LINES
3606
+ ) {
3607
+ respond({ decision: "allow" });
3608
+ }
3609
+ }
747
3610
  respond({
748
3611
  decision: "deny",
749
- reason: `Antigravity Governance: Native file inspection via '${toolName}' bypasses token optimization. Run 'rtk read ${target}' via run_command instead.`
3612
+ reason: `Antigravity Governance: Unbounded inspection of step spillover via '${toolName}' is denied. Pass a bounded slice ('StartLine' and 'EndLine', max ${MAX_SAFE_SLICE_LINES} lines) to 'read_file' or run 'rtk read ${target}' via run_command instead.`
750
3613
  });
751
3614
  }
752
3615
 
@@ -757,20 +3620,16 @@ try {
757
3620
  if (state === "RUNNING") {
758
3621
  denyRunningTask(taskMatch.id, convId);
759
3622
  }
760
-
761
3623
  if (state === "COMPLETED") {
762
- // If terminated / completed, route to rtk read:
763
3624
  respond({
764
3625
  decision: "deny",
765
3626
  reason: `Antigravity Governance: Native file inspection via '${toolName}' bypasses token optimization. Run 'rtk read ${target}' via run_command instead.`
766
3627
  });
767
3628
  }
768
-
769
- // If not verified completed (or does not exist):
770
3629
  respond({
771
3630
  decision: "deny",
772
3631
  reason:
773
- "Antigravity Execution Governance: Direct access to internal Antigravity execution state (.system_generated/tasks) is strictly prohibited to prevent polling loops. rely on native Reactive Wakeup (<SYSTEM_MESSAGE>) upon completion."
3632
+ "Antigravity Execution Governance: Direct access to internal Antigravity execution state (.system_generated/tasks) is strictly prohibited to prevent polling loops. Rely on native Reactive Wakeup (<SYSTEM_MESSAGE>) upon completion."
774
3633
  });
775
3634
  }
776
3635
 
@@ -778,79 +3637,35 @@ try {
778
3637
  if (isProtectedInternalState(target) || isProtectedInternalState(JSON.stringify(args))) {
779
3638
  respond({
780
3639
  decision: "deny",
781
- reason:
782
- "Antigravity Execution Governance: Direct access to internal Antigravity execution state (.system_generated) is strictly prohibited to prevent transcript bloat and polling loops.\n" +
783
- "- To inspect conversation transcripts: Call MCP tool read_transcript(conversationId=\"...\", mode=\"compact\") or query_transcript(...).\n" +
784
- "- To inspect background tasks: Yield execution turn and rely on native Reactive Wakeup (<SYSTEM_MESSAGE>). Do not read internal task logs."
3640
+ reason: buildInternalStateToolDenial(convId)
785
3641
  });
786
3642
  }
787
3643
 
788
- // 2d. Allow Antigravity Special Files (SKILL.md, brain artifacts, configs)
3644
+ // 2d. Allow Antigravity Special Files (SKILL.md, brain artifacts, configs) symmetrically for read_file & view_file
789
3645
  if (isAntigravitySpecialFile(target)) {
790
3646
  respond({ decision: "allow" });
791
3647
  }
792
3648
 
793
- // 2e. Hybrid Slice & Guarded Full-File Policy for view_file
794
- // - 1..150 line slice -> ALLOW
795
- // - Explicit full-file read (StartLine=1, EndLine=actualFileLines) of an existing file <= 800 lines AND <= 45 KiB -> ALLOW
796
- // - Partial slice of 151..800+ lines -> DENY (discover target with waymark_ask and narrow the slice)
797
- // - Explicit full-file read exceeding 800 lines or 45 KiB -> DENY
798
- // - Unbounded view_file (missing StartLine/EndLine) -> DENY
799
- if (toolName === "view_file") {
800
- const MAX_SAFE_SLICE_LINES = 150;
801
- const MAX_FULL_FILE_LINES = 800;
802
- const MAX_FULL_FILE_BYTES = 45 * 1024; // 45 KiB (46,080 bytes)
803
-
804
- const waymarkAvailable = (() => {
805
- if (process.env.AGY_FORCE_WAYMARK === "0") return false;
806
- if (process.env.AGY_FORCE_WAYMARK === "1") return true;
807
- try {
808
- const home = process.env.AGY_TEST_PROFILE_DIR || os.homedir();
809
- const schemaPath = path.join(home, ".gemini", "antigravity", "mcp", "waymark-engine", "waymark_ask.json");
810
- if (fs.existsSync(schemaPath)) return true;
811
- const mcpConfigPath = path.join(home, ".gemini", "config", "mcp_config.json");
812
- if (fs.existsSync(mcpConfigPath)) {
813
- const raw = fs.readFileSync(mcpConfigPath, "utf-8");
814
- if (raw.includes('"waymark-engine"')) return true;
815
- }
816
- } catch {}
817
- return false;
818
- })();
819
-
820
- const getFileMetrics = (filePath) => {
821
- if (!filePath || typeof filePath !== "string") return null;
822
- try {
823
- if (!fs.existsSync(filePath)) return null;
824
- const stat = fs.statSync(filePath);
825
- if (!stat.isFile()) return null;
826
- const bytes = stat.size;
827
- const content = fs.readFileSync(filePath, "utf-8");
828
- if (content.length === 0) {
829
- return { bytes: 0, lineCount: 0, rawSplitCount: 1 };
830
- }
831
- const rawSplitCount = content.split(/\r?\n/).length;
832
- const lineCount = /\r?\n$/.test(content) ? Math.max(1, rawSplitCount - 1) : rawSplitCount;
833
- return { bytes, lineCount, rawSplitCount };
834
- } catch {
835
- return null;
836
- }
837
- };
838
-
839
- const discoveryGuidance = waymarkAvailable
840
- ? `Locate the target symbol, heading, or config key first with 'waymark_ask(..., plain: true)' and pass a precise bounded slice ('StartLine' and 'EndLine', max ${MAX_SAFE_SLICE_LINES} lines) to 'view_file'`
841
- : `Provide bounded slices ('StartLine' and 'EndLine', max ${MAX_SAFE_SLICE_LINES} lines) via 'view_file', or run 'rtk read ${target || "<file>"}' via run_command instead`;
3649
+ // 2e. Symmetrical Hybrid Slice & Guarded Full-File Policy for `read_file` and `view_file`
3650
+ if (toolName === "read_file" || toolName === "view_file") {
3651
+ const rgAvailable = Boolean(findExecutableOnPath("rg"));
3652
+ const searchHint = rgAvailable ? "rtk rg" : "rtk grep -rnE";
3653
+ const discoveryGuidance = `Locate the target symbol, heading, or config key first with '${searchHint}' via run_command and pass a precise bounded slice ('StartLine' and 'EndLine', max ${MAX_SAFE_SLICE_LINES} lines) to '${toolName}', or run 'rtk read ${target || "<file>"}' via run_command instead`;
842
3654
 
843
- const rawStart = args.StartLine !== undefined ? args.StartLine : args.start_line;
844
- const rawEnd = args.EndLine !== undefined ? args.EndLine : args.end_line;
845
- const hasSliceParams = typeof rawStart !== "undefined" || typeof rawEnd !== "undefined";
3655
+ if (hasByteOffset && !hasSliceParams) {
3656
+ respond({
3657
+ decision: "deny",
3658
+ reason: `Antigravity Governance: Unbounded 'ByteOffset'-only read via '${toolName}' is denied. ${discoveryGuidance}.`
3659
+ });
3660
+ }
846
3661
 
847
3662
  if (hasSliceParams) {
848
3663
  const startLine = Number(rawStart);
849
3664
  const endLine = Number(rawEnd);
850
3665
 
851
3666
  if (
852
- typeof rawStart !== "undefined" &&
853
- typeof rawEnd !== "undefined" &&
3667
+ rawStart !== undefined &&
3668
+ rawEnd !== undefined &&
854
3669
  Number.isInteger(startLine) &&
855
3670
  Number.isInteger(endLine) &&
856
3671
  startLine > 0 &&
@@ -861,7 +3676,7 @@ try {
861
3676
  respond({ decision: "allow" });
862
3677
  }
863
3678
 
864
- const metrics = startLine === 1 ? getFileMetrics(target) : null;
3679
+ const metrics = startLine === 1 && !hasByteOffset ? getFileMetrics(target) : null;
865
3680
  const isExplicitFullFile = Boolean(
866
3681
  metrics &&
867
3682
  startLine === 1 &&
@@ -875,19 +3690,19 @@ try {
875
3690
  const kib = (metrics.bytes / 1024).toFixed(1);
876
3691
  respond({
877
3692
  decision: "deny",
878
- reason: `Antigravity Governance: Explicit full-file read via 'view_file' (${metrics.lineCount} lines, ${kib} KiB) exceeds the full-file audit ceiling (max ${MAX_FULL_FILE_LINES} lines and 45 KiB). ${discoveryGuidance}.`
3693
+ reason: `Antigravity Governance: Explicit full-file read via '${toolName}' (${metrics.lineCount} lines, ${kib} KiB) exceeds the full-file audit ceiling (max ${MAX_FULL_FILE_LINES} lines and 45 KiB). ${discoveryGuidance}.`
879
3694
  });
880
3695
  }
881
3696
 
882
3697
  respond({
883
3698
  decision: "deny",
884
- reason: `Antigravity Governance: Oversized partial file slice via 'view_file' (${span} lines > ${MAX_SAFE_SLICE_LINES} lines max safe slice window). Partial slices above ${MAX_SAFE_SLICE_LINES} lines are denied; full-file reads are only permitted when explicitly covering line 1 through the actual end of a file <= ${MAX_FULL_FILE_LINES} lines and <= 45 KiB. ${discoveryGuidance}.`
3699
+ reason: `Antigravity Governance: Oversized partial file slice via '${toolName}' (${span} lines > ${MAX_SAFE_SLICE_LINES} lines max safe slice window). Partial slices above ${MAX_SAFE_SLICE_LINES} lines are denied; full-file reads are only permitted when explicitly covering line 1 through the actual end of a file <= ${MAX_FULL_FILE_LINES} lines and <= 45 KiB. ${discoveryGuidance}.`
885
3700
  });
886
3701
  }
887
3702
 
888
3703
  respond({
889
3704
  decision: "deny",
890
- reason: `Antigravity Governance: Invalid slice range via 'view_file'. Ensure StartLine and EndLine are positive integers with EndLine >= StartLine. ${discoveryGuidance}.`
3705
+ reason: `Antigravity Governance: Invalid slice range via '${toolName}'. Ensure StartLine and EndLine are positive integers with EndLine >= StartLine. ${discoveryGuidance}.`
891
3706
  });
892
3707
  }
893
3708
 
@@ -898,13 +3713,6 @@ try {
898
3713
  }
899
3714
 
900
3715
  // 2f. Hard-Route Other Native File/Search Tools (LH-07)
901
- if (toolName === "read_file") {
902
- respond({
903
- decision: "deny",
904
- reason: `Antigravity Governance: Native file inspection via 'read_file' bypasses token optimization. Run 'rtk read ${target || "<file>"}' via run_command instead.`
905
- });
906
- }
907
-
908
3716
  if (toolName === "read_many_files") {
909
3717
  respond({
910
3718
  decision: "deny",
@@ -913,9 +3721,13 @@ try {
913
3721
  }
914
3722
 
915
3723
  if (toolName === "grep_search") {
916
- const pattern = args.query || args.pattern || "";
917
- const isWin = os.platform() === "win32";
918
- const cmdHint = isWin ? `rtk rg "${pattern}" .` : `rtk grep "${pattern}"`;
3724
+ const pattern = String(args.Query || args.query || args.Pattern || args.pattern || "").trim();
3725
+ const searchTarget = String(args.SearchPath || args.searchPath || args.path || ".").trim() || ".";
3726
+ const safePattern = pattern ? escapeDoubleQuotedArg(pattern) : "<pattern>";
3727
+ const rgAvailable = Boolean(findExecutableOnPath("rg"));
3728
+ const cmdHint = rgAvailable
3729
+ ? `rtk rg -- "${safePattern}" ${searchTarget}`
3730
+ : `rtk grep -rnE "${safePattern}" ${searchTarget}`;
919
3731
  respond({
920
3732
  decision: "deny",
921
3733
  reason: `Antigravity Governance: Native grep inspection via 'grep_search' bypasses token optimization. Run '${cmdHint}' via run_command instead.`
@@ -923,7 +3735,7 @@ try {
923
3735
  }
924
3736
 
925
3737
  if (toolName === "find_by_name") {
926
- const searchTarget = args.query || args.pattern || target || ".";
3738
+ const searchTarget = args.Pattern || args.pattern || args.query || target || ".";
927
3739
  respond({
928
3740
  decision: "deny",
929
3741
  reason: `Antigravity Governance: Native file search via 'find_by_name' bypasses token optimization. Run 'rtk find ${searchTarget}' via run_command instead.`
@@ -932,7 +3744,7 @@ try {
932
3744
 
933
3745
  if (toolName === "list_dir") {
934
3746
  const isWin = os.platform() === "win32";
935
- const cmdHint = isWin ? `rtk find ${target || "."}` : `rtk ls ${target || ""}`;
3747
+ const cmdHint = isWin ? `rtk find ${target || "."}` : `rtk ls ${target || "."}`;
936
3748
  respond({
937
3749
  decision: "deny",
938
3750
  reason: `Antigravity Governance: Native directory listing via 'list_dir' bypasses token optimization. Run '${cmdHint}' via run_command instead.`
@@ -993,22 +3805,22 @@ try {
993
3805
  }
994
3806
 
995
3807
  if (toolName === "schedule") {
996
- const prompt = String(args.Prompt || args.prompt || "").replace(/^["']|["']$/g, "").trim().toLowerCase();
997
- const cond = String(args.TimerCondition || args.timerCondition || "").replace(/^["']|["']$/g, "").trim().toLowerCase();
998
3808
  const duration = Number(args.DurationSeconds || args.durationSeconds || args.duration || 0);
999
3809
 
1000
- const isTaskPoll =
1001
- cond.startsWith("task-") ||
1002
- cond.includes("task") ||
1003
- prompt.includes("check on") ||
1004
- prompt.includes("check task") ||
1005
- prompt.includes("background task") ||
1006
- prompt.includes("still running") ||
1007
- prompt.includes("test suite") ||
1008
- ((prompt.includes("check") || prompt.includes("status") || prompt.includes("poll") || prompt.includes("monitor") || prompt.includes("wait")) &&
1009
- (prompt.includes("task") || prompt.includes("process") || prompt.includes("job") || prompt.includes("test") || prompt.includes("command") || prompt.includes("suite")));
1010
-
1011
- if (isTaskPoll) {
3810
+ if (isSwarmPollSchedule(args)) {
3811
+ const session = getSessionState(convId);
3812
+ session.denials++;
3813
+ saveSessionState(convId, session);
3814
+ respond({
3815
+ decision: "deny",
3816
+ reason:
3817
+ "Antigravity Execution Governance: Using one-shot 'schedule' timers to poll a swarm or subagent is strictly prohibited. " +
3818
+ "For '/swarm', call 'swarm_wait' directly without a 'schedule' timer (it sleeps at zero token cost and wakes immediately when any board message arrives, while the dedicated 'swarm_monitor' daemon cron checks for stalls). " +
3819
+ "For subagents, stop calling tools and rely on native Reactive Wakeup (<SYSTEM_MESSAGE>)."
3820
+ });
3821
+ }
3822
+
3823
+ if (isTaskPollSchedule(args)) {
1012
3824
  const session = getSessionState(convId);
1013
3825
  const requiredBackoffSec = Math.max(30, getRequiredBackoffSeconds(session.pollCount));
1014
3826
 
@@ -1030,7 +3842,12 @@ try {
1030
3842
  });
1031
3843
  }
1032
3844
 
1033
- // If duration >= requiredBackoffSec, watchdog timer is permitted!
3845
+ // If duration >= requiredBackoffSec, watchdog timer is permitted AND advances backoff level
3846
+ // so repeated 45s/60s timers without exponential backoff are blocked on subsequent turns:
3847
+ session.pollCount = Math.max(1, session.pollCount) + 1;
3848
+ session.lastPollTime = Date.now();
3849
+ session.denials = 0;
3850
+ saveSessionState(convId, session);
1034
3851
  respond({ decision: "allow" });
1035
3852
  }
1036
3853
 
@@ -1039,10 +3856,57 @@ try {
1039
3856
  }
1040
3857
 
1041
3858
  // =========================================================================
1042
- // 4. Benchmark & Evaluator Mutation Protection (LH-09)
3859
+ // 4. Benchmark, Governance & Evaluator Mutation Protection (LH-09 & LH-02)
1043
3860
  // =========================================================================
1044
- if (toolName === "replace_file_content" || toolName === "write_to_file") {
1045
- const targetFile = String(args.TargetFile || args.targetFile || args.path || "").trim();
3861
+ if (
3862
+ toolName === "replace_file_content" ||
3863
+ toolName === "write_to_file" ||
3864
+ toolName === "write_file" ||
3865
+ toolName === "edit_file"
3866
+ ) {
3867
+ const targetFile = String(args.Path || args.path || args.TargetFile || args.targetFile || "").trim();
3868
+ if (isProtectedInternalState(targetFile)) {
3869
+ respond({
3870
+ decision: "deny",
3871
+ reason: buildInternalStateToolDenial(convId)
3872
+ });
3873
+ }
3874
+ if (isProtectedGovernanceFileMutation(targetFile, convId)) {
3875
+ respond({
3876
+ decision: "deny",
3877
+ reason:
3878
+ "Antigravity Execution Governance: Direct mutation of protected Antigravity hook, shim, or governance source files is strictly prohibited."
3879
+ });
3880
+ }
3881
+
3882
+ const bodyContent = String(
3883
+ args.Content ||
3884
+ args.content ||
3885
+ args.file_content ||
3886
+ args.Replacement ||
3887
+ args.replacement ||
3888
+ args.new_string ||
3889
+ ""
3890
+ );
3891
+ const normTarget = targetFile.replace(/\\/g, "/");
3892
+ const isTmpOrScratch =
3893
+ normTarget.startsWith("/tmp/") ||
3894
+ normTarget.startsWith("/var/tmp/") ||
3895
+ normTarget.includes("/scratch/") ||
3896
+ normTarget.startsWith("scratch/") ||
3897
+ normTarget.startsWith(os.tmpdir().replace(/\\/g, "/") + "/");
3898
+ const isScript = isExecutableScriptPath(targetFile) || /^\s*#!/.test(bodyContent);
3899
+ const callerAccess = evaluateCallerTranscriptAccess(convId);
3900
+
3901
+ if (isScript && bodyContent && (callerAccess.isRestrictedChild || isTmpOrScratch)) {
3902
+ if (isProtectedShellOrScriptOperation(bodyContent)) {
3903
+ respond({
3904
+ decision: "deny",
3905
+ reason: buildInternalStateShellDenial(convId)
3906
+ });
3907
+ }
3908
+ }
3909
+
1046
3910
  if (isProtectedBenchmarkPath(targetFile)) {
1047
3911
  respond({
1048
3912
  decision: "force_ask",