@blogic-cz/agent-tools 1.5.4 → 1.5.5
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 +570 -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;AA6nCF,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,UA35CD,SAAS,UAAU,UAAU,KAAK,IA25CM,CAAC;AAE5E,oDAAoD;AACpD,eAAO,MAAM,aAAa,YA75CC,MAAM,KAAK;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAAG,IA65CjB,CAAC;AAExD,yEAAyE;AACzE,eAAO,MAAM,aAAa,aA/5CE,MAAM,KAAK,OA+5CgB,CAAC;AAExD,yDAAyD;AACzD,eAAO,MAAM,aAAa,aAj6CE,MAAM,KAAK,OAi6CgB,CAAC;AAExD,oEAAoE;AACpE,eAAO,MAAM,sBAAsB,YAn6CC,MAAM,KAAK,OAm6C0B,CAAC;AAE1E,iDAAiD;AACjD,eAAO,MAAM,iBAAiB,YAr6CC,MAAM,KAAK;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GAAG,IAq6Cf,CAAC;AAEhE,wDAAwD;AACxD,eAAO,MAAM,kBAAkB,YAv6CC,MAAM,KAAK,OAu6CsB,CAAC;AAElE,8EAA8E;AAC9E,eAAO,MAAM,kBAAkB,YAz6CC,MAAM,KAAK,MAAM,GAAG,IAy6Ca,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,103 @@ 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 hasSafeLocalAssignmentCommands(
|
|
253
|
+
command: string,
|
|
254
|
+
assignments: Map<string, { value: string; end: number }>,
|
|
255
|
+
): boolean {
|
|
256
|
+
if (!assignments.size) return true;
|
|
257
|
+
const parsed = parseStaticShellCommands(command);
|
|
258
|
+
if (!parsed || typeof parsed === "string") return false;
|
|
259
|
+
return parsed.pipelines.flat().every((words) => {
|
|
260
|
+
const assignment = /^([A-Za-z_][A-Za-z0-9_]*)=([A-Za-z0-9_./-]+)$/.exec(words[0] ?? "");
|
|
261
|
+
if (assignment) {
|
|
262
|
+
return words.length === 1 && assignments.get(assignment[1] ?? "")?.value === assignment[2];
|
|
263
|
+
}
|
|
264
|
+
const argv = unwrapStaticCommand(words);
|
|
265
|
+
const name = argv[0]?.split("/").at(-1) ?? "";
|
|
266
|
+
if (name === "cd") return isNavigationOperand(argv, "LOCALVALUE");
|
|
267
|
+
if (name === "git") {
|
|
268
|
+
let index = 1;
|
|
269
|
+
while (argv[index] === "-C" && argv[index + 1]) index += 2;
|
|
270
|
+
return (
|
|
271
|
+
["branch", "diff", "log", "rev-parse", "rev-list", "show", "status"].includes(
|
|
272
|
+
argv[index] ?? "",
|
|
273
|
+
) && !hasExecutionOption(argv.slice(index + 1), ["exec", "ext-diff", "textconv"])
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
if (name === "find") {
|
|
277
|
+
return !argv.slice(1).some((arg) => /^-(?:exec|ok|delete|fprint|fprintf)/.test(arg));
|
|
278
|
+
}
|
|
279
|
+
if (name === "bun") return argv[1] === "run" && ["db-tool", "gh-tool"].includes(argv[2] ?? "");
|
|
280
|
+
return (
|
|
281
|
+
isPassiveTextCommand(argv) ||
|
|
282
|
+
["ls", "cat", "tail", "wc", "uniq", "cut", "mv"].includes(name) ||
|
|
283
|
+
(name === "sort" && !hasExecutionOption(argv.slice(1), ["compress-program"])) ||
|
|
284
|
+
(name === "sed" &&
|
|
285
|
+
argv
|
|
286
|
+
.slice(1)
|
|
287
|
+
.every(
|
|
288
|
+
(arg) =>
|
|
289
|
+
/^-[En]+$/.test(arg) ||
|
|
290
|
+
/^s([/|]).*\1[^/|]*\1[gp]*$/.test(arg) ||
|
|
291
|
+
/^[A-Za-z0-9_./-]+$/.test(arg),
|
|
292
|
+
) &&
|
|
293
|
+
!argv.includes("-f"))
|
|
294
|
+
);
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function hasSensitivePathRedirect(
|
|
299
|
+
command: string,
|
|
300
|
+
isPathBlocked: (path: string) => boolean,
|
|
301
|
+
): boolean {
|
|
302
|
+
let heredoc = boundedHeredoc(command);
|
|
303
|
+
while (heredoc) {
|
|
304
|
+
if (!heredoc.closed) return true;
|
|
305
|
+
command = [heredoc.header, heredoc.following].join("\n");
|
|
306
|
+
heredoc = boundedHeredoc(command);
|
|
307
|
+
}
|
|
308
|
+
const parsed = parseStaticShellCommands(command, true);
|
|
309
|
+
if (!parsed || typeof parsed === "string") return false;
|
|
310
|
+
const assignments = getLeadingLiteralAssignments(command);
|
|
311
|
+
return parsed.redirects.some(({ target, dynamic }) => {
|
|
312
|
+
if (!dynamic) return isPathBlocked(target);
|
|
313
|
+
const materialized = target.replace(
|
|
314
|
+
/\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g,
|
|
315
|
+
(match, braced: string, plain: string) => assignments.get(braced ?? plain)?.value ?? match,
|
|
316
|
+
);
|
|
317
|
+
return /[$`]/.test(materialized) || isPathBlocked(materialized);
|
|
318
|
+
});
|
|
319
|
+
}
|
|
224
320
|
|
|
225
321
|
// ============================================================================
|
|
226
322
|
// HELPERS
|
|
@@ -230,11 +326,21 @@ function escapeRegex(s: string): string {
|
|
|
230
326
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
231
327
|
}
|
|
232
328
|
|
|
233
|
-
/** Static shell words only. Never expand variables or execute a command.
|
|
329
|
+
/** Static shell words only. Never expand variables or execute a command.
|
|
330
|
+
* literalOperandsOnly retains recognizable paths/commands on unsupported expansions;
|
|
331
|
+
* callers may use it to deny, never to establish a safe exception.
|
|
332
|
+
*/
|
|
234
333
|
function parseStaticShellCommands(
|
|
235
334
|
command: string,
|
|
236
|
-
|
|
335
|
+
literalOperandsOnly = false,
|
|
336
|
+
):
|
|
337
|
+
| { pipelines: string[][][]; redirects: { target: string; dynamic: boolean; pipeline: number }[] }
|
|
338
|
+
| "brace-expansion"
|
|
339
|
+
| undefined {
|
|
237
340
|
const pipelines: string[][][] = [];
|
|
341
|
+
const redirects: { target: string; dynamic: boolean; pipeline: number }[] = [];
|
|
342
|
+
let redirect = false;
|
|
343
|
+
let dynamic = false;
|
|
238
344
|
let commands: string[][] = [];
|
|
239
345
|
let argv: string[] = [];
|
|
240
346
|
let word = "";
|
|
@@ -242,9 +348,14 @@ function parseStaticShellCommands(
|
|
|
242
348
|
let quote: "'" | '"' | undefined;
|
|
243
349
|
|
|
244
350
|
const finishWord = () => {
|
|
245
|
-
if (started)
|
|
351
|
+
if (started) {
|
|
352
|
+
if (redirect) redirects.push({ target: word, dynamic, pipeline: pipelines.length });
|
|
353
|
+
else argv.push(word);
|
|
354
|
+
redirect = false;
|
|
355
|
+
}
|
|
246
356
|
word = "";
|
|
247
357
|
started = false;
|
|
358
|
+
dynamic = false;
|
|
248
359
|
};
|
|
249
360
|
const finishCommand = () => {
|
|
250
361
|
finishWord();
|
|
@@ -269,17 +380,46 @@ function parseStaticShellCommands(
|
|
|
269
380
|
word += next;
|
|
270
381
|
started = true;
|
|
271
382
|
} else if (char === "$" || char === "`") {
|
|
272
|
-
return undefined;
|
|
383
|
+
if (!literalOperandsOnly) return undefined;
|
|
384
|
+
dynamic = true;
|
|
385
|
+
word += char;
|
|
386
|
+
started = true;
|
|
273
387
|
} else if (quote === '"') {
|
|
274
388
|
if (char === quote) quote = undefined;
|
|
275
389
|
else word += char;
|
|
276
390
|
} else if (char === "'" || char === '"') {
|
|
277
391
|
quote = char;
|
|
278
392
|
started = true;
|
|
279
|
-
} else if (
|
|
393
|
+
} else if (
|
|
394
|
+
!literalOperandsOnly &&
|
|
395
|
+
char === "{" &&
|
|
396
|
+
/^\{[^{}]*(?:,|\.\.)[^{}]*\}/.test(command.slice(i))
|
|
397
|
+
) {
|
|
280
398
|
return "brace-expansion";
|
|
281
|
-
} else if (
|
|
282
|
-
|
|
399
|
+
} else if (char === ">" || char === "<") {
|
|
400
|
+
if (char === "<" && command[i + 1] === "<") {
|
|
401
|
+
if (literalOperandsOnly) break;
|
|
402
|
+
return undefined;
|
|
403
|
+
}
|
|
404
|
+
if (/^\d+$/.test(word)) {
|
|
405
|
+
word = "";
|
|
406
|
+
started = false;
|
|
407
|
+
} else {
|
|
408
|
+
finishWord();
|
|
409
|
+
}
|
|
410
|
+
if (redirect) return undefined;
|
|
411
|
+
if (command[i + 1] === char) i++;
|
|
412
|
+
if (command[i + 1] === "&" && /[0-9-]/.test(command[i + 2] ?? "")) {
|
|
413
|
+
i += 2;
|
|
414
|
+
while (/\d/.test(command[i + 1] ?? "")) i++;
|
|
415
|
+
} else {
|
|
416
|
+
redirect = true;
|
|
417
|
+
}
|
|
418
|
+
} else if (char === "#" && !started) {
|
|
419
|
+
while (i + 1 < command.length && command[i + 1] !== "\n") i++;
|
|
420
|
+
} else if (/[()]/.test(char)) {
|
|
421
|
+
if (!literalOperandsOnly) return undefined;
|
|
422
|
+
finishWord();
|
|
283
423
|
} else if (char === "|" && command[i - 1] !== "|" && command[i + 1] !== "|") {
|
|
284
424
|
finishCommand();
|
|
285
425
|
if (command[i + 1] === "&") i++;
|
|
@@ -293,9 +433,10 @@ function parseStaticShellCommands(
|
|
|
293
433
|
}
|
|
294
434
|
}
|
|
295
435
|
|
|
296
|
-
if (quote) return undefined;
|
|
436
|
+
if (quote && !literalOperandsOnly) return undefined;
|
|
297
437
|
finishPipeline();
|
|
298
|
-
|
|
438
|
+
if (redirect) return undefined;
|
|
439
|
+
return { pipelines, redirects };
|
|
299
440
|
}
|
|
300
441
|
|
|
301
442
|
function mentionsEnvironmentRead(text: string): boolean {
|
|
@@ -383,20 +524,59 @@ function isLiteralTextProducer(argv: string[]): boolean {
|
|
|
383
524
|
|
|
384
525
|
function isAwkExecution(argv: string[]): boolean {
|
|
385
526
|
if (argv[0]?.split("/").at(-1) !== "awk") return false;
|
|
386
|
-
|
|
387
|
-
|
|
527
|
+
// Only a literal inline program establishes passive behavior. File/unknown options do not.
|
|
528
|
+
const program = argv[1];
|
|
529
|
+
return (
|
|
530
|
+
!program ||
|
|
531
|
+
program.startsWith("-") ||
|
|
532
|
+
/\b(?:system|getline|ENVIRON)\b|\||\b(?:print|printf)\b[^\n]*>|@(?:include|load)/.test(
|
|
533
|
+
program,
|
|
534
|
+
) ||
|
|
535
|
+
argv.slice(2).some((arg) => arg.startsWith("-"))
|
|
536
|
+
);
|
|
388
537
|
}
|
|
389
538
|
|
|
390
539
|
function isJqObjectConstruction(argv: string[]): boolean {
|
|
540
|
+
if (argv[0]?.split("/").at(-1) !== "jq") return false;
|
|
541
|
+
let index = 1;
|
|
542
|
+
// Options with values, program files, and unknown options cannot prove an inline filter.
|
|
543
|
+
while (/^-[rRcMsScn]+$/.test(argv[index] ?? "")) index++;
|
|
544
|
+
if (argv[index] === "--") index++;
|
|
545
|
+
const filter = argv[index] ?? "";
|
|
546
|
+
if (argv.slice(index + 1).some((arg) => arg.startsWith("-"))) return false;
|
|
391
547
|
return (
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
!argv.slice(1).some((arg) => /\benv\b|\binput(?:s)?\b/.test(arg))
|
|
548
|
+
/^\{\s*[A-Za-z_][A-Za-z0-9_-]*(?:\s*,\s*[A-Za-z_][A-Za-z0-9_-]*)*\s*\}$/.test(filter) ||
|
|
549
|
+
/^\{\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(
|
|
550
|
+
filter,
|
|
551
|
+
)
|
|
397
552
|
);
|
|
398
553
|
}
|
|
399
554
|
|
|
555
|
+
function jqFileArguments(argv: string[]): string[] {
|
|
556
|
+
const files: string[] = [];
|
|
557
|
+
let index = 1;
|
|
558
|
+
for (; index < argv.length; index++) {
|
|
559
|
+
const arg = argv[index] ?? "";
|
|
560
|
+
if (arg === "--") {
|
|
561
|
+
index++;
|
|
562
|
+
break;
|
|
563
|
+
}
|
|
564
|
+
if (/^-[rRcMsScn]+$/.test(arg)) continue;
|
|
565
|
+
if (arg === "--arg" || arg === "--argjson") {
|
|
566
|
+
index += 2;
|
|
567
|
+
continue;
|
|
568
|
+
}
|
|
569
|
+
if (arg === "--rawfile" || arg === "--slurpfile") {
|
|
570
|
+
files.push(argv[index + 2] ?? "");
|
|
571
|
+
index += 2;
|
|
572
|
+
continue;
|
|
573
|
+
}
|
|
574
|
+
if (arg.startsWith("-")) return argv.slice(1);
|
|
575
|
+
break;
|
|
576
|
+
}
|
|
577
|
+
return [...files, ...argv.slice(index + 1)];
|
|
578
|
+
}
|
|
579
|
+
|
|
400
580
|
/** Index in `args` where the command wrapped by `env` starts, or null when `env` only lists. */
|
|
401
581
|
function environmentCommandStart(args: string[]): number | null {
|
|
402
582
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -408,7 +588,8 @@ function environmentCommandStart(args: string[]): number | null {
|
|
|
408
588
|
continue;
|
|
409
589
|
}
|
|
410
590
|
if (arg === "-i" || arg === "--ignore-environment" || arg.startsWith("--chdir=")) continue;
|
|
411
|
-
if (arg.startsWith("
|
|
591
|
+
if (arg.startsWith("--unset=")) continue;
|
|
592
|
+
if (arg.startsWith("-")) return null;
|
|
412
593
|
return i;
|
|
413
594
|
}
|
|
414
595
|
return null;
|
|
@@ -444,13 +625,25 @@ function isSafeLiteralConsumer(argv: string[]): boolean {
|
|
|
444
625
|
}
|
|
445
626
|
|
|
446
627
|
function unwrapStaticCommand(words: string[]): string[] {
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
628
|
+
let argv = [...words];
|
|
629
|
+
while (argv.length) {
|
|
630
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(argv[0] ?? "")) {
|
|
631
|
+
argv.shift();
|
|
632
|
+
continue;
|
|
633
|
+
}
|
|
634
|
+
const executable = argv[0]?.split("/").at(-1);
|
|
635
|
+
if (executable === "env") {
|
|
636
|
+
const wrapped = unwrapEnvironmentCommand(argv);
|
|
637
|
+
if (!wrapped) break;
|
|
638
|
+
argv = wrapped;
|
|
639
|
+
} else if (executable === "rtk" || executable === "command") {
|
|
640
|
+
argv.shift();
|
|
641
|
+
if (executable === "rtk") {
|
|
642
|
+
if (argv[0] === "proxy") argv.shift();
|
|
643
|
+
else if (argv[0] === "read") argv[0] = "cat";
|
|
644
|
+
}
|
|
645
|
+
if (executable === "command" && argv[0] === "--") argv.shift();
|
|
646
|
+
} else break;
|
|
454
647
|
}
|
|
455
648
|
return argv;
|
|
456
649
|
}
|
|
@@ -473,25 +666,62 @@ function hasExecutionOption(args: string[], longOptions: string[], shortOption?:
|
|
|
473
666
|
});
|
|
474
667
|
}
|
|
475
668
|
|
|
669
|
+
function passivePrintfFormatIndex(argv: string[]): number | undefined {
|
|
670
|
+
const index = argv[1] === "--" ? 2 : 1;
|
|
671
|
+
const format = argv[index];
|
|
672
|
+
if (format === undefined || (index === 1 && format.startsWith("-"))) return undefined;
|
|
673
|
+
// Ignore escaped percent signs; %n writes a shell variable named by a data argument.
|
|
674
|
+
return /%[-+ #0]*(?:\d+|\*)?(?:\.(?:\d+|\*))?[hljztL]*n/.test(format.replaceAll("%%", ""))
|
|
675
|
+
? undefined
|
|
676
|
+
: index;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/** Unknown values are permitted only in operands that navigate without displaying the value. */
|
|
680
|
+
function isNavigationOperand(argv: string[], marker: string): boolean {
|
|
681
|
+
const name = argv[0]?.split("/").at(-1);
|
|
682
|
+
if (name === "cd") {
|
|
683
|
+
const index = argv[1] === "--" ? 2 : 1;
|
|
684
|
+
return argv.length === index + 1 && !argv[0]?.includes(marker);
|
|
685
|
+
}
|
|
686
|
+
if (name !== "git") return false;
|
|
687
|
+
let index = 1;
|
|
688
|
+
while (argv[index] === "-C" && argv[index + 1]) index += 2;
|
|
689
|
+
return (
|
|
690
|
+
["status", "rev-parse", "rev-list"].includes(argv[index] ?? "") &&
|
|
691
|
+
!argv.slice(index).some((arg) => arg.includes(marker)) &&
|
|
692
|
+
!argv[0]?.includes(marker)
|
|
693
|
+
);
|
|
694
|
+
}
|
|
695
|
+
|
|
476
696
|
function isPassiveTextCommand(argv: string[]): boolean {
|
|
477
697
|
const name = argv[0]?.split("/").at(-1) ?? "";
|
|
478
698
|
const args = argv.slice(1);
|
|
479
699
|
// These commands consume literal text. Execution options are not exceptions.
|
|
480
|
-
if (
|
|
700
|
+
if (name === "printf") return passivePrintfFormatIndex(argv) !== undefined;
|
|
701
|
+
if (["echo", "grep", "head"].includes(name)) {
|
|
481
702
|
return true;
|
|
482
703
|
}
|
|
483
704
|
if (name === "rg") return !hasExecutionOption(args, ["pre", "hostname-bin"]);
|
|
484
705
|
if (name === "awk") return !isAwkExecution(argv);
|
|
485
706
|
if (name === "jq") return isJqObjectConstruction(argv);
|
|
486
707
|
return (
|
|
487
|
-
name === "git" &&
|
|
488
|
-
|
|
489
|
-
|
|
708
|
+
(name === "git" && args[0] === "status") ||
|
|
709
|
+
(name === "git" &&
|
|
710
|
+
args[0] === "grep" &&
|
|
711
|
+
!hasExecutionOption(args, ["open-files-in-pager", "textconv"], "O"))
|
|
490
712
|
);
|
|
491
713
|
}
|
|
492
714
|
|
|
493
715
|
function hasStaticEnvironmentRead(argv: string[], allowedNames: Set<string>): boolean {
|
|
494
716
|
const name = argv[0]?.split("/").at(-1);
|
|
717
|
+
if (
|
|
718
|
+
["sh", "bash", "zsh"].includes(name ?? "") &&
|
|
719
|
+
hasExecutionOption(argv.slice(1), ["command"], "c") &&
|
|
720
|
+
argv.slice(1).some((arg) => /\$\{?[A-Za-z_]/.test(arg))
|
|
721
|
+
) {
|
|
722
|
+
return true;
|
|
723
|
+
}
|
|
724
|
+
if (name === "awk" && argv.slice(1).some((arg) => /\bENVIRON\b/.test(arg))) return true;
|
|
495
725
|
if (name === "printenv") return !isAllowedEnvironmentRead(argv, allowedNames);
|
|
496
726
|
if (name === "env") {
|
|
497
727
|
const wrapped = unwrapEnvironmentCommand(argv);
|
|
@@ -505,21 +735,20 @@ function hasStaticEnvironmentRead(argv: string[], allowedNames: Set<string>): bo
|
|
|
505
735
|
}
|
|
506
736
|
|
|
507
737
|
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
738
|
const parsed = parseStaticShellCommands(command);
|
|
513
|
-
if (
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
739
|
+
if (!parsed || typeof parsed === "string") return false;
|
|
740
|
+
const commands = parsed.pipelines.flat().map(unwrapStaticCommand);
|
|
741
|
+
const prompts = commands.filter(
|
|
742
|
+
(argv) => argv[0] === "herdr" && argv[1] === "agent" && argv[2] === "prompt",
|
|
743
|
+
);
|
|
744
|
+
return (
|
|
745
|
+
prompts.length > 0 &&
|
|
746
|
+
commands.every((argv) =>
|
|
747
|
+
argv[0] === "herdr" && argv[1] === "agent" && argv[2] === "prompt"
|
|
748
|
+
? argv.length >= 5
|
|
749
|
+
: isPassiveTextCommand(argv),
|
|
750
|
+
)
|
|
751
|
+
);
|
|
523
752
|
}
|
|
524
753
|
|
|
525
754
|
function isHerdrPrompt(command: string): boolean {
|
|
@@ -547,6 +776,23 @@ function hasEnvironmentRead(command: string, allowedNames: Set<string>): boolean
|
|
|
547
776
|
}
|
|
548
777
|
const pipelines = parsed.pipelines.map((commands) => commands.map(unwrapStaticCommand));
|
|
549
778
|
const unwrapped = pipelines.flat();
|
|
779
|
+
if (
|
|
780
|
+
command.includes(">") &&
|
|
781
|
+
unwrapped.some((argv) => isAllowedEnvironmentRead(argv, allowedNames))
|
|
782
|
+
) {
|
|
783
|
+
return true;
|
|
784
|
+
}
|
|
785
|
+
if (
|
|
786
|
+
command.includes(">") &&
|
|
787
|
+
pipelines.some((pipeline) =>
|
|
788
|
+
pipeline.some(
|
|
789
|
+
(argv) => isLiteralTextProducer(argv) && mentionsEnvironmentRead(argv.join(" ")),
|
|
790
|
+
),
|
|
791
|
+
) &&
|
|
792
|
+
unwrapped.some((argv) => ["sh", "bash", "zsh", "xargs", "eval"].includes(argv[0] ?? ""))
|
|
793
|
+
) {
|
|
794
|
+
return true;
|
|
795
|
+
}
|
|
550
796
|
if (unwrapped.some((argv) => hasStaticEnvironmentRead(argv, allowedNames))) {
|
|
551
797
|
return true;
|
|
552
798
|
}
|
|
@@ -568,22 +814,62 @@ function hasEnvironmentRead(command: string, allowedNames: Set<string>): boolean
|
|
|
568
814
|
return result;
|
|
569
815
|
}
|
|
570
816
|
|
|
817
|
+
/** Find an actual heredoc operator outside shell quotes/comments, then parse one bounded body. */
|
|
818
|
+
function boundedHeredoc(command: string) {
|
|
819
|
+
let quote: "'" | '"' | undefined;
|
|
820
|
+
let wordStarted = false;
|
|
821
|
+
for (let i = 0; i < command.length; i++) {
|
|
822
|
+
const char = command[i];
|
|
823
|
+
if (quote === "'") {
|
|
824
|
+
if (char === quote) quote = undefined;
|
|
825
|
+
} else if (char === "\\") {
|
|
826
|
+
i++;
|
|
827
|
+
wordStarted = true;
|
|
828
|
+
} else if (quote === '"') {
|
|
829
|
+
if (char === quote) quote = undefined;
|
|
830
|
+
} else if (char === "'" || char === '"') {
|
|
831
|
+
quote = char;
|
|
832
|
+
wordStarted = true;
|
|
833
|
+
} else if (char === "#" && !wordStarted) {
|
|
834
|
+
while (i + 1 < command.length && command[i + 1] !== "\n") i++;
|
|
835
|
+
} else if (command.startsWith("<<", i)) {
|
|
836
|
+
const match =
|
|
837
|
+
/^<<(-?)[ \t]*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\2[ \t]*(?:#[^\r\n]*)?(?:\r?\n|$)/.exec(
|
|
838
|
+
command.slice(i),
|
|
839
|
+
);
|
|
840
|
+
const lines = match
|
|
841
|
+
? command
|
|
842
|
+
.slice(i + match[0].length)
|
|
843
|
+
.trimEnd()
|
|
844
|
+
.split(/\r?\n/)
|
|
845
|
+
: [];
|
|
846
|
+
const end = lines.findIndex(
|
|
847
|
+
(line) => (match?.[1] ? line.replace(/^\t+/, "") : line) === match?.[3],
|
|
848
|
+
);
|
|
849
|
+
return {
|
|
850
|
+
header: command.slice(0, i),
|
|
851
|
+
quoted: Boolean(match?.[2]),
|
|
852
|
+
closed: Boolean(match) && end >= 0,
|
|
853
|
+
body: lines.slice(0, end >= 0 ? end : undefined).join("\n"),
|
|
854
|
+
following: end >= 0 ? lines.slice(end + 1).join("\n") : "",
|
|
855
|
+
};
|
|
856
|
+
} else {
|
|
857
|
+
wordStarted = !/[\s;&|()<>]/.test(char ?? "");
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
return undefined;
|
|
861
|
+
}
|
|
862
|
+
|
|
571
863
|
/** A single literal writer cannot execute its body or feed another command. */
|
|
572
864
|
function isLiteralTextWrite(command: string): boolean {
|
|
573
|
-
const
|
|
574
|
-
const heredoc = /^(.*?)<<(-?)[ \t]*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\3[ \t]*$/.exec(lines[0] ?? "");
|
|
865
|
+
const heredoc = boundedHeredoc(command);
|
|
575
866
|
let header = command;
|
|
576
867
|
let writers = ["echo", "printf"];
|
|
577
868
|
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 ?? "";
|
|
869
|
+
// Only a complete body with no following commands is a literal-write exception.
|
|
870
|
+
if (!heredoc.closed || heredoc.following) return false;
|
|
871
|
+
if (!heredoc.quoted && /[$`\\]/.test(heredoc.body)) return false;
|
|
872
|
+
header = heredoc.header;
|
|
587
873
|
writers = ["cat", "tee"];
|
|
588
874
|
} else if (!command.includes(">")) {
|
|
589
875
|
return false;
|
|
@@ -591,14 +877,7 @@ function isLiteralTextWrite(command: string): boolean {
|
|
|
591
877
|
|
|
592
878
|
// Output redirection does not execute text. Other unsupported syntax still fails parsing.
|
|
593
879
|
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
|
-
}
|
|
880
|
+
if (!parsed || parsed === "brace-expansion") return false;
|
|
602
881
|
const commands = parsed.pipelines.flat().map(unwrapStaticCommand);
|
|
603
882
|
const writerIndexes = commands
|
|
604
883
|
.map((argv, index) => (writers.includes(argv[0]?.split("/").at(-1) ?? "") ? index : -1))
|
|
@@ -613,18 +892,21 @@ function isLiteralTextWrite(command: string): boolean {
|
|
|
613
892
|
);
|
|
614
893
|
}
|
|
615
894
|
|
|
616
|
-
function hasSensitiveFileRead(command: string): boolean {
|
|
895
|
+
function hasSensitiveFileRead(command: string, isPathBlocked: (path: string) => boolean): boolean {
|
|
617
896
|
const sensitivePath =
|
|
618
897
|
/(?:^|[/\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
898
|
const sensitiveCamelCase =
|
|
620
899
|
/(?:^|[/\s'"])[A-Za-z0-9_.-]*(?:(?:secret|credential)[A-Z]|(?:Secret|Credential)[A-Z])[^/\s'"]*/;
|
|
621
900
|
const sensitiveName = /secret|credential/i;
|
|
622
901
|
const isSensitivePath = (text: string) =>
|
|
623
|
-
sensitivePath.test(text) ||
|
|
902
|
+
sensitivePath.test(text) ||
|
|
903
|
+
sensitiveCamelCase.test(text) ||
|
|
904
|
+
(sensitiveName.test(text) && !/(?:^|\/)credential-guard(?:-[^/]+)?\.(?:ts|md)$/.test(text));
|
|
624
905
|
if (/(?:^|[\s;&|])--env-file(?:=|\s)[^;&|]*\.env\b/i.test(command)) return true;
|
|
625
906
|
if (/\bcat\b[^;&|]*<<-?\s*\S+[\s\S]*\b(?:secret|credential)\b/i.test(command)) return true;
|
|
626
907
|
const parsed = parseStaticShellCommands(command);
|
|
627
|
-
const readers =
|
|
908
|
+
const readers =
|
|
909
|
+
/\b(?:cat|cp|grep|rg|sed|awk|jq|head|tail|less|more|source|ls|sort|uniq|cut|wc|mv|find)\b/i;
|
|
628
910
|
if (parsed && parsed !== "brace-expansion") {
|
|
629
911
|
return parsed.pipelines.flat().some((words) => {
|
|
630
912
|
const argv = unwrapStaticCommand(words);
|
|
@@ -633,6 +915,17 @@ function hasSensitiveFileRead(command: string): boolean {
|
|
|
633
915
|
if (inspectedArgv === null) return false;
|
|
634
916
|
const inspected = unwrapStaticCommand(inspectedArgv);
|
|
635
917
|
const name = inspected[0]?.split("/").at(-1) ?? "";
|
|
918
|
+
if (name === "bun" && inspected[1] === "run" && inspected[2] === "gh-tool") {
|
|
919
|
+
return inspected.some((arg, index) => {
|
|
920
|
+
const path =
|
|
921
|
+
arg === "--body-file"
|
|
922
|
+
? inspected[index + 1]
|
|
923
|
+
: arg.startsWith("--body-file=")
|
|
924
|
+
? arg.slice("--body-file=".length)
|
|
925
|
+
: undefined;
|
|
926
|
+
return path !== undefined && (isPathBlocked(path) || isSensitivePath(path));
|
|
927
|
+
});
|
|
928
|
+
}
|
|
636
929
|
if (!readers.test(name)) return false;
|
|
637
930
|
if (name === "rg" || name === "grep") {
|
|
638
931
|
const args = inspected.slice(1);
|
|
@@ -722,11 +1015,23 @@ function hasSensitiveFileRead(command: string): boolean {
|
|
|
722
1015
|
}
|
|
723
1016
|
paths.push(arg);
|
|
724
1017
|
}
|
|
725
|
-
return paths.some(isSensitivePath);
|
|
1018
|
+
return paths.some((path) => isPathBlocked(path) || isSensitivePath(path));
|
|
726
1019
|
}
|
|
727
|
-
|
|
1020
|
+
const paths = name === "jq" ? jqFileArguments(inspected) : inspected.slice(1);
|
|
1021
|
+
return paths.some((path) => isPathBlocked(path) || isSensitivePath(path));
|
|
728
1022
|
});
|
|
729
1023
|
}
|
|
1024
|
+
const operands = parseStaticShellCommands(command, true);
|
|
1025
|
+
if (
|
|
1026
|
+
operands &&
|
|
1027
|
+
typeof operands !== "string" &&
|
|
1028
|
+
operands.pipelines.flat().some((words) => {
|
|
1029
|
+
const argv = unwrapStaticCommand(words);
|
|
1030
|
+
return readers.test(argv[0] ?? "") && argv.slice(1).some(isPathBlocked);
|
|
1031
|
+
})
|
|
1032
|
+
)
|
|
1033
|
+
return true;
|
|
1034
|
+
if (readers.test(command) && command.split(/[\s'"`()<>;&|]+/).some(isPathBlocked)) return true;
|
|
730
1035
|
const unquoted = command.replace(/'(?:\\.|[^'])*'/g, " ").replace(/"(?:\\.|[^"$`])*"/g, " ");
|
|
731
1036
|
if (/\b(?:bun|node|python3?|ruby)\b/i.test(command) && isSensitivePath(command)) {
|
|
732
1037
|
return true;
|
|
@@ -738,9 +1043,149 @@ function hasSensitiveFileRead(command: string): boolean {
|
|
|
738
1043
|
);
|
|
739
1044
|
}
|
|
740
1045
|
|
|
741
|
-
function hasEnvironmentVariableExpansionRead(
|
|
742
|
-
|
|
743
|
-
|
|
1046
|
+
function hasEnvironmentVariableExpansionRead(
|
|
1047
|
+
command: string,
|
|
1048
|
+
allowedNames: Set<string>,
|
|
1049
|
+
isPathBlocked: (path: string) => boolean,
|
|
1050
|
+
): boolean {
|
|
1051
|
+
let heredoc = boundedHeredoc(command);
|
|
1052
|
+
while (heredoc) {
|
|
1053
|
+
// In an unquoted heredoc, # and quote characters are data, not shell syntax.
|
|
1054
|
+
if (!heredoc.closed || (!heredoc.quoted && /[$`\\]/.test(heredoc.body))) return true;
|
|
1055
|
+
command = [heredoc.header, heredoc.following].join("\n");
|
|
1056
|
+
heredoc = boundedHeredoc(command);
|
|
1057
|
+
}
|
|
1058
|
+
if (!command.includes("$")) return false;
|
|
1059
|
+
let invalidExpansion = false;
|
|
1060
|
+
const assignments = getLeadingLiteralAssignments(command);
|
|
1061
|
+
const localValues = new Map<string, string>();
|
|
1062
|
+
let localIndex = 0;
|
|
1063
|
+
let expansions = 0;
|
|
1064
|
+
let normalized = "";
|
|
1065
|
+
let quote: "'" | '"' | undefined;
|
|
1066
|
+
let wordStarted = false;
|
|
1067
|
+
for (let i = 0; i < command.length; i++) {
|
|
1068
|
+
const char = command[i] ?? "";
|
|
1069
|
+
if (!quote && char === "#" && !wordStarted) {
|
|
1070
|
+
while (i + 1 < command.length && command[i + 1] !== "\n") i++;
|
|
1071
|
+
continue;
|
|
1072
|
+
}
|
|
1073
|
+
if (!quote && /[\s;&|()<>]/.test(char)) wordStarted = false;
|
|
1074
|
+
else wordStarted = true;
|
|
1075
|
+
if (char === "\\" && quote !== "'") {
|
|
1076
|
+
normalized += char + (command[++i] ?? "");
|
|
1077
|
+
continue;
|
|
1078
|
+
}
|
|
1079
|
+
if (quote === "'") {
|
|
1080
|
+
normalized += char;
|
|
1081
|
+
if (char === "'") quote = undefined;
|
|
1082
|
+
continue;
|
|
1083
|
+
}
|
|
1084
|
+
if (char === "'" && quote !== '"') {
|
|
1085
|
+
normalized += char;
|
|
1086
|
+
quote = "'";
|
|
1087
|
+
continue;
|
|
1088
|
+
}
|
|
1089
|
+
if (char === '"') {
|
|
1090
|
+
normalized += char;
|
|
1091
|
+
quote = quote === '"' ? undefined : '"';
|
|
1092
|
+
continue;
|
|
1093
|
+
}
|
|
1094
|
+
if (char !== "$") {
|
|
1095
|
+
normalized += char;
|
|
1096
|
+
continue;
|
|
1097
|
+
}
|
|
1098
|
+
const variable = /^\$\{([A-Za-z_][A-Za-z0-9_]*)\}|^\$([A-Za-z_][A-Za-z0-9_]*)/.exec(
|
|
1099
|
+
command.slice(i),
|
|
1100
|
+
);
|
|
1101
|
+
if (!variable) {
|
|
1102
|
+
if (command[i + 1] === "{" || /[$][0-9@*#?!]/.test(command.slice(i, i + 2))) {
|
|
1103
|
+
invalidExpansion = true;
|
|
1104
|
+
}
|
|
1105
|
+
normalized += char;
|
|
1106
|
+
continue;
|
|
1107
|
+
}
|
|
1108
|
+
expansions++;
|
|
1109
|
+
const name = variable[1] ?? variable[2] ?? "";
|
|
1110
|
+
const assignment = assignments.get(name);
|
|
1111
|
+
if (assignment && assignment.end >= i) invalidExpansion = true;
|
|
1112
|
+
if (assignment && assignment.end < i) {
|
|
1113
|
+
const marker = `LOCALVALUE${localIndex++}`;
|
|
1114
|
+
localValues.set(marker, assignment.value);
|
|
1115
|
+
normalized += marker;
|
|
1116
|
+
} else if (allowedNames.has(name)) {
|
|
1117
|
+
normalized += quote ? "ENVVALUE" : "ENVVALUEUNQUOTED";
|
|
1118
|
+
} else {
|
|
1119
|
+
normalized += quote ? "UNKNOWNVALUE" : "UNKNOWNVALUEUNQUOTED";
|
|
1120
|
+
}
|
|
1121
|
+
i += variable[0].length - 1;
|
|
1122
|
+
}
|
|
1123
|
+
if (!expansions) return invalidExpansion;
|
|
1124
|
+
if (invalidExpansion) return true;
|
|
1125
|
+
|
|
1126
|
+
const localMaterialized = [...localValues].reduce(
|
|
1127
|
+
(text, [marker, value]) => text.replaceAll(marker, value),
|
|
1128
|
+
normalized,
|
|
1129
|
+
);
|
|
1130
|
+
// Options must be checked after local literals become actual argv, never as placeholders.
|
|
1131
|
+
if (!hasSafeLocalAssignmentCommands(localMaterialized, assignments)) return true;
|
|
1132
|
+
const parsed = parseStaticShellCommands(localMaterialized);
|
|
1133
|
+
if (!parsed || typeof parsed === "string") return true;
|
|
1134
|
+
for (const words of parsed.pipelines.flat()) {
|
|
1135
|
+
if (!words.some((arg) => arg.includes("UNKNOWNVALUE"))) continue;
|
|
1136
|
+
if (words.some((arg) => arg.includes("UNKNOWNVALUEUNQUOTED"))) return true;
|
|
1137
|
+
const argv = unwrapStaticCommand(words);
|
|
1138
|
+
if (
|
|
1139
|
+
words.slice(0, words.length - argv.length).some((arg) => arg.includes("UNKNOWNVALUE")) ||
|
|
1140
|
+
!isNavigationOperand(argv, "UNKNOWNVALUE")
|
|
1141
|
+
)
|
|
1142
|
+
return true;
|
|
1143
|
+
}
|
|
1144
|
+
if (!localMaterialized.includes("ENVVALUE")) {
|
|
1145
|
+
return (
|
|
1146
|
+
hasSensitiveFileRead(localMaterialized, isPathBlocked) ||
|
|
1147
|
+
hasSensitivePathRedirect(localMaterialized, isPathBlocked) ||
|
|
1148
|
+
hasEnvironmentRead(localMaterialized, allowedNames)
|
|
1149
|
+
);
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
for (const [index, pipeline] of parsed.pipelines.entries()) {
|
|
1153
|
+
const hasEnvironmentValue = pipeline.some((argv) =>
|
|
1154
|
+
argv.some((arg) => arg.includes("ENVVALUE")),
|
|
1155
|
+
);
|
|
1156
|
+
if (!hasEnvironmentValue) continue;
|
|
1157
|
+
if (parsed.redirects.some((redirect) => redirect.pipeline === index)) return true;
|
|
1158
|
+
if (
|
|
1159
|
+
pipeline.some((argv) => {
|
|
1160
|
+
const unwrapped = unwrapStaticCommand(argv);
|
|
1161
|
+
const name = unwrapped[0]?.split("/").at(-1) ?? "";
|
|
1162
|
+
if (argv.some((arg) => arg.includes("ENVVALUE"))) {
|
|
1163
|
+
if (argv.slice(0, argv.length - unwrapped.length).some((arg) => arg.includes("ENVVALUE")))
|
|
1164
|
+
return true;
|
|
1165
|
+
if (isNavigationOperand(unwrapped, "ENVVALUE")) {
|
|
1166
|
+
return unwrapped.some((arg) => arg.includes("ENVVALUEUNQUOTED"));
|
|
1167
|
+
}
|
|
1168
|
+
const formatIndex = name === "printf" ? passivePrintfFormatIndex(unwrapped) : undefined;
|
|
1169
|
+
return (
|
|
1170
|
+
!["echo", "printf"].includes(name) ||
|
|
1171
|
+
!isPassiveTextCommand(unwrapped) ||
|
|
1172
|
+
unwrapped[0]?.includes("ENVVALUE") === true ||
|
|
1173
|
+
(name === "printf" &&
|
|
1174
|
+
(formatIndex === undefined ||
|
|
1175
|
+
unwrapped.slice(0, formatIndex + 1).some((arg) => arg.includes("ENVVALUE"))))
|
|
1176
|
+
);
|
|
1177
|
+
}
|
|
1178
|
+
return !isPassiveTextCommand(unwrapped);
|
|
1179
|
+
})
|
|
1180
|
+
) {
|
|
1181
|
+
return true;
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
const materialized = [...localValues].reduce(
|
|
1185
|
+
(text, [marker, value]) => text.replaceAll(marker, value),
|
|
1186
|
+
normalized.replaceAll("ENVVALUE", "SAFEENVVALUE"),
|
|
1187
|
+
);
|
|
1188
|
+
return hasSensitiveFileRead(materialized, isPathBlocked);
|
|
744
1189
|
}
|
|
745
1190
|
|
|
746
1191
|
function hasEnvironmentSourceAccess(command: string): boolean {
|
|
@@ -849,37 +1294,44 @@ export function createCredentialGuard(config?: CredentialGuardConfig): Credentia
|
|
|
849
1294
|
}
|
|
850
1295
|
|
|
851
1296
|
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
|
-
);
|
|
1297
|
+
return getDangerousBashReason(command) !== null;
|
|
859
1298
|
}
|
|
860
1299
|
|
|
861
|
-
function
|
|
862
|
-
if (
|
|
863
|
-
|
|
1300
|
+
function getDangerousBashReason(command: string): string | null {
|
|
1301
|
+
if (
|
|
1302
|
+
hasSensitiveFileRead(command, isPathBlocked) ||
|
|
1303
|
+
hasSensitivePathRedirect(command, isPathBlocked)
|
|
1304
|
+
) {
|
|
1305
|
+
return "accesses a sensitive file path";
|
|
864
1306
|
}
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
1307
|
+
if (
|
|
1308
|
+
!isLiteralTextWrite(command) &&
|
|
1309
|
+
hasEnvironmentVariableExpansionRead(command, allowedEnvironmentVariables, isPathBlocked)
|
|
1310
|
+
) {
|
|
1311
|
+
return "expands an unapproved or executable environment variable value";
|
|
869
1312
|
}
|
|
1313
|
+
if (hasEnvironmentSourceAccess(command)) return "reads process environment from a script";
|
|
1314
|
+
if (hasEnvironmentRead(command, allowedEnvironmentVariables)) {
|
|
1315
|
+
return "reads environment variables or passes them to an executor";
|
|
1316
|
+
}
|
|
1317
|
+
if (dangerousBashPatterns.some((pattern) => pattern.test(command))) {
|
|
1318
|
+
return "matches a configured dangerous-command pattern";
|
|
1319
|
+
}
|
|
1320
|
+
return null;
|
|
1321
|
+
}
|
|
870
1322
|
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
return
|
|
874
|
-
|
|
875
|
-
|
|
1323
|
+
function isGhCommandAllowed(command: string): boolean {
|
|
1324
|
+
const parsed = parseStaticShellCommands(command);
|
|
1325
|
+
if (!parsed || typeof parsed === "string") return false;
|
|
1326
|
+
const ghCommands = parsed.pipelines
|
|
1327
|
+
.flat()
|
|
1328
|
+
.map(unwrapStaticCommand)
|
|
1329
|
+
.filter((argv) => argv[0]?.split("/").at(-1) === "gh");
|
|
1330
|
+
return ghCommands.length > 0 && ghCommands.every(isAllowedGhArgv);
|
|
876
1331
|
}
|
|
877
1332
|
|
|
878
1333
|
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()));
|
|
1334
|
+
return isGhCommandAllowed(command);
|
|
883
1335
|
}
|
|
884
1336
|
|
|
885
1337
|
function detectSleepPolling(command: string): string | null {
|
|
@@ -894,6 +1346,27 @@ export function createCredentialGuard(config?: CredentialGuardConfig): Credentia
|
|
|
894
1346
|
}
|
|
895
1347
|
|
|
896
1348
|
function getBlockedCliTool(command: string): { name: string; wrapper: string } | null {
|
|
1349
|
+
const staticCommands = parseStaticShellCommands(command);
|
|
1350
|
+
const parsed = staticCommands ?? parseStaticShellCommands(command, true);
|
|
1351
|
+
if (parsed && typeof parsed !== "string") {
|
|
1352
|
+
const commands = parsed.pipelines.flat().map(unwrapStaticCommand);
|
|
1353
|
+
for (const argv of commands) {
|
|
1354
|
+
const executable = argv[0]?.split("/").at(-1) ?? "";
|
|
1355
|
+
const match = blockedCliTools.find(({ name }) =>
|
|
1356
|
+
name === "curl (Azure DevOps)"
|
|
1357
|
+
? executable === "curl" && argv.slice(1).join(" ").includes("dev.azure.com")
|
|
1358
|
+
: name === "az" || name === "az (Azure DevOps)"
|
|
1359
|
+
? executable === "az" &&
|
|
1360
|
+
(name !== "az (Azure DevOps)" ||
|
|
1361
|
+
/^(?:devops|pipelines|repos|boards|artifacts)$/.test(argv[1] ?? ""))
|
|
1362
|
+
: executable === name,
|
|
1363
|
+
);
|
|
1364
|
+
if (!match) continue;
|
|
1365
|
+
if (match.name === "gh" && allGhCommandsAllowed(command)) continue;
|
|
1366
|
+
return { name: match.name, wrapper: match.wrapper };
|
|
1367
|
+
}
|
|
1368
|
+
if (staticCommands) return null;
|
|
1369
|
+
}
|
|
897
1370
|
for (const { pattern, name, wrapper } of blockedCliTools) {
|
|
898
1371
|
if (pattern.test(command)) {
|
|
899
1372
|
if (name === "gh" && allGhCommandsAllowed(command)) {
|
|
@@ -946,9 +1419,10 @@ export function createCredentialGuard(config?: CredentialGuardConfig): Credentia
|
|
|
946
1419
|
if (tool === "bash") {
|
|
947
1420
|
const command = extractCommand(args);
|
|
948
1421
|
|
|
949
|
-
|
|
1422
|
+
const dangerousReason = getDangerousBashReason(command);
|
|
1423
|
+
if (dangerousReason) {
|
|
950
1424
|
throw new Error(
|
|
951
|
-
`\u{1F6AB} Command blocked:
|
|
1425
|
+
`\u{1F6AB} Command blocked: ${dangerousReason}; this command might expose secrets.\n\n` +
|
|
952
1426
|
`Command: ${command}\n\n` +
|
|
953
1427
|
`If you need environment info, ask the user directly.\n\n` +
|
|
954
1428
|
`Think this is wrong? Review the project repository, adjust the patterns, and submit a PR.`,
|