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.
- package/CHANGELOG.md +27 -1
- package/README.md +52 -220
- package/assets/architecture-overview.svg +39 -0
- package/bin/agy-context.mjs +616 -0
- package/hooks.json +3 -3
- package/install.ps1 +1 -1
- package/lib/transcript-core.mjs +2310 -0
- package/package.json +11 -10
- package/plugin.json +6 -6
- package/rules/AGENTS.md +47 -79
- package/scripts/execution-guard-hook.mjs +3239 -375
- package/scripts/install-register.mjs +465 -323
- package/scripts/provision-rtk.mjs +35 -26
- package/scripts/release-preflight.mjs +75 -11
- package/scripts/state-dir.mjs +1 -0
- package/skills/context-governance/SKILL.md +85 -79
- package/mcp/index.js +0 -1332
- package/mcp/instructions.md +0 -33
- package/mcp_config.json +0 -8
|
@@ -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
|
-
* -
|
|
9
|
-
* -
|
|
10
|
-
* -
|
|
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 ->
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* - find_by_name ->
|
|
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
|
-
* -
|
|
20
|
-
*
|
|
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
|
|
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
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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
|
-
|
|
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.
|
|
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]) =>
|
|
121
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
197
|
-
|
|
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
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
"
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
"
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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
|
|
394
|
-
|
|
395
|
-
|
|
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
|
|
399
|
-
const
|
|
400
|
-
const
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
|
|
408
|
-
|
|
409
|
-
const
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
const
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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
|
-
}
|
|
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
|
-
//
|
|
491
|
-
if (
|
|
492
|
-
|
|
493
|
-
if (
|
|
494
|
-
|
|
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
|
|
2927
|
+
return `${effEnvPrefix}${mapped}`;
|
|
497
2928
|
}
|
|
498
2929
|
|
|
499
|
-
//
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
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
|
|
2936
|
+
return `${effEnvPrefix}${mapped}`;
|
|
511
2937
|
}
|
|
512
2938
|
|
|
513
|
-
//
|
|
514
|
-
|
|
515
|
-
|
|
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
|
-
|
|
529
|
-
|
|
2944
|
+
return trimmedEff;
|
|
2945
|
+
}
|
|
530
2946
|
|
|
531
|
-
|
|
532
|
-
const
|
|
533
|
-
|
|
534
|
-
|
|
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
|
-
|
|
538
|
-
|
|
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
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
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
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
//
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
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: "
|
|
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
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
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
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
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
|
-
//
|
|
674
|
-
if (
|
|
675
|
-
|
|
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
|
|
679
|
-
const
|
|
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
|
|
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:
|
|
692
|
-
if (taskMatch && !
|
|
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
|
-
|
|
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
|
|
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 (
|
|
708
|
-
overwrite.CommandLine =
|
|
3544
|
+
if (effectiveCmd !== rawCmd) {
|
|
3545
|
+
overwrite.CommandLine = effectiveCmd;
|
|
709
3546
|
}
|
|
710
|
-
if (!isDaemon &&
|
|
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
|
|
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
|
-
|
|
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:
|
|
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.
|
|
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
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
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
|
-
|
|
844
|
-
|
|
845
|
-
|
|
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
|
-
|
|
853
|
-
|
|
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 '
|
|
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 '
|
|
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 '
|
|
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
|
|
918
|
-
const
|
|
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.
|
|
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
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
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 (
|
|
1045
|
-
|
|
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",
|