@blogic-cz/agent-tools 1.5.4 → 1.5.6
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/README.md +3 -2
- package/dist/credential-guard/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/credential-guard/index.ts +595 -96
package/README.md
CHANGED
|
@@ -634,8 +634,9 @@ Claude Code uses shell command hooks. The package ships a ready-made wrapper scr
|
|
|
634
634
|
|
|
635
635
|
That's it. The hook reads tool input from stdin, runs the guard, and exits with code 2 (blocked + reason on stderr) or 0 (allowed).
|
|
636
636
|
|
|
637
|
-
The guard permits static `printenv` reads only for exact names in `credentialGuard.allowedEnvironmentVariables`. The default list is empty. Multiple names are allowed only when each name is approved.
|
|
638
|
-
|
|
637
|
+
The guard permits static `printenv` reads and simple `echo`/`printf` expansions only for exact names in `credentialGuard.allowedEnvironmentVariables`. The default list is empty. Multiple names are allowed only when each name is approved. Leading standalone literal assignments such as `F=README.md; cat "$F"` work in supported static commands. Conditional assignments, loop variables, mutation such as `printf -v`, and unknown commands cannot establish that proof. Use a literal path or a leading semicolon-terminated assignment for these cases. Approved environment values may be displayed, but redirecting their pipeline or passing them to an executor stays blocked.
|
|
638
|
+
Ordinary navigation operands such as `cd "$HOME"` and `git -C "$WORKTREE_ROOT" status --short` do not need display permission. This does not authorize printing those values, using them as code/options, or reading unknown file paths. In `printf`, approved values belong in data arguments after a fixed nonmutating format, for example `printf -- '%s' "$WORKSPACE_LABEL"`.
|
|
639
|
+
It recognizes `rtk`, `rtk proxy`, `command`, and supported `env` prefixes and checks each command in a pipeline or chain. Quoted filenames, comments, and redirects retain the sensitive-path checks. Jq object selectors and inline awk programs qualify only when their actual program and options establish passive behavior.
|
|
639
640
|
Literal environment-command text in `rg`, `grep`, `git grep`, `echo`, and `printf` is allowed. In pipelines, every other command must be a known passive text command or an approved static environment read.
|
|
640
641
|
Each pipeline is checked separately from statements joined by `;`, `&&`, or `||`.
|
|
641
642
|
Execution options, other environment reads, and environment reads in dynamic or unsupported shell syntax stay blocked. Custom dangerous-command patterns still apply to the complete input.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/credential-guard/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,KAAK,EAAmB,qBAAqB,EAAE,MAAM,eAAe,CAAC;AAM5E,8CAA8C;AAC9C,MAAM,MAAM,SAAS,GAAG;IACtB,IAAI,EAAE,MAAM,CAAC;CACd,CAAC;AAEF,+CAA+C;AAC/C,MAAM,MAAM,UAAU,GAAG;IACvB,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAC/B,CAAC;AAQF,+CAA+C;AAC/C,MAAM,MAAM,eAAe,GAAG;IAC5B,uBAAuB,EAAE,CAAC,KAAK,EAAE,SAAS,EAAE,MAAM,EAAE,UAAU,KAAK,IAAI,CAAC;IACxE,aAAa,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC;IAC3E,aAAa,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC;IAC7C,aAAa,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC;IAC7C,sBAAsB,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC;IACrD,iBAAiB,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC;IACjF,kBAAkB,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC;IACjD,kBAAkB,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,MAAM,GAAG,IAAI,CAAC;CACxD,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/credential-guard/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,KAAK,EAAmB,qBAAqB,EAAE,MAAM,eAAe,CAAC;AAM5E,8CAA8C;AAC9C,MAAM,MAAM,SAAS,GAAG;IACtB,IAAI,EAAE,MAAM,CAAC;CACd,CAAC;AAEF,+CAA+C;AAC/C,MAAM,MAAM,UAAU,GAAG;IACvB,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAC/B,CAAC;AAQF,+CAA+C;AAC/C,MAAM,MAAM,eAAe,GAAG;IAC5B,uBAAuB,EAAE,CAAC,KAAK,EAAE,SAAS,EAAE,MAAM,EAAE,UAAU,KAAK,IAAI,CAAC;IACxE,aAAa,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC;IAC3E,aAAa,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC;IAC7C,aAAa,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC;IAC7C,sBAAsB,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC;IACrD,iBAAiB,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC;IACjF,kBAAkB,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC;IACjD,kBAAkB,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,MAAM,GAAG,IAAI,CAAC;CACxD,CAAC;AAspCF,6CAA6C;AAC7C,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAErE;AAED,2CAA2C;AAC3C,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAEpE;AAED,2CAA2C;AAC3C,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAEpE;AAMD;;;;;GAKG;AACH,wBAAgB,qBAAqB,CAAC,MAAM,CAAC,EAAE,qBAAqB,GAAG,eAAe,CAoPrF;AAQD,kEAAkE;AAClE,eAAO,MAAM,uBAAuB,UAp7CD,SAAS,UAAU,UAAU,KAAK,IAo7CM,CAAC;AAE5E,oDAAoD;AACpD,eAAO,MAAM,aAAa,YAt7CC,MAAM,KAAK;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAAG,IAs7CjB,CAAC;AAExD,yEAAyE;AACzE,eAAO,MAAM,aAAa,aAx7CE,MAAM,KAAK,OAw7CgB,CAAC;AAExD,yDAAyD;AACzD,eAAO,MAAM,aAAa,aA17CE,MAAM,KAAK,OA07CgB,CAAC;AAExD,oEAAoE;AACpE,eAAO,MAAM,sBAAsB,YA57CC,MAAM,KAAK,OA47C0B,CAAC;AAE1E,iDAAiD;AACjD,eAAO,MAAM,iBAAiB,YA97CC,MAAM,KAAK;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GAAG,IA87Cf,CAAC;AAEhE,wDAAwD;AACxD,eAAO,MAAM,kBAAkB,YAh8CC,MAAM,KAAK,OAg8CsB,CAAC;AAElE,8EAA8E;AAC9E,eAAO,MAAM,kBAAkB,YAl8CC,MAAM,KAAK,MAAM,GAAG,IAk8Ca,CAAC"}
|
package/package.json
CHANGED
|
@@ -206,7 +206,7 @@ const DEFAULT_POLLING_DETECTION_RULES: PollingDetectionRule[] = [
|
|
|
206
206
|
/**
|
|
207
207
|
* Read-only gh subcommands safe on external repos with -R flag.
|
|
208
208
|
*/
|
|
209
|
-
const GH_ALLOWED_READONLY_SUBCOMMANDS = [
|
|
209
|
+
const GH_ALLOWED_READONLY_SUBCOMMANDS = new Set([
|
|
210
210
|
"issue list",
|
|
211
211
|
"issue view",
|
|
212
212
|
"issue search",
|
|
@@ -220,7 +220,128 @@ const GH_ALLOWED_READONLY_SUBCOMMANDS = [
|
|
|
220
220
|
"search issues",
|
|
221
221
|
"search prs",
|
|
222
222
|
"search repos",
|
|
223
|
-
];
|
|
223
|
+
]);
|
|
224
|
+
|
|
225
|
+
function isAllowedGhArgv(argv: string[]): boolean {
|
|
226
|
+
const subcommand = argv.slice(1, 3).join(" ");
|
|
227
|
+
return (
|
|
228
|
+
argv.some((arg, index) => (arg === "-R" || arg === "--repo") && Boolean(argv[index + 1])) &&
|
|
229
|
+
GH_ALLOWED_READONLY_SUBCOMMANDS.has(subcommand)
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function getLeadingLiteralAssignments(
|
|
234
|
+
command: string,
|
|
235
|
+
): Map<string, { value: string; end: number }> {
|
|
236
|
+
const assignments = new Map<string, { value: string; end: number }>();
|
|
237
|
+
let offset = 0;
|
|
238
|
+
while (offset === 0 || command[offset - 1] === ";" || command[offset - 1] === "\n") {
|
|
239
|
+
while (/\s/.test(command[offset] ?? "")) offset++;
|
|
240
|
+
const assignment = /^([A-Za-z_][A-Za-z0-9_]*)=([A-Za-z0-9_./-]+)/.exec(command.slice(offset));
|
|
241
|
+
if (!assignment) break;
|
|
242
|
+
const end = offset + assignment[0].length;
|
|
243
|
+
const next = command.slice(end).match(/^\s*/)?.[0].length ?? 0;
|
|
244
|
+
const separator = command[end + next];
|
|
245
|
+
if (separator !== ";" && separator !== "\n") break;
|
|
246
|
+
assignments.set(assignment[1] ?? "", { value: assignment[2] ?? "", end });
|
|
247
|
+
offset = end + next + 1;
|
|
248
|
+
}
|
|
249
|
+
return assignments;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function isSafeHerdrTabCreate(argv: string[]): boolean {
|
|
253
|
+
if (argv[0] !== "herdr" || argv[1] !== "tab" || argv[2] !== "create") return false;
|
|
254
|
+
const seen = new Set<string>();
|
|
255
|
+
for (let index = 3; index < argv.length; index++) {
|
|
256
|
+
const arg = argv[index] ?? "";
|
|
257
|
+
const [option, ...attached] = arg.split("=");
|
|
258
|
+
if (option === "--focus" || option === "--no-focus") {
|
|
259
|
+
if (attached.length || seen.has("focus")) return false;
|
|
260
|
+
seen.add("focus");
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
if (!["--workspace", "--cwd", "--label"].includes(option ?? "") || seen.has(option ?? ""))
|
|
264
|
+
return false;
|
|
265
|
+
const value = attached.length ? attached.join("=") : argv[++index];
|
|
266
|
+
if (!value || (!attached.length && value.startsWith("--"))) return false;
|
|
267
|
+
seen.add(option ?? "");
|
|
268
|
+
}
|
|
269
|
+
return true;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function hasSafeLocalAssignmentCommands(
|
|
273
|
+
command: string,
|
|
274
|
+
assignments: Map<string, { value: string; end: number }>,
|
|
275
|
+
): boolean {
|
|
276
|
+
if (!assignments.size) return true;
|
|
277
|
+
const parsed = parseStaticShellCommands(command);
|
|
278
|
+
if (!parsed || typeof parsed === "string") return false;
|
|
279
|
+
return parsed.pipelines.flat().every((words) => {
|
|
280
|
+
const assignment = /^([A-Za-z_][A-Za-z0-9_]*)=([A-Za-z0-9_./-]+)$/.exec(words[0] ?? "");
|
|
281
|
+
if (assignment) {
|
|
282
|
+
return (
|
|
283
|
+
words.length === 1 &&
|
|
284
|
+
assignment[1] !== "IFS" &&
|
|
285
|
+
assignments.get(assignment[1] ?? "")?.value === assignment[2]
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
const argv = unwrapStaticCommand(words);
|
|
289
|
+
if (isSafeHerdrTabCreate(argv)) return true;
|
|
290
|
+
const name = argv[0]?.split("/").at(-1) ?? "";
|
|
291
|
+
if (name === "cd") return isNavigationOperand(argv, "LOCALVALUE");
|
|
292
|
+
if (name === "git") {
|
|
293
|
+
let index = 1;
|
|
294
|
+
while (argv[index] === "-C" && argv[index + 1]) index += 2;
|
|
295
|
+
return (
|
|
296
|
+
["branch", "diff", "log", "rev-parse", "rev-list", "show", "status"].includes(
|
|
297
|
+
argv[index] ?? "",
|
|
298
|
+
) && !hasExecutionOption(argv.slice(index + 1), ["exec", "ext-diff", "textconv"])
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
if (name === "find") {
|
|
302
|
+
return !argv.slice(1).some((arg) => /^-(?:exec|ok|delete|fprint|fprintf)/.test(arg));
|
|
303
|
+
}
|
|
304
|
+
if (name === "bun") return argv[1] === "run" && ["db-tool", "gh-tool"].includes(argv[2] ?? "");
|
|
305
|
+
return (
|
|
306
|
+
isPassiveTextCommand(argv) ||
|
|
307
|
+
["ls", "cat", "tail", "wc", "uniq", "cut", "mv"].includes(name) ||
|
|
308
|
+
(name === "sort" && !hasExecutionOption(argv.slice(1), ["compress-program"])) ||
|
|
309
|
+
(name === "sed" &&
|
|
310
|
+
argv
|
|
311
|
+
.slice(1)
|
|
312
|
+
.every(
|
|
313
|
+
(arg) =>
|
|
314
|
+
/^-[En]+$/.test(arg) ||
|
|
315
|
+
/^s([/|]).*\1[^/|]*\1[gp]*$/.test(arg) ||
|
|
316
|
+
/^[A-Za-z0-9_./-]+$/.test(arg),
|
|
317
|
+
) &&
|
|
318
|
+
!argv.includes("-f"))
|
|
319
|
+
);
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function hasSensitivePathRedirect(
|
|
324
|
+
command: string,
|
|
325
|
+
isPathBlocked: (path: string) => boolean,
|
|
326
|
+
): boolean {
|
|
327
|
+
let heredoc = boundedHeredoc(command);
|
|
328
|
+
while (heredoc) {
|
|
329
|
+
if (!heredoc.closed) return true;
|
|
330
|
+
command = [heredoc.header, heredoc.following].join("\n");
|
|
331
|
+
heredoc = boundedHeredoc(command);
|
|
332
|
+
}
|
|
333
|
+
const parsed = parseStaticShellCommands(command, true);
|
|
334
|
+
if (!parsed || typeof parsed === "string") return false;
|
|
335
|
+
const assignments = getLeadingLiteralAssignments(command);
|
|
336
|
+
return parsed.redirects.some(({ target, dynamic }) => {
|
|
337
|
+
if (!dynamic) return isPathBlocked(target);
|
|
338
|
+
const materialized = target.replace(
|
|
339
|
+
/\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g,
|
|
340
|
+
(match, braced: string, plain: string) => assignments.get(braced ?? plain)?.value ?? match,
|
|
341
|
+
);
|
|
342
|
+
return /[$`]/.test(materialized) || isPathBlocked(materialized);
|
|
343
|
+
});
|
|
344
|
+
}
|
|
224
345
|
|
|
225
346
|
// ============================================================================
|
|
226
347
|
// HELPERS
|
|
@@ -230,11 +351,21 @@ function escapeRegex(s: string): string {
|
|
|
230
351
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
231
352
|
}
|
|
232
353
|
|
|
233
|
-
/** Static shell words only. Never expand variables or execute a command.
|
|
354
|
+
/** Static shell words only. Never expand variables or execute a command.
|
|
355
|
+
* literalOperandsOnly retains recognizable paths/commands on unsupported expansions;
|
|
356
|
+
* callers may use it to deny, never to establish a safe exception.
|
|
357
|
+
*/
|
|
234
358
|
function parseStaticShellCommands(
|
|
235
359
|
command: string,
|
|
236
|
-
|
|
360
|
+
literalOperandsOnly = false,
|
|
361
|
+
):
|
|
362
|
+
| { pipelines: string[][][]; redirects: { target: string; dynamic: boolean; pipeline: number }[] }
|
|
363
|
+
| "brace-expansion"
|
|
364
|
+
| undefined {
|
|
237
365
|
const pipelines: string[][][] = [];
|
|
366
|
+
const redirects: { target: string; dynamic: boolean; pipeline: number }[] = [];
|
|
367
|
+
let redirect = false;
|
|
368
|
+
let dynamic = false;
|
|
238
369
|
let commands: string[][] = [];
|
|
239
370
|
let argv: string[] = [];
|
|
240
371
|
let word = "";
|
|
@@ -242,9 +373,14 @@ function parseStaticShellCommands(
|
|
|
242
373
|
let quote: "'" | '"' | undefined;
|
|
243
374
|
|
|
244
375
|
const finishWord = () => {
|
|
245
|
-
if (started)
|
|
376
|
+
if (started) {
|
|
377
|
+
if (redirect) redirects.push({ target: word, dynamic, pipeline: pipelines.length });
|
|
378
|
+
else argv.push(word);
|
|
379
|
+
redirect = false;
|
|
380
|
+
}
|
|
246
381
|
word = "";
|
|
247
382
|
started = false;
|
|
383
|
+
dynamic = false;
|
|
248
384
|
};
|
|
249
385
|
const finishCommand = () => {
|
|
250
386
|
finishWord();
|
|
@@ -269,17 +405,46 @@ function parseStaticShellCommands(
|
|
|
269
405
|
word += next;
|
|
270
406
|
started = true;
|
|
271
407
|
} else if (char === "$" || char === "`") {
|
|
272
|
-
return undefined;
|
|
408
|
+
if (!literalOperandsOnly) return undefined;
|
|
409
|
+
dynamic = true;
|
|
410
|
+
word += char;
|
|
411
|
+
started = true;
|
|
273
412
|
} else if (quote === '"') {
|
|
274
413
|
if (char === quote) quote = undefined;
|
|
275
414
|
else word += char;
|
|
276
415
|
} else if (char === "'" || char === '"') {
|
|
277
416
|
quote = char;
|
|
278
417
|
started = true;
|
|
279
|
-
} else if (
|
|
418
|
+
} else if (
|
|
419
|
+
!literalOperandsOnly &&
|
|
420
|
+
char === "{" &&
|
|
421
|
+
/^\{[^{}]*(?:,|\.\.)[^{}]*\}/.test(command.slice(i))
|
|
422
|
+
) {
|
|
280
423
|
return "brace-expansion";
|
|
281
|
-
} else if (
|
|
282
|
-
|
|
424
|
+
} else if (char === ">" || char === "<") {
|
|
425
|
+
if (char === "<" && command[i + 1] === "<") {
|
|
426
|
+
if (literalOperandsOnly) break;
|
|
427
|
+
return undefined;
|
|
428
|
+
}
|
|
429
|
+
if (/^\d+$/.test(word)) {
|
|
430
|
+
word = "";
|
|
431
|
+
started = false;
|
|
432
|
+
} else {
|
|
433
|
+
finishWord();
|
|
434
|
+
}
|
|
435
|
+
if (redirect) return undefined;
|
|
436
|
+
if (command[i + 1] === char) i++;
|
|
437
|
+
if (command[i + 1] === "&" && /[0-9-]/.test(command[i + 2] ?? "")) {
|
|
438
|
+
i += 2;
|
|
439
|
+
while (/\d/.test(command[i + 1] ?? "")) i++;
|
|
440
|
+
} else {
|
|
441
|
+
redirect = true;
|
|
442
|
+
}
|
|
443
|
+
} else if (char === "#" && !started) {
|
|
444
|
+
while (i + 1 < command.length && command[i + 1] !== "\n") i++;
|
|
445
|
+
} else if (/[()]/.test(char)) {
|
|
446
|
+
if (!literalOperandsOnly) return undefined;
|
|
447
|
+
finishWord();
|
|
283
448
|
} else if (char === "|" && command[i - 1] !== "|" && command[i + 1] !== "|") {
|
|
284
449
|
finishCommand();
|
|
285
450
|
if (command[i + 1] === "&") i++;
|
|
@@ -293,9 +458,10 @@ function parseStaticShellCommands(
|
|
|
293
458
|
}
|
|
294
459
|
}
|
|
295
460
|
|
|
296
|
-
if (quote) return undefined;
|
|
461
|
+
if (quote && !literalOperandsOnly) return undefined;
|
|
297
462
|
finishPipeline();
|
|
298
|
-
|
|
463
|
+
if (redirect) return undefined;
|
|
464
|
+
return { pipelines, redirects };
|
|
299
465
|
}
|
|
300
466
|
|
|
301
467
|
function mentionsEnvironmentRead(text: string): boolean {
|
|
@@ -383,20 +549,59 @@ function isLiteralTextProducer(argv: string[]): boolean {
|
|
|
383
549
|
|
|
384
550
|
function isAwkExecution(argv: string[]): boolean {
|
|
385
551
|
if (argv[0]?.split("/").at(-1) !== "awk") return false;
|
|
386
|
-
|
|
387
|
-
|
|
552
|
+
// Only a literal inline program establishes passive behavior. File/unknown options do not.
|
|
553
|
+
const program = argv[1];
|
|
554
|
+
return (
|
|
555
|
+
!program ||
|
|
556
|
+
program.startsWith("-") ||
|
|
557
|
+
/\b(?:system|getline|ENVIRON)\b|\||\b(?:print|printf)\b[^\n]*>|@(?:include|load)/.test(
|
|
558
|
+
program,
|
|
559
|
+
) ||
|
|
560
|
+
argv.slice(2).some((arg) => arg.startsWith("-"))
|
|
561
|
+
);
|
|
388
562
|
}
|
|
389
563
|
|
|
390
564
|
function isJqObjectConstruction(argv: string[]): boolean {
|
|
565
|
+
if (argv[0]?.split("/").at(-1) !== "jq") return false;
|
|
566
|
+
let index = 1;
|
|
567
|
+
// Options with values, program files, and unknown options cannot prove an inline filter.
|
|
568
|
+
while (/^-[rRcMsScn]+$/.test(argv[index] ?? "")) index++;
|
|
569
|
+
if (argv[index] === "--") index++;
|
|
570
|
+
const filter = argv[index] ?? "";
|
|
571
|
+
if (argv.slice(index + 1).some((arg) => arg.startsWith("-"))) return false;
|
|
391
572
|
return (
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
!argv.slice(1).some((arg) => /\benv\b|\binput(?:s)?\b/.test(arg))
|
|
573
|
+
/^\{\s*[A-Za-z_][A-Za-z0-9_-]*(?:\s*,\s*[A-Za-z_][A-Za-z0-9_-]*)*\s*\}$/.test(filter) ||
|
|
574
|
+
/^\{\s*[A-Za-z_][A-Za-z0-9_-]*\s*:\s*\.[A-Za-z_][A-Za-z0-9_.-]*(?:\s*,\s*[A-Za-z_][A-Za-z0-9_-]*\s*:\s*\.[A-Za-z_][A-Za-z0-9_.-]*)*\s*\}$/.test(
|
|
575
|
+
filter,
|
|
576
|
+
)
|
|
397
577
|
);
|
|
398
578
|
}
|
|
399
579
|
|
|
580
|
+
function jqFileArguments(argv: string[]): string[] {
|
|
581
|
+
const files: string[] = [];
|
|
582
|
+
let index = 1;
|
|
583
|
+
for (; index < argv.length; index++) {
|
|
584
|
+
const arg = argv[index] ?? "";
|
|
585
|
+
if (arg === "--") {
|
|
586
|
+
index++;
|
|
587
|
+
break;
|
|
588
|
+
}
|
|
589
|
+
if (/^-[rRcMsScn]+$/.test(arg)) continue;
|
|
590
|
+
if (arg === "--arg" || arg === "--argjson") {
|
|
591
|
+
index += 2;
|
|
592
|
+
continue;
|
|
593
|
+
}
|
|
594
|
+
if (arg === "--rawfile" || arg === "--slurpfile") {
|
|
595
|
+
files.push(argv[index + 2] ?? "");
|
|
596
|
+
index += 2;
|
|
597
|
+
continue;
|
|
598
|
+
}
|
|
599
|
+
if (arg.startsWith("-")) return argv.slice(1);
|
|
600
|
+
break;
|
|
601
|
+
}
|
|
602
|
+
return [...files, ...argv.slice(index + 1)];
|
|
603
|
+
}
|
|
604
|
+
|
|
400
605
|
/** Index in `args` where the command wrapped by `env` starts, or null when `env` only lists. */
|
|
401
606
|
function environmentCommandStart(args: string[]): number | null {
|
|
402
607
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -408,7 +613,8 @@ function environmentCommandStart(args: string[]): number | null {
|
|
|
408
613
|
continue;
|
|
409
614
|
}
|
|
410
615
|
if (arg === "-i" || arg === "--ignore-environment" || arg.startsWith("--chdir=")) continue;
|
|
411
|
-
if (arg.startsWith("
|
|
616
|
+
if (arg.startsWith("--unset=")) continue;
|
|
617
|
+
if (arg.startsWith("-")) return null;
|
|
412
618
|
return i;
|
|
413
619
|
}
|
|
414
620
|
return null;
|
|
@@ -444,13 +650,25 @@ function isSafeLiteralConsumer(argv: string[]): boolean {
|
|
|
444
650
|
}
|
|
445
651
|
|
|
446
652
|
function unwrapStaticCommand(words: string[]): string[] {
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
653
|
+
let argv = [...words];
|
|
654
|
+
while (argv.length) {
|
|
655
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(argv[0] ?? "")) {
|
|
656
|
+
argv.shift();
|
|
657
|
+
continue;
|
|
658
|
+
}
|
|
659
|
+
const executable = argv[0]?.split("/").at(-1);
|
|
660
|
+
if (executable === "env") {
|
|
661
|
+
const wrapped = unwrapEnvironmentCommand(argv);
|
|
662
|
+
if (!wrapped) break;
|
|
663
|
+
argv = wrapped;
|
|
664
|
+
} else if (executable === "rtk" || executable === "command") {
|
|
665
|
+
argv.shift();
|
|
666
|
+
if (executable === "rtk") {
|
|
667
|
+
if (argv[0] === "proxy") argv.shift();
|
|
668
|
+
else if (argv[0] === "read") argv[0] = "cat";
|
|
669
|
+
}
|
|
670
|
+
if (executable === "command" && argv[0] === "--") argv.shift();
|
|
671
|
+
} else break;
|
|
454
672
|
}
|
|
455
673
|
return argv;
|
|
456
674
|
}
|
|
@@ -473,25 +691,62 @@ function hasExecutionOption(args: string[], longOptions: string[], shortOption?:
|
|
|
473
691
|
});
|
|
474
692
|
}
|
|
475
693
|
|
|
694
|
+
function passivePrintfFormatIndex(argv: string[]): number | undefined {
|
|
695
|
+
const index = argv[1] === "--" ? 2 : 1;
|
|
696
|
+
const format = argv[index];
|
|
697
|
+
if (format === undefined || (index === 1 && format.startsWith("-"))) return undefined;
|
|
698
|
+
// Ignore escaped percent signs; %n writes a shell variable named by a data argument.
|
|
699
|
+
return /%[-+ #0]*(?:\d+|\*)?(?:\.(?:\d+|\*))?[hljztL]*n/.test(format.replaceAll("%%", ""))
|
|
700
|
+
? undefined
|
|
701
|
+
: index;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/** Unknown values are permitted only in operands that navigate without displaying the value. */
|
|
705
|
+
function isNavigationOperand(argv: string[], marker: string): boolean {
|
|
706
|
+
const name = argv[0]?.split("/").at(-1);
|
|
707
|
+
if (name === "cd") {
|
|
708
|
+
const index = argv[1] === "--" ? 2 : 1;
|
|
709
|
+
return argv.length === index + 1 && !argv[0]?.includes(marker);
|
|
710
|
+
}
|
|
711
|
+
if (name !== "git") return false;
|
|
712
|
+
let index = 1;
|
|
713
|
+
while (argv[index] === "-C" && argv[index + 1]) index += 2;
|
|
714
|
+
return (
|
|
715
|
+
["status", "rev-parse", "rev-list"].includes(argv[index] ?? "") &&
|
|
716
|
+
!argv.slice(index).some((arg) => arg.includes(marker)) &&
|
|
717
|
+
!argv[0]?.includes(marker)
|
|
718
|
+
);
|
|
719
|
+
}
|
|
720
|
+
|
|
476
721
|
function isPassiveTextCommand(argv: string[]): boolean {
|
|
477
722
|
const name = argv[0]?.split("/").at(-1) ?? "";
|
|
478
723
|
const args = argv.slice(1);
|
|
479
724
|
// These commands consume literal text. Execution options are not exceptions.
|
|
480
|
-
if (
|
|
725
|
+
if (name === "printf") return passivePrintfFormatIndex(argv) !== undefined;
|
|
726
|
+
if (["echo", "grep", "head"].includes(name)) {
|
|
481
727
|
return true;
|
|
482
728
|
}
|
|
483
729
|
if (name === "rg") return !hasExecutionOption(args, ["pre", "hostname-bin"]);
|
|
484
730
|
if (name === "awk") return !isAwkExecution(argv);
|
|
485
731
|
if (name === "jq") return isJqObjectConstruction(argv);
|
|
486
732
|
return (
|
|
487
|
-
name === "git" &&
|
|
488
|
-
|
|
489
|
-
|
|
733
|
+
(name === "git" && args[0] === "status") ||
|
|
734
|
+
(name === "git" &&
|
|
735
|
+
args[0] === "grep" &&
|
|
736
|
+
!hasExecutionOption(args, ["open-files-in-pager", "textconv"], "O"))
|
|
490
737
|
);
|
|
491
738
|
}
|
|
492
739
|
|
|
493
740
|
function hasStaticEnvironmentRead(argv: string[], allowedNames: Set<string>): boolean {
|
|
494
741
|
const name = argv[0]?.split("/").at(-1);
|
|
742
|
+
if (
|
|
743
|
+
["sh", "bash", "zsh"].includes(name ?? "") &&
|
|
744
|
+
hasExecutionOption(argv.slice(1), ["command"], "c") &&
|
|
745
|
+
argv.slice(1).some((arg) => /\$\{?[A-Za-z_]/.test(arg))
|
|
746
|
+
) {
|
|
747
|
+
return true;
|
|
748
|
+
}
|
|
749
|
+
if (name === "awk" && argv.slice(1).some((arg) => /\bENVIRON\b/.test(arg))) return true;
|
|
495
750
|
if (name === "printenv") return !isAllowedEnvironmentRead(argv, allowedNames);
|
|
496
751
|
if (name === "env") {
|
|
497
752
|
const wrapped = unwrapEnvironmentCommand(argv);
|
|
@@ -505,21 +760,20 @@ function hasStaticEnvironmentRead(argv: string[], allowedNames: Set<string>): bo
|
|
|
505
760
|
}
|
|
506
761
|
|
|
507
762
|
function isStaticHerdrPrompt(command: string): boolean {
|
|
508
|
-
if (/^herdr\s+agent\s+prompt\s+\S+\s+(?:"(?:\\.|[^"])*"|'(?:\\.|[^'])*')$/.test(command.trim())) {
|
|
509
|
-
if (!/[$`]/.test(command)) return true;
|
|
510
|
-
return !hasSensitiveFileRead(command) && !mentionsEnvironmentRead(command);
|
|
511
|
-
}
|
|
512
763
|
const parsed = parseStaticShellCommands(command);
|
|
513
|
-
if (
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
764
|
+
if (!parsed || typeof parsed === "string") return false;
|
|
765
|
+
const commands = parsed.pipelines.flat().map(unwrapStaticCommand);
|
|
766
|
+
const prompts = commands.filter(
|
|
767
|
+
(argv) => argv[0] === "herdr" && argv[1] === "agent" && argv[2] === "prompt",
|
|
768
|
+
);
|
|
769
|
+
return (
|
|
770
|
+
prompts.length > 0 &&
|
|
771
|
+
commands.every((argv) =>
|
|
772
|
+
argv[0] === "herdr" && argv[1] === "agent" && argv[2] === "prompt"
|
|
773
|
+
? argv.length >= 5
|
|
774
|
+
: isPassiveTextCommand(argv),
|
|
775
|
+
)
|
|
776
|
+
);
|
|
523
777
|
}
|
|
524
778
|
|
|
525
779
|
function isHerdrPrompt(command: string): boolean {
|
|
@@ -547,6 +801,23 @@ function hasEnvironmentRead(command: string, allowedNames: Set<string>): boolean
|
|
|
547
801
|
}
|
|
548
802
|
const pipelines = parsed.pipelines.map((commands) => commands.map(unwrapStaticCommand));
|
|
549
803
|
const unwrapped = pipelines.flat();
|
|
804
|
+
if (
|
|
805
|
+
command.includes(">") &&
|
|
806
|
+
unwrapped.some((argv) => isAllowedEnvironmentRead(argv, allowedNames))
|
|
807
|
+
) {
|
|
808
|
+
return true;
|
|
809
|
+
}
|
|
810
|
+
if (
|
|
811
|
+
command.includes(">") &&
|
|
812
|
+
pipelines.some((pipeline) =>
|
|
813
|
+
pipeline.some(
|
|
814
|
+
(argv) => isLiteralTextProducer(argv) && mentionsEnvironmentRead(argv.join(" ")),
|
|
815
|
+
),
|
|
816
|
+
) &&
|
|
817
|
+
unwrapped.some((argv) => ["sh", "bash", "zsh", "xargs", "eval"].includes(argv[0] ?? ""))
|
|
818
|
+
) {
|
|
819
|
+
return true;
|
|
820
|
+
}
|
|
550
821
|
if (unwrapped.some((argv) => hasStaticEnvironmentRead(argv, allowedNames))) {
|
|
551
822
|
return true;
|
|
552
823
|
}
|
|
@@ -568,22 +839,62 @@ function hasEnvironmentRead(command: string, allowedNames: Set<string>): boolean
|
|
|
568
839
|
return result;
|
|
569
840
|
}
|
|
570
841
|
|
|
842
|
+
/** Find an actual heredoc operator outside shell quotes/comments, then parse one bounded body. */
|
|
843
|
+
function boundedHeredoc(command: string) {
|
|
844
|
+
let quote: "'" | '"' | undefined;
|
|
845
|
+
let wordStarted = false;
|
|
846
|
+
for (let i = 0; i < command.length; i++) {
|
|
847
|
+
const char = command[i];
|
|
848
|
+
if (quote === "'") {
|
|
849
|
+
if (char === quote) quote = undefined;
|
|
850
|
+
} else if (char === "\\") {
|
|
851
|
+
i++;
|
|
852
|
+
wordStarted = true;
|
|
853
|
+
} else if (quote === '"') {
|
|
854
|
+
if (char === quote) quote = undefined;
|
|
855
|
+
} else if (char === "'" || char === '"') {
|
|
856
|
+
quote = char;
|
|
857
|
+
wordStarted = true;
|
|
858
|
+
} else if (char === "#" && !wordStarted) {
|
|
859
|
+
while (i + 1 < command.length && command[i + 1] !== "\n") i++;
|
|
860
|
+
} else if (command.startsWith("<<", i)) {
|
|
861
|
+
const match =
|
|
862
|
+
/^<<(-?)[ \t]*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\2[ \t]*(?:#[^\r\n]*)?(?:\r?\n|$)/.exec(
|
|
863
|
+
command.slice(i),
|
|
864
|
+
);
|
|
865
|
+
const lines = match
|
|
866
|
+
? command
|
|
867
|
+
.slice(i + match[0].length)
|
|
868
|
+
.trimEnd()
|
|
869
|
+
.split(/\r?\n/)
|
|
870
|
+
: [];
|
|
871
|
+
const end = lines.findIndex(
|
|
872
|
+
(line) => (match?.[1] ? line.replace(/^\t+/, "") : line) === match?.[3],
|
|
873
|
+
);
|
|
874
|
+
return {
|
|
875
|
+
header: command.slice(0, i),
|
|
876
|
+
quoted: Boolean(match?.[2]),
|
|
877
|
+
closed: Boolean(match) && end >= 0,
|
|
878
|
+
body: lines.slice(0, end >= 0 ? end : undefined).join("\n"),
|
|
879
|
+
following: end >= 0 ? lines.slice(end + 1).join("\n") : "",
|
|
880
|
+
};
|
|
881
|
+
} else {
|
|
882
|
+
wordStarted = !/[\s;&|()<>]/.test(char ?? "");
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
return undefined;
|
|
886
|
+
}
|
|
887
|
+
|
|
571
888
|
/** A single literal writer cannot execute its body or feed another command. */
|
|
572
889
|
function isLiteralTextWrite(command: string): boolean {
|
|
573
|
-
const
|
|
574
|
-
const heredoc = /^(.*?)<<(-?)[ \t]*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\3[ \t]*$/.exec(lines[0] ?? "");
|
|
890
|
+
const heredoc = boundedHeredoc(command);
|
|
575
891
|
let header = command;
|
|
576
892
|
let writers = ["echo", "printf"];
|
|
577
893
|
if (heredoc) {
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
// Only the first delimiter closes the body. No following commands are exceptions.
|
|
583
|
-
if (end < 0 || end !== lines.length - 1) return false;
|
|
584
|
-
const body = lines.slice(1, end).join("\n");
|
|
585
|
-
if (!quote && /[$`\\]/.test(body)) return false;
|
|
586
|
-
header = prefix ?? "";
|
|
894
|
+
// Only a complete body with no following commands is a literal-write exception.
|
|
895
|
+
if (!heredoc.closed || heredoc.following) return false;
|
|
896
|
+
if (!heredoc.quoted && /[$`\\]/.test(heredoc.body)) return false;
|
|
897
|
+
header = heredoc.header;
|
|
587
898
|
writers = ["cat", "tee"];
|
|
588
899
|
} else if (!command.includes(">")) {
|
|
589
900
|
return false;
|
|
@@ -591,14 +902,7 @@ function isLiteralTextWrite(command: string): boolean {
|
|
|
591
902
|
|
|
592
903
|
// Output redirection does not execute text. Other unsupported syntax still fails parsing.
|
|
593
904
|
const parsed = parseStaticShellCommands(header.replace(/2>&1/g, " ").replace(/>>?/g, " "));
|
|
594
|
-
if (!parsed || parsed === "brace-expansion")
|
|
595
|
-
const balancedQuotes =
|
|
596
|
-
(command.match(/'/g)?.length ?? 0) % 2 === 0 && (command.match(/"/g)?.length ?? 0) % 2 === 0;
|
|
597
|
-
return (
|
|
598
|
-
balancedQuotes &&
|
|
599
|
-
/^(?:cd\s+\S+\s+&&\s+)?(?:echo|printf)\b[\s\S]*>>?\s+\S+\s*(?:&&\s+tail\b.*)?$/i.test(command)
|
|
600
|
-
);
|
|
601
|
-
}
|
|
905
|
+
if (!parsed || parsed === "brace-expansion") return false;
|
|
602
906
|
const commands = parsed.pipelines.flat().map(unwrapStaticCommand);
|
|
603
907
|
const writerIndexes = commands
|
|
604
908
|
.map((argv, index) => (writers.includes(argv[0]?.split("/").at(-1) ?? "") ? index : -1))
|
|
@@ -613,18 +917,21 @@ function isLiteralTextWrite(command: string): boolean {
|
|
|
613
917
|
);
|
|
614
918
|
}
|
|
615
919
|
|
|
616
|
-
function hasSensitiveFileRead(command: string): boolean {
|
|
920
|
+
function hasSensitiveFileRead(command: string, isPathBlocked: (path: string) => boolean): boolean {
|
|
617
921
|
const sensitivePath =
|
|
618
922
|
/(?:^|[/\s'"])(?:[.]env(?\b)|\S*[.](?:pem|key)\b|\S*[.](?:ssh|aws)[/]|(?:\S*secret(?:-|[/\s.]|$))|(?:(?:secrets?|credentials?)(?:[/\s.]|$)|[A-Za-z0-9_.-]*(?:secrets?|credentials?)(?:[/\s.]|$)|[A-Za-z0-9_.-]*-(?:secrets?|credentials?)(?:[/\s.-]|$)))/i;
|
|
619
923
|
const sensitiveCamelCase =
|
|
620
924
|
/(?:^|[/\s'"])[A-Za-z0-9_.-]*(?:(?:secret|credential)[A-Z]|(?:Secret|Credential)[A-Z])[^/\s'"]*/;
|
|
621
925
|
const sensitiveName = /secret|credential/i;
|
|
622
926
|
const isSensitivePath = (text: string) =>
|
|
623
|
-
sensitivePath.test(text) ||
|
|
927
|
+
sensitivePath.test(text) ||
|
|
928
|
+
sensitiveCamelCase.test(text) ||
|
|
929
|
+
(sensitiveName.test(text) && !/(?:^|\/)credential-guard(?:-[^/]+)?\.(?:ts|md)$/.test(text));
|
|
624
930
|
if (/(?:^|[\s;&|])--env-file(?:=|\s)[^;&|]*\.env\b/i.test(command)) return true;
|
|
625
931
|
if (/\bcat\b[^;&|]*<<-?\s*\S+[\s\S]*\b(?:secret|credential)\b/i.test(command)) return true;
|
|
626
932
|
const parsed = parseStaticShellCommands(command);
|
|
627
|
-
const readers =
|
|
933
|
+
const readers =
|
|
934
|
+
/\b(?:cat|cp|grep|rg|sed|awk|jq|head|tail|less|more|source|ls|sort|uniq|cut|wc|mv|find)\b/i;
|
|
628
935
|
if (parsed && parsed !== "brace-expansion") {
|
|
629
936
|
return parsed.pipelines.flat().some((words) => {
|
|
630
937
|
const argv = unwrapStaticCommand(words);
|
|
@@ -633,6 +940,17 @@ function hasSensitiveFileRead(command: string): boolean {
|
|
|
633
940
|
if (inspectedArgv === null) return false;
|
|
634
941
|
const inspected = unwrapStaticCommand(inspectedArgv);
|
|
635
942
|
const name = inspected[0]?.split("/").at(-1) ?? "";
|
|
943
|
+
if (name === "bun" && inspected[1] === "run" && inspected[2] === "gh-tool") {
|
|
944
|
+
return inspected.some((arg, index) => {
|
|
945
|
+
const path =
|
|
946
|
+
arg === "--body-file"
|
|
947
|
+
? inspected[index + 1]
|
|
948
|
+
: arg.startsWith("--body-file=")
|
|
949
|
+
? arg.slice("--body-file=".length)
|
|
950
|
+
: undefined;
|
|
951
|
+
return path !== undefined && (isPathBlocked(path) || isSensitivePath(path));
|
|
952
|
+
});
|
|
953
|
+
}
|
|
636
954
|
if (!readers.test(name)) return false;
|
|
637
955
|
if (name === "rg" || name === "grep") {
|
|
638
956
|
const args = inspected.slice(1);
|
|
@@ -722,11 +1040,23 @@ function hasSensitiveFileRead(command: string): boolean {
|
|
|
722
1040
|
}
|
|
723
1041
|
paths.push(arg);
|
|
724
1042
|
}
|
|
725
|
-
return paths.some(isSensitivePath);
|
|
1043
|
+
return paths.some((path) => isPathBlocked(path) || isSensitivePath(path));
|
|
726
1044
|
}
|
|
727
|
-
|
|
1045
|
+
const paths = name === "jq" ? jqFileArguments(inspected) : inspected.slice(1);
|
|
1046
|
+
return paths.some((path) => isPathBlocked(path) || isSensitivePath(path));
|
|
728
1047
|
});
|
|
729
1048
|
}
|
|
1049
|
+
const operands = parseStaticShellCommands(command, true);
|
|
1050
|
+
if (
|
|
1051
|
+
operands &&
|
|
1052
|
+
typeof operands !== "string" &&
|
|
1053
|
+
operands.pipelines.flat().some((words) => {
|
|
1054
|
+
const argv = unwrapStaticCommand(words);
|
|
1055
|
+
return readers.test(argv[0] ?? "") && argv.slice(1).some(isPathBlocked);
|
|
1056
|
+
})
|
|
1057
|
+
)
|
|
1058
|
+
return true;
|
|
1059
|
+
if (readers.test(command) && command.split(/[\s'"`()<>;&|]+/).some(isPathBlocked)) return true;
|
|
730
1060
|
const unquoted = command.replace(/'(?:\\.|[^'])*'/g, " ").replace(/"(?:\\.|[^"$`])*"/g, " ");
|
|
731
1061
|
if (/\b(?:bun|node|python3?|ruby)\b/i.test(command) && isSensitivePath(command)) {
|
|
732
1062
|
return true;
|
|
@@ -738,9 +1068,149 @@ function hasSensitiveFileRead(command: string): boolean {
|
|
|
738
1068
|
);
|
|
739
1069
|
}
|
|
740
1070
|
|
|
741
|
-
function hasEnvironmentVariableExpansionRead(
|
|
742
|
-
|
|
743
|
-
|
|
1071
|
+
function hasEnvironmentVariableExpansionRead(
|
|
1072
|
+
command: string,
|
|
1073
|
+
allowedNames: Set<string>,
|
|
1074
|
+
isPathBlocked: (path: string) => boolean,
|
|
1075
|
+
): boolean {
|
|
1076
|
+
let heredoc = boundedHeredoc(command);
|
|
1077
|
+
while (heredoc) {
|
|
1078
|
+
// In an unquoted heredoc, # and quote characters are data, not shell syntax.
|
|
1079
|
+
if (!heredoc.closed || (!heredoc.quoted && /[$`\\]/.test(heredoc.body))) return true;
|
|
1080
|
+
command = [heredoc.header, heredoc.following].join("\n");
|
|
1081
|
+
heredoc = boundedHeredoc(command);
|
|
1082
|
+
}
|
|
1083
|
+
if (!command.includes("$")) return false;
|
|
1084
|
+
let invalidExpansion = false;
|
|
1085
|
+
const assignments = getLeadingLiteralAssignments(command);
|
|
1086
|
+
const localValues = new Map<string, string>();
|
|
1087
|
+
let localIndex = 0;
|
|
1088
|
+
let expansions = 0;
|
|
1089
|
+
let normalized = "";
|
|
1090
|
+
let quote: "'" | '"' | undefined;
|
|
1091
|
+
let wordStarted = false;
|
|
1092
|
+
for (let i = 0; i < command.length; i++) {
|
|
1093
|
+
const char = command[i] ?? "";
|
|
1094
|
+
if (!quote && char === "#" && !wordStarted) {
|
|
1095
|
+
while (i + 1 < command.length && command[i + 1] !== "\n") i++;
|
|
1096
|
+
continue;
|
|
1097
|
+
}
|
|
1098
|
+
if (!quote && /[\s;&|()<>]/.test(char)) wordStarted = false;
|
|
1099
|
+
else wordStarted = true;
|
|
1100
|
+
if (char === "\\" && quote !== "'") {
|
|
1101
|
+
normalized += char + (command[++i] ?? "");
|
|
1102
|
+
continue;
|
|
1103
|
+
}
|
|
1104
|
+
if (quote === "'") {
|
|
1105
|
+
normalized += char;
|
|
1106
|
+
if (char === "'") quote = undefined;
|
|
1107
|
+
continue;
|
|
1108
|
+
}
|
|
1109
|
+
if (char === "'" && quote !== '"') {
|
|
1110
|
+
normalized += char;
|
|
1111
|
+
quote = "'";
|
|
1112
|
+
continue;
|
|
1113
|
+
}
|
|
1114
|
+
if (char === '"') {
|
|
1115
|
+
normalized += char;
|
|
1116
|
+
quote = quote === '"' ? undefined : '"';
|
|
1117
|
+
continue;
|
|
1118
|
+
}
|
|
1119
|
+
if (char !== "$") {
|
|
1120
|
+
normalized += char;
|
|
1121
|
+
continue;
|
|
1122
|
+
}
|
|
1123
|
+
const variable = /^\$\{([A-Za-z_][A-Za-z0-9_]*)\}|^\$([A-Za-z_][A-Za-z0-9_]*)/.exec(
|
|
1124
|
+
command.slice(i),
|
|
1125
|
+
);
|
|
1126
|
+
if (!variable) {
|
|
1127
|
+
if (command[i + 1] === "{" || /[$][0-9@*#?!]/.test(command.slice(i, i + 2))) {
|
|
1128
|
+
invalidExpansion = true;
|
|
1129
|
+
}
|
|
1130
|
+
normalized += char;
|
|
1131
|
+
continue;
|
|
1132
|
+
}
|
|
1133
|
+
expansions++;
|
|
1134
|
+
const name = variable[1] ?? variable[2] ?? "";
|
|
1135
|
+
const assignment = assignments.get(name);
|
|
1136
|
+
if (assignment && assignment.end >= i) invalidExpansion = true;
|
|
1137
|
+
if (assignment && assignment.end < i) {
|
|
1138
|
+
const marker = `LOCALVALUE${localIndex++}`;
|
|
1139
|
+
localValues.set(marker, assignment.value);
|
|
1140
|
+
normalized += marker;
|
|
1141
|
+
} else if (allowedNames.has(name)) {
|
|
1142
|
+
normalized += quote ? "ENVVALUE" : "ENVVALUEUNQUOTED";
|
|
1143
|
+
} else {
|
|
1144
|
+
normalized += quote ? "UNKNOWNVALUE" : "UNKNOWNVALUEUNQUOTED";
|
|
1145
|
+
}
|
|
1146
|
+
i += variable[0].length - 1;
|
|
1147
|
+
}
|
|
1148
|
+
if (!expansions) return invalidExpansion;
|
|
1149
|
+
if (invalidExpansion) return true;
|
|
1150
|
+
|
|
1151
|
+
const localMaterialized = [...localValues].reduce(
|
|
1152
|
+
(text, [marker, value]) => text.replaceAll(marker, value),
|
|
1153
|
+
normalized,
|
|
1154
|
+
);
|
|
1155
|
+
// Options must be checked after local literals become actual argv, never as placeholders.
|
|
1156
|
+
if (!hasSafeLocalAssignmentCommands(localMaterialized, assignments)) return true;
|
|
1157
|
+
const parsed = parseStaticShellCommands(localMaterialized);
|
|
1158
|
+
if (!parsed || typeof parsed === "string") return true;
|
|
1159
|
+
for (const words of parsed.pipelines.flat()) {
|
|
1160
|
+
if (!words.some((arg) => arg.includes("UNKNOWNVALUE"))) continue;
|
|
1161
|
+
if (words.some((arg) => arg.includes("UNKNOWNVALUEUNQUOTED"))) return true;
|
|
1162
|
+
const argv = unwrapStaticCommand(words);
|
|
1163
|
+
if (
|
|
1164
|
+
words.slice(0, words.length - argv.length).some((arg) => arg.includes("UNKNOWNVALUE")) ||
|
|
1165
|
+
!isNavigationOperand(argv, "UNKNOWNVALUE")
|
|
1166
|
+
)
|
|
1167
|
+
return true;
|
|
1168
|
+
}
|
|
1169
|
+
if (!localMaterialized.includes("ENVVALUE")) {
|
|
1170
|
+
return (
|
|
1171
|
+
hasSensitiveFileRead(localMaterialized, isPathBlocked) ||
|
|
1172
|
+
hasSensitivePathRedirect(localMaterialized, isPathBlocked) ||
|
|
1173
|
+
hasEnvironmentRead(localMaterialized, allowedNames)
|
|
1174
|
+
);
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
for (const [index, pipeline] of parsed.pipelines.entries()) {
|
|
1178
|
+
const hasEnvironmentValue = pipeline.some((argv) =>
|
|
1179
|
+
argv.some((arg) => arg.includes("ENVVALUE")),
|
|
1180
|
+
);
|
|
1181
|
+
if (!hasEnvironmentValue) continue;
|
|
1182
|
+
if (parsed.redirects.some((redirect) => redirect.pipeline === index)) return true;
|
|
1183
|
+
if (
|
|
1184
|
+
pipeline.some((argv) => {
|
|
1185
|
+
const unwrapped = unwrapStaticCommand(argv);
|
|
1186
|
+
const name = unwrapped[0]?.split("/").at(-1) ?? "";
|
|
1187
|
+
if (argv.some((arg) => arg.includes("ENVVALUE"))) {
|
|
1188
|
+
if (argv.slice(0, argv.length - unwrapped.length).some((arg) => arg.includes("ENVVALUE")))
|
|
1189
|
+
return true;
|
|
1190
|
+
if (isNavigationOperand(unwrapped, "ENVVALUE")) {
|
|
1191
|
+
return unwrapped.some((arg) => arg.includes("ENVVALUEUNQUOTED"));
|
|
1192
|
+
}
|
|
1193
|
+
const formatIndex = name === "printf" ? passivePrintfFormatIndex(unwrapped) : undefined;
|
|
1194
|
+
return (
|
|
1195
|
+
!["echo", "printf"].includes(name) ||
|
|
1196
|
+
!isPassiveTextCommand(unwrapped) ||
|
|
1197
|
+
unwrapped[0]?.includes("ENVVALUE") === true ||
|
|
1198
|
+
(name === "printf" &&
|
|
1199
|
+
(formatIndex === undefined ||
|
|
1200
|
+
unwrapped.slice(0, formatIndex + 1).some((arg) => arg.includes("ENVVALUE"))))
|
|
1201
|
+
);
|
|
1202
|
+
}
|
|
1203
|
+
return !isPassiveTextCommand(unwrapped);
|
|
1204
|
+
})
|
|
1205
|
+
) {
|
|
1206
|
+
return true;
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
const materialized = [...localValues].reduce(
|
|
1210
|
+
(text, [marker, value]) => text.replaceAll(marker, value),
|
|
1211
|
+
normalized.replaceAll("ENVVALUE", "SAFEENVVALUE"),
|
|
1212
|
+
);
|
|
1213
|
+
return hasSensitiveFileRead(materialized, isPathBlocked);
|
|
744
1214
|
}
|
|
745
1215
|
|
|
746
1216
|
function hasEnvironmentSourceAccess(command: string): boolean {
|
|
@@ -849,37 +1319,44 @@ export function createCredentialGuard(config?: CredentialGuardConfig): Credentia
|
|
|
849
1319
|
}
|
|
850
1320
|
|
|
851
1321
|
function isDangerousBashCommand(command: string): boolean {
|
|
852
|
-
return (
|
|
853
|
-
dangerousBashPatterns.some((pattern) => pattern.test(command)) ||
|
|
854
|
-
hasSensitiveFileRead(command) ||
|
|
855
|
-
hasEnvironmentVariableExpansionRead(command) ||
|
|
856
|
-
hasEnvironmentSourceAccess(command) ||
|
|
857
|
-
hasEnvironmentRead(command, allowedEnvironmentVariables)
|
|
858
|
-
);
|
|
1322
|
+
return getDangerousBashReason(command) !== null;
|
|
859
1323
|
}
|
|
860
1324
|
|
|
861
|
-
function
|
|
862
|
-
if (
|
|
863
|
-
|
|
1325
|
+
function getDangerousBashReason(command: string): string | null {
|
|
1326
|
+
if (
|
|
1327
|
+
hasSensitiveFileRead(command, isPathBlocked) ||
|
|
1328
|
+
hasSensitivePathRedirect(command, isPathBlocked)
|
|
1329
|
+
) {
|
|
1330
|
+
return "accesses a sensitive file path";
|
|
864
1331
|
}
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
1332
|
+
if (
|
|
1333
|
+
!isLiteralTextWrite(command) &&
|
|
1334
|
+
hasEnvironmentVariableExpansionRead(command, allowedEnvironmentVariables, isPathBlocked)
|
|
1335
|
+
) {
|
|
1336
|
+
return "expands an unapproved or executable environment variable value";
|
|
869
1337
|
}
|
|
1338
|
+
if (hasEnvironmentSourceAccess(command)) return "reads process environment from a script";
|
|
1339
|
+
if (hasEnvironmentRead(command, allowedEnvironmentVariables)) {
|
|
1340
|
+
return "reads environment variables or passes them to an executor";
|
|
1341
|
+
}
|
|
1342
|
+
if (dangerousBashPatterns.some((pattern) => pattern.test(command))) {
|
|
1343
|
+
return "matches a configured dangerous-command pattern";
|
|
1344
|
+
}
|
|
1345
|
+
return null;
|
|
1346
|
+
}
|
|
870
1347
|
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
return
|
|
874
|
-
|
|
875
|
-
|
|
1348
|
+
function isGhCommandAllowed(command: string): boolean {
|
|
1349
|
+
const parsed = parseStaticShellCommands(command);
|
|
1350
|
+
if (!parsed || typeof parsed === "string") return false;
|
|
1351
|
+
const ghCommands = parsed.pipelines
|
|
1352
|
+
.flat()
|
|
1353
|
+
.map(unwrapStaticCommand)
|
|
1354
|
+
.filter((argv) => argv[0]?.split("/").at(-1) === "gh");
|
|
1355
|
+
return ghCommands.length > 0 && ghCommands.every(isAllowedGhArgv);
|
|
876
1356
|
}
|
|
877
1357
|
|
|
878
1358
|
function allGhCommandsAllowed(command: string): boolean {
|
|
879
|
-
|
|
880
|
-
const ghSegments = segments.filter((s) => /\bgh\s/.test(s));
|
|
881
|
-
if (ghSegments.length === 0) return false;
|
|
882
|
-
return ghSegments.every((segment) => isGhCommandAllowed(segment.trim()));
|
|
1359
|
+
return isGhCommandAllowed(command);
|
|
883
1360
|
}
|
|
884
1361
|
|
|
885
1362
|
function detectSleepPolling(command: string): string | null {
|
|
@@ -894,6 +1371,27 @@ export function createCredentialGuard(config?: CredentialGuardConfig): Credentia
|
|
|
894
1371
|
}
|
|
895
1372
|
|
|
896
1373
|
function getBlockedCliTool(command: string): { name: string; wrapper: string } | null {
|
|
1374
|
+
const staticCommands = parseStaticShellCommands(command);
|
|
1375
|
+
const parsed = staticCommands ?? parseStaticShellCommands(command, true);
|
|
1376
|
+
if (parsed && typeof parsed !== "string") {
|
|
1377
|
+
const commands = parsed.pipelines.flat().map(unwrapStaticCommand);
|
|
1378
|
+
for (const argv of commands) {
|
|
1379
|
+
const executable = argv[0]?.split("/").at(-1) ?? "";
|
|
1380
|
+
const match = blockedCliTools.find(({ name }) =>
|
|
1381
|
+
name === "curl (Azure DevOps)"
|
|
1382
|
+
? executable === "curl" && argv.slice(1).join(" ").includes("dev.azure.com")
|
|
1383
|
+
: name === "az" || name === "az (Azure DevOps)"
|
|
1384
|
+
? executable === "az" &&
|
|
1385
|
+
(name !== "az (Azure DevOps)" ||
|
|
1386
|
+
/^(?:devops|pipelines|repos|boards|artifacts)$/.test(argv[1] ?? ""))
|
|
1387
|
+
: executable === name,
|
|
1388
|
+
);
|
|
1389
|
+
if (!match) continue;
|
|
1390
|
+
if (match.name === "gh" && allGhCommandsAllowed(command)) continue;
|
|
1391
|
+
return { name: match.name, wrapper: match.wrapper };
|
|
1392
|
+
}
|
|
1393
|
+
if (staticCommands) return null;
|
|
1394
|
+
}
|
|
897
1395
|
for (const { pattern, name, wrapper } of blockedCliTools) {
|
|
898
1396
|
if (pattern.test(command)) {
|
|
899
1397
|
if (name === "gh" && allGhCommandsAllowed(command)) {
|
|
@@ -946,9 +1444,10 @@ export function createCredentialGuard(config?: CredentialGuardConfig): Credentia
|
|
|
946
1444
|
if (tool === "bash") {
|
|
947
1445
|
const command = extractCommand(args);
|
|
948
1446
|
|
|
949
|
-
|
|
1447
|
+
const dangerousReason = getDangerousBashReason(command);
|
|
1448
|
+
if (dangerousReason) {
|
|
950
1449
|
throw new Error(
|
|
951
|
-
`\u{1F6AB} Command blocked:
|
|
1450
|
+
`\u{1F6AB} Command blocked: ${dangerousReason}; this command might expose secrets.\n\n` +
|
|
952
1451
|
`Command: ${command}\n\n` +
|
|
953
1452
|
`If you need environment info, ask the user directly.\n\n` +
|
|
954
1453
|
`Think this is wrong? Review the project repository, adjust the patterns, and submit a PR.`,
|