@blogic-cz/agent-tools 1.6.0 → 1.6.2
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 -1
- package/dist/credential-guard/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/credential-guard/index.ts +232 -108
package/README.md
CHANGED
|
@@ -650,10 +650,12 @@ The guard permits static `printenv` reads and simple `echo`/`printf` expansions
|
|
|
650
650
|
|
|
651
651
|
Python inline programs and quoted stdin heredocs can also display approved values through `os.getenv("NAME")`, `os.environ.get("NAME", "default")`, or `os.environ["NAME"]`. The complete program must be `import os` followed by `print(...)` statements using these reads and plain string labels or defaults. Dynamic names, aliases, escaped or interpolated strings, additional statements, and whole-environment output remain unsupported. The same redirection and pipeline restrictions apply.
|
|
652
652
|
|
|
653
|
-
Literal assignments such as `F=README.md; cat "$F"` work in supported static commands. Bounded proofs also support a literal `for` loop over a fixed list,
|
|
653
|
+
Literal assignments such as `F=README.md; cat "$F"` work in supported static commands. Bounded proofs also support a literal `for` loop over a fixed list, a single ASCII-letter path binding such as `cd PATH && A=docs/report.md && shasum -a 256 "$A"` or `F=README.md; cat "$F"`, including the existing `git add -- "$T/file"` form, and literal path braces such as `rg -n -i PATTERN src/{a,b} docs/{guide,api}.md`. Path braces work with supported passive text commands and `cat`, and support one unquoted comma list per path, at most 16 members per list and 32 generated paths per command, within 64 KiB. Nested braces, ranges, empty members, mixed quoting, command positions, and compound commands remain unsupported. Path bindings accept literal values and `;`, newline, or `&&` sequencing; a binding conditional on successful `cd` is proved only for uses in that same `&&` list. Their uses must be supported read-only file operands or explicit `git add --` paths. Rebinding, executable operands, startup-variable names, and uncertain scopes cannot establish this proof. Loop binders are a single lowercase letter or `T`, and their uses must occupy supported data operands. Every body command and expanded path is checked; unsupported control flow, mutations such as `printf -v`, and unknown command roles cannot establish the proof. Other variable assignments and brace expansions retain conservative refusal. Fully quoted literal braces remain usable in static searches.
|
|
654
654
|
|
|
655
655
|
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"`.
|
|
656
656
|
|
|
657
|
+
Literal `herdr agent prompt` commands can be composed with independent `cat` readers and passive text pipelines. Prompt text stays data; command substitutions, environment mutation, unknown consumers, and executable pipelines cannot establish the proof. All reader operands and redirects still receive the ordinary sensitive-path checks. Literal loops also support `herdr pane read ID --source recent-unwrapped --lines N`, with the binder restricted to `ID`, and `2>&1` file-descriptor duplication. A bounded Python stdin processor supports only `import json,sys; d=json.load(sys.stdin); print(d["result"].get("text","") if isinstance(d.get("result"),dict) else d)`, including insignificant spacing and single lowercase-letter local renaming. Extra imports, statements, calls, runtime options, arguments, and other Python programs cannot establish this loop proof. The existing loop size and command limits apply.
|
|
658
|
+
|
|
657
659
|
The guard recognizes `rtk`, `rtk proxy`, `command`, and supported `env` prefixes and checks commands in pipelines and chains. Quoted filenames, comments, and redirects retain sensitive-path checks. Jq object selectors and inline awk programs qualify only when their actual program and options establish passive behavior. Literal environment-command text in `rg`, `grep`, `git grep`, `echo`, and `printf` is allowed only with supported command roles and passive pipeline consumers. Environment-source evidence and startup identity checks also apply to the complete original invocation. Custom dangerous-command patterns apply to both the original input and proved expansions.
|
|
658
660
|
|
|
659
661
|
Paths are checked against sensitive filename patterns and configured path rules. Supported proofs apply those checks to their expanded operands. Ordinary parent-directory and absolute paths may be allowed; the guard does not resolve filesystem paths or symlinks, or enforce repository confinement.
|
|
@@ -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;AAK5E,OAAO,EAAE,iBAAiB,EAAE,MAAM,6BAA6B,CAAC;AAChE,OAAO,EAAE,mBAAmB,EAAE,MAAM,0BAA0B,CAAC;AAM/D,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;AAK5E,OAAO,EAAE,iBAAiB,EAAE,MAAM,6BAA6B,CAAC;AAChE,OAAO,EAAE,mBAAmB,EAAE,MAAM,0BAA0B,CAAC;AAM/D,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;AA6tEF,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,CA4TrF;AAQD,kEAAkE;AAClE,eAAO,MAAM,uBAAuB,UAnkFD,SAAS,UAAU,UAAU,KAAK,IAmkFM,CAAC;AAE5E,oDAAoD;AACpD,eAAO,MAAM,aAAa,YArkFC,MAAM,KAAK;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAAG,IAqkFjB,CAAC;AAExD,yEAAyE;AACzE,eAAO,MAAM,aAAa,aAvkFE,MAAM,KAAK,OAukFgB,CAAC;AAExD,yDAAyD;AACzD,eAAO,MAAM,aAAa,aAzkFE,MAAM,KAAK,OAykFgB,CAAC;AAExD,oEAAoE;AACpE,eAAO,MAAM,sBAAsB,YA3kFC,MAAM,KAAK,OA2kF0B,CAAC;AAE1E,iDAAiD;AACjD,eAAO,MAAM,iBAAiB,YA7kFC,MAAM,KAAK;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GAAG,IA6kFf,CAAC;AAEhE,wDAAwD;AACxD,eAAO,MAAM,kBAAkB,YA/kFC,MAAM,KAAK,OA+kFsB,CAAC;AAElE,8EAA8E;AAC9E,eAAO,MAAM,kBAAkB,YAjlFC,MAAM,KAAK,MAAM,GAAG,IAilFa,CAAC"}
|
package/package.json
CHANGED
|
@@ -190,11 +190,11 @@ function getLeadingLiteralAssignments(
|
|
|
190
190
|
const assignments = new Map<string, { value: string; end: number }>();
|
|
191
191
|
let offset = 0;
|
|
192
192
|
while (offset === 0 || command[offset - 1] === ";" || command[offset - 1] === "\n") {
|
|
193
|
-
while (
|
|
193
|
+
while (/[ \t\n]/.test(command[offset] ?? "")) offset++;
|
|
194
194
|
const assignment = /^([A-Za-z_][A-Za-z0-9_]*)=([A-Za-z0-9_./-]+)/.exec(command.slice(offset));
|
|
195
195
|
if (!assignment) break;
|
|
196
196
|
const end = offset + assignment[0].length;
|
|
197
|
-
const next = command.slice(end).match(
|
|
197
|
+
const next = command.slice(end).match(/^[ \t]*/)?.[0].length ?? 0;
|
|
198
198
|
const separator = command[end + next];
|
|
199
199
|
if (separator !== ";" && separator !== "\n") break;
|
|
200
200
|
assignments.set(assignment[1] ?? "", { value: assignment[2] ?? "", end });
|
|
@@ -239,39 +239,45 @@ function hasSafeLocalAssignmentCommands(
|
|
|
239
239
|
assignments.get(assignment[1] ?? "")?.value === assignment[2]
|
|
240
240
|
);
|
|
241
241
|
}
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
if (name === "find") {
|
|
256
|
-
return !argv.slice(1).some((arg) => /^-(?:exec|ok|delete|fprint|fprintf)/.test(arg));
|
|
257
|
-
}
|
|
258
|
-
if (name === "bun") return argv[1] === "run" && ["db-tool", "gh-tool"].includes(argv[2] ?? "");
|
|
242
|
+
return isSafeLocalAssignmentCommand(words);
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function isSafeLocalAssignmentCommand(words: string[]): boolean {
|
|
247
|
+
const argv = unwrapStaticCommand(words);
|
|
248
|
+
if (isSafeHerdrTabCreate(argv)) return true;
|
|
249
|
+
const name = argv[0]?.split("/").at(-1) ?? "";
|
|
250
|
+
if (name === "wc" && hasExecutionOption(argv.slice(1), ["files0-from"])) return false;
|
|
251
|
+
if (name === "cd") return isNavigationOperand(argv, "LOCALVALUE");
|
|
252
|
+
if (name === "git") {
|
|
253
|
+
let index = 1;
|
|
254
|
+
while (argv[index] === "-C" && argv[index + 1]) index += 2;
|
|
259
255
|
return (
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
(name === "sed" &&
|
|
264
|
-
argv
|
|
265
|
-
.slice(1)
|
|
266
|
-
.every(
|
|
267
|
-
(arg) =>
|
|
268
|
-
/^-[En]+$/.test(arg) ||
|
|
269
|
-
/^s([/|]).*\1[^/|]*\1[gp]*$/.test(arg) ||
|
|
270
|
-
/^[A-Za-z0-9_./-]+$/.test(arg),
|
|
271
|
-
) &&
|
|
272
|
-
!argv.includes("-f"))
|
|
256
|
+
["branch", "diff", "log", "rev-parse", "rev-list", "show", "status"].includes(
|
|
257
|
+
argv[index] ?? "",
|
|
258
|
+
) && !hasExecutionOption(argv.slice(index + 1), ["exec", "ext-diff", "textconv"])
|
|
273
259
|
);
|
|
274
|
-
}
|
|
260
|
+
}
|
|
261
|
+
if (name === "find") {
|
|
262
|
+
return !argv.slice(1).some((arg) => /^-(?:exec|ok|delete|fprint|fprintf)/.test(arg));
|
|
263
|
+
}
|
|
264
|
+
if (name === "bun") return argv[1] === "run" && ["db-tool", "gh-tool"].includes(argv[2] ?? "");
|
|
265
|
+
return (
|
|
266
|
+
isPassiveTextCommand(argv) ||
|
|
267
|
+
["ls", "cat", "tail", "wc", "uniq", "cut", "mv"].includes(name) ||
|
|
268
|
+
(name === "sort" &&
|
|
269
|
+
!hasExecutionOption(argv.slice(1), ["compress-program", "files0-from", "output"], "o")) ||
|
|
270
|
+
(name === "sed" &&
|
|
271
|
+
argv
|
|
272
|
+
.slice(1)
|
|
273
|
+
.every(
|
|
274
|
+
(arg) =>
|
|
275
|
+
/^-[En]+$/.test(arg) ||
|
|
276
|
+
/^s([/|]).*\1[^/|]*\1[gp]*$/.test(arg) ||
|
|
277
|
+
/^[A-Za-z0-9_./-]+$/.test(arg),
|
|
278
|
+
) &&
|
|
279
|
+
!argv.includes("-f"))
|
|
280
|
+
);
|
|
275
281
|
}
|
|
276
282
|
|
|
277
283
|
function hasSensitivePathRedirect(
|
|
@@ -429,6 +435,7 @@ function literalBindingText(
|
|
|
429
435
|
let quote: "'" | '"' | undefined;
|
|
430
436
|
for (let i = 0; i < command.length; i++) {
|
|
431
437
|
const char = command[i] ?? "";
|
|
438
|
+
if (!quote && /[^\S \t\n]/.test(char)) return undefined;
|
|
432
439
|
if (char === "\\" && quote !== "'") {
|
|
433
440
|
const next = command[++i];
|
|
434
441
|
if (next === undefined || /[\r\n]/.test(next)) return undefined;
|
|
@@ -497,9 +504,8 @@ function isLiteralLoopCommand(words: string[], marker: string, value: string): b
|
|
|
497
504
|
}
|
|
498
505
|
if (name === "herdr")
|
|
499
506
|
return (
|
|
500
|
-
argv.length === 4 &&
|
|
501
|
-
|
|
502
|
-
argv[2] === "get" &&
|
|
507
|
+
((argv.length === 4 && argv[1] === "agent" && argv[2] === "get") ||
|
|
508
|
+
isLiteralHerdrPaneRead(argv)) &&
|
|
503
509
|
/^[A-Za-z0-9_.:-]+$/.test(argv[3] ?? "") &&
|
|
504
510
|
uses.every((index) => index === 3)
|
|
505
511
|
);
|
|
@@ -513,7 +519,36 @@ function isLiteralLoopCommand(words: string[], marker: string, value: string): b
|
|
|
513
519
|
uses.every((index) => index === 8)
|
|
514
520
|
);
|
|
515
521
|
return (
|
|
516
|
-
|
|
522
|
+
uses.length === 0 &&
|
|
523
|
+
((["grep", "head", "tail"].includes(name ?? "") && isPassiveTextCommand(argv)) ||
|
|
524
|
+
isLiteralStdinJsonDisplay(argv))
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function isLiteralHerdrPaneRead(argv: string[]): boolean {
|
|
529
|
+
return (
|
|
530
|
+
argv.length === 8 &&
|
|
531
|
+
argv.slice(0, 3).join(" ") === "herdr pane read" &&
|
|
532
|
+
/^[A-Za-z0-9_.:-]+$/.test(argv[3] ?? "") &&
|
|
533
|
+
argv[4] === "--source" &&
|
|
534
|
+
argv[5] === "recent-unwrapped" &&
|
|
535
|
+
argv[6] === "--lines" &&
|
|
536
|
+
/^[1-9]\d*$/.test(argv[7] ?? "")
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/** A complete stdin-only JSON projection, with no suffix, imports or executable input. */
|
|
541
|
+
function isLiteralStdinJsonDisplay(argv: string[]): boolean {
|
|
542
|
+
if (argv.length !== 3 || !["python", "python3"].includes(argv[0] ?? "") || argv[1] !== "-c")
|
|
543
|
+
return false;
|
|
544
|
+
const program = argv[2] ?? "";
|
|
545
|
+
if (program.length > 65_536) return false;
|
|
546
|
+
const parts = programParts(program, true);
|
|
547
|
+
if (!parts || parts.literals.join("\0") !== "result\0text\0\0result") return false;
|
|
548
|
+
// ponytail: one JSON projection with a single-letter local; extend only with another complete grammar.
|
|
549
|
+
const code = parts.code.replace(/[ \t]+/g, " ").trim();
|
|
550
|
+
return /^import json\s*,\s*sys\s*(?:;|\r?\n)\s*([a-z])\s*=\s*json\s*\.\s*load\s*\(\s*sys\s*\.\s*stdin\s*\)\s*(?:;|\r?\n)\s*print\s*\(\s*\1\s*\[\s*STRING\s*\]\s*\.\s*get\s*\(\s*STRING\s*,\s*STRING\s*\)\s+if isinstance\s*\(\s*\1\s*\.\s*get\s*\(\s*STRING\s*\)\s*,\s*dict\s*\)\s+else \1\s*\)\s*;?$/.test(
|
|
551
|
+
code,
|
|
517
552
|
);
|
|
518
553
|
}
|
|
519
554
|
|
|
@@ -524,7 +559,7 @@ function materializeLiteralShell(command: string): LiteralShellProof | undefined
|
|
|
524
559
|
if (new TextEncoder().encode(command).byteLength > 65_536) return undefined;
|
|
525
560
|
const marker = "GUARDLITERALBINDING";
|
|
526
561
|
if (command.includes(marker)) return undefined;
|
|
527
|
-
let script = command.
|
|
562
|
+
let script = command.replace(/^[ \t\n]+|[ \t\n]+$/g, "");
|
|
528
563
|
const wrapped = parseStaticShellCommands(script);
|
|
529
564
|
if (
|
|
530
565
|
wrapped &&
|
|
@@ -534,7 +569,10 @@ function materializeLiteralShell(command: string): LiteralShellProof | undefined
|
|
|
534
569
|
wrapped.redirects.length === 0
|
|
535
570
|
) {
|
|
536
571
|
const argv = unwrapProofCommand(wrapped.pipelines[0]?.[0] ?? [], marker);
|
|
537
|
-
if (argv?.[0] === "sh" && argv[1] === "-c" && argv.length === 3)
|
|
572
|
+
if (argv?.[0] === "sh" && argv[1] === "-c" && argv.length === 3) {
|
|
573
|
+
if (!literalBindingText(script, "", "")) return undefined;
|
|
574
|
+
script = argv[2] ?? "";
|
|
575
|
+
}
|
|
538
576
|
}
|
|
539
577
|
const loop =
|
|
540
578
|
/^for[ \t]+([A-Za-z_]\w*)[ \t]+in[ \t]+([^;\n]+);[ \t\r\n]*do[ \t\r\n]+([\s\S]*);[ \t\r\n]*done[ \t\r\n]*$/.exec(
|
|
@@ -557,7 +595,8 @@ function materializeLiteralShell(command: string): LiteralShellProof | undefined
|
|
|
557
595
|
const values = rawValues.map((item) => item.replace(/^['"]|['"]$/g, ""));
|
|
558
596
|
if (!values.length || values.length > 16) return undefined;
|
|
559
597
|
const marked = literalBindingText(loop[3] ?? "", name, marker);
|
|
560
|
-
if (!marked || /&&|\|\||[<>]|&/.test(marked.syntax.replace(/\b2>&1\
|
|
598
|
+
if (!marked || /&&|\|\||[<>]|&/.test(marked.syntax.replace(/\b2>&1(?=[ \t\n;|]|$)/g, "")))
|
|
599
|
+
return undefined;
|
|
561
600
|
const parsed = parseStaticShellCommands(marked.text);
|
|
562
601
|
if (
|
|
563
602
|
!parsed ||
|
|
@@ -587,64 +626,135 @@ function materializeLiteralShell(command: string): LiteralShellProof | undefined
|
|
|
587
626
|
paths: [],
|
|
588
627
|
};
|
|
589
628
|
}
|
|
629
|
+
// Single ASCII-letter names avoid shell startup/special variables such as PATH and IFS.
|
|
590
630
|
const assignment =
|
|
591
|
-
/^cd[ \t]+(~?\/?[A-Za-z0-9_./-]+)[ \t]
|
|
592
|
-
|
|
631
|
+
/^(?:cd[ \t]+(~?\/?[A-Za-z0-9_./-]+)[ \t]*(;|\n|&&)[ \t]*)?([A-Za-z])=([A-Za-z0-9_./][A-Za-z0-9_./-]*)[ \t]*(;|\n|&&)[ \t]*/.exec(
|
|
632
|
+
script,
|
|
593
633
|
);
|
|
594
|
-
if (assignment
|
|
634
|
+
if (assignment) {
|
|
595
635
|
const rest = literalBindingText(
|
|
596
|
-
|
|
597
|
-
assignment[
|
|
636
|
+
script.slice(assignment[0].length),
|
|
637
|
+
assignment[3] ?? "",
|
|
598
638
|
marker,
|
|
599
639
|
);
|
|
600
|
-
if (!rest) return undefined;
|
|
601
|
-
|
|
602
|
-
const
|
|
603
|
-
|
|
604
|
-
if (
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
parsed.pipelines[0]?.length === 1 &&
|
|
611
|
-
parsed.redirects.length === 0
|
|
612
|
-
? parsed.pipelines[0]?.[0]
|
|
613
|
-
: undefined;
|
|
640
|
+
if (!rest || !rest.text.includes(marker)) return undefined;
|
|
641
|
+
// No branches, background jobs or pipeline-local state. FD duplication stays inert.
|
|
642
|
+
const syntax = rest.syntax.replace(/[<>]&[0-9-]+/g, "");
|
|
643
|
+
if (/\|\||(^|[^&])&(?!&)|[^\S \t\n]/.test(syntax)) return undefined;
|
|
644
|
+
if (assignment[2] === "&&") {
|
|
645
|
+
if (assignment[5] !== "&&") return undefined;
|
|
646
|
+
const boundary = rest.syntax.search(/[;\r\n]/);
|
|
647
|
+
if (boundary >= 0 && rest.text.slice(boundary).includes(marker)) return undefined;
|
|
648
|
+
}
|
|
649
|
+
const parsed = parseStaticShellCommands(rest.text);
|
|
614
650
|
if (
|
|
615
|
-
!
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
651
|
+
!parsed ||
|
|
652
|
+
typeof parsed === "string" ||
|
|
653
|
+
parsed.redirects.some(({ target }) => target.includes(marker)) ||
|
|
654
|
+
!parsed.pipelines.flat().length ||
|
|
655
|
+
parsed.pipelines.flat().length > 32
|
|
619
656
|
)
|
|
620
657
|
return undefined;
|
|
621
|
-
const value = assignment[
|
|
658
|
+
const value = assignment[4] ?? "";
|
|
659
|
+
const paths: string[] = [];
|
|
660
|
+
for (const words of parsed.pipelines.flat()) {
|
|
661
|
+
const marked = unwrapProofCommand(words, marker);
|
|
662
|
+
if (!marked) return undefined;
|
|
663
|
+
const argv = marked.map((word) => word.replaceAll(marker, () => value));
|
|
664
|
+
const name = argv[0]?.split("/").at(-1) ?? "";
|
|
665
|
+
const uses = marked.flatMap((word, index) => (word.includes(marker) ? [index] : []));
|
|
666
|
+
const checksum =
|
|
667
|
+
["shasum", "sha1sum", "sha256sum", "sha512sum", "md5sum", "cksum"].includes(name) &&
|
|
668
|
+
!hasExecutionOption(argv.slice(1), ["check"], "c");
|
|
669
|
+
if (!uses.length) {
|
|
670
|
+
if (
|
|
671
|
+
!isSafeLocalAssignmentCommand(argv) &&
|
|
672
|
+
inlineProgramIndex(argv) === undefined &&
|
|
673
|
+
!(argv[0] === "git" && argv[1] === "commit") &&
|
|
674
|
+
!checksum
|
|
675
|
+
)
|
|
676
|
+
return undefined;
|
|
677
|
+
continue;
|
|
678
|
+
}
|
|
679
|
+
if (
|
|
680
|
+
uses.some(
|
|
681
|
+
(index) => index === 0 || !/^[A-Za-z0-9_./][A-Za-z0-9_./-]*$/.test(argv[index] ?? ""),
|
|
682
|
+
)
|
|
683
|
+
)
|
|
684
|
+
return undefined;
|
|
685
|
+
const gitAdd =
|
|
686
|
+
argv.slice(0, 3).join(" ") === "git add --" && uses.every((index) => index >= 3);
|
|
687
|
+
const reader =
|
|
688
|
+
["cat", "ls", "head", "tail", "wc", "uniq", "cut", "sort", "grep", "rg"].includes(name) &&
|
|
689
|
+
isSafeLocalAssignmentCommand(argv);
|
|
690
|
+
if (!gitAdd && !reader && !checksum) return undefined;
|
|
691
|
+
paths.push(...uses.map((index) => argv[index] ?? ""));
|
|
692
|
+
}
|
|
693
|
+
const prefix = assignment[1] ? `cd ${assignment[1]}${assignment[2]} ` : "";
|
|
622
694
|
const size =
|
|
623
|
-
new TextEncoder().encode(rest.text).byteLength +
|
|
624
|
-
(
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
if (size > 65_536) return undefined;
|
|
628
|
-
const paths = argv.slice(3).map((word) => word.replaceAll(marker, () => value));
|
|
629
|
-
return {
|
|
630
|
-
command: `cd ${assignment[1]} && ${use.replaceAll(marker, () => value)}${suffix}`,
|
|
631
|
-
paths,
|
|
632
|
-
};
|
|
695
|
+
new TextEncoder().encode(prefix + rest.text).byteLength +
|
|
696
|
+
(rest.text.split(marker).length - 1) * (value.length - marker.length);
|
|
697
|
+
if (paths.length > 32 || size > 65_536) return undefined;
|
|
698
|
+
return { command: prefix + rest.text.replaceAll(marker, () => value), paths };
|
|
633
699
|
}
|
|
634
|
-
//
|
|
635
|
-
const
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
const
|
|
642
|
-
|
|
643
|
-
|
|
700
|
+
// Expand only complete, unquoted path words. Keep all other source text intact.
|
|
701
|
+
const paths: string[] = [];
|
|
702
|
+
let expanded = "";
|
|
703
|
+
let marked = "";
|
|
704
|
+
let start = 0;
|
|
705
|
+
let quote: "'" | '"' | undefined;
|
|
706
|
+
let braces = false;
|
|
707
|
+
const finishWord = (end: number): boolean => {
|
|
708
|
+
const word = command.slice(start, end);
|
|
709
|
+
if (!braces) {
|
|
710
|
+
expanded += word;
|
|
711
|
+
marked += word;
|
|
712
|
+
return true;
|
|
713
|
+
}
|
|
714
|
+
// ponytail: one comma-list per path, no nesting; extend only with a bounded shell parser.
|
|
715
|
+
const match =
|
|
716
|
+
/^([A-Za-z0-9_./-]*\/[A-Za-z0-9_./-]*)\{([A-Za-z0-9_./-]+(?:,[A-Za-z0-9_./-]+)+)\}([A-Za-z0-9_./-]*)$/.exec(
|
|
717
|
+
word,
|
|
718
|
+
);
|
|
719
|
+
if (!match) return false;
|
|
720
|
+
const members = (match[2] ?? "").split(",");
|
|
721
|
+
if (members.length > 16 || paths.length + members.length > 32) return false;
|
|
722
|
+
const values = members.map((member) => (match[1] ?? "") + member + (match[3] ?? ""));
|
|
723
|
+
const text = values.join(" ");
|
|
724
|
+
if (expanded.length + text.length + command.length - end > 65_536) return false;
|
|
725
|
+
paths.push(...values);
|
|
726
|
+
expanded += text;
|
|
727
|
+
marked += marker;
|
|
728
|
+
return true;
|
|
729
|
+
};
|
|
730
|
+
for (let i = 0; i <= command.length; i++) {
|
|
731
|
+
const char = command[i];
|
|
732
|
+
if (quote === "'") {
|
|
733
|
+
if (char === quote) quote = undefined;
|
|
734
|
+
} else if (char === "\\") {
|
|
735
|
+
if (command[i + 1] === undefined || /[\r\n]/.test(command[i + 1] ?? "")) return undefined;
|
|
736
|
+
i++;
|
|
737
|
+
} else if (quote === '"') {
|
|
738
|
+
if (char === quote) quote = undefined;
|
|
739
|
+
} else if (char === "'" || char === '"') {
|
|
740
|
+
quote = char;
|
|
741
|
+
} else if (char === "{" || char === "}") {
|
|
742
|
+
braces = true;
|
|
743
|
+
} else if (char === undefined || /[ \t\n]/.test(char)) {
|
|
744
|
+
if (!finishWord(i)) return undefined;
|
|
745
|
+
expanded += char ?? "";
|
|
746
|
+
marked += char ?? "";
|
|
747
|
+
start = i + 1;
|
|
748
|
+
braces = false;
|
|
749
|
+
} else if (/[\s;&|<>#]/.test(char)) {
|
|
750
|
+
// Other JS whitespace is a literal shell word character, not a token boundary.
|
|
751
|
+
return undefined;
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
if (quote || !paths.length || new TextEncoder().encode(expanded).byteLength > 65_536)
|
|
755
|
+
return undefined;
|
|
644
756
|
const parsed = parseStaticShellCommands(marked);
|
|
645
757
|
if (
|
|
646
|
-
!lexical ||
|
|
647
|
-
/[;&|<>\r\n]/.test(lexical.syntax) ||
|
|
648
758
|
!parsed ||
|
|
649
759
|
typeof parsed === "string" ||
|
|
650
760
|
parsed.redirects.length ||
|
|
@@ -653,24 +763,19 @@ function materializeLiteralShell(command: string): LiteralShellProof | undefined
|
|
|
653
763
|
)
|
|
654
764
|
return undefined;
|
|
655
765
|
const argv = unwrapProofCommand(parsed.pipelines[0]?.[0] ?? [], marker);
|
|
766
|
+
const expandedCommands = parseStaticShellCommands(expanded);
|
|
767
|
+
const expandedArgv =
|
|
768
|
+
expandedCommands && typeof expandedCommands !== "string"
|
|
769
|
+
? unwrapProofCommand(expandedCommands.pipelines[0]?.[0] ?? [], marker)
|
|
770
|
+
: undefined;
|
|
771
|
+
// Classify actual options too: a marked path can hide an executor such as git grep -O/path.
|
|
656
772
|
if (
|
|
657
773
|
!argv ||
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
argv[1] !== "-n" ||
|
|
661
|
-
argv[2]?.startsWith("-") ||
|
|
662
|
-
argv[3] !== marker
|
|
774
|
+
!expandedArgv ||
|
|
775
|
+
(!isPassiveTextCommand(expandedArgv) && expandedArgv[0]?.split("/").at(-1) !== "cat")
|
|
663
776
|
)
|
|
664
777
|
return undefined;
|
|
665
|
-
|
|
666
|
-
if (!members) return undefined;
|
|
667
|
-
const values = (members[1] ?? "").split(",");
|
|
668
|
-
const size =
|
|
669
|
-
new TextEncoder().encode(command.slice(0, start)).byteLength +
|
|
670
|
-
values.reduce((total, value) => total + path.length - members[0].length + value.length + 1, -1);
|
|
671
|
-
if (size > 65_536) return undefined;
|
|
672
|
-
const paths = values.map((member) => path.replace(members[0], () => member));
|
|
673
|
-
return { command: command.slice(0, start) + paths.join(" "), paths };
|
|
778
|
+
return { command: expanded, paths };
|
|
674
779
|
}
|
|
675
780
|
|
|
676
781
|
/** Complete package metadata loops: no prelude, loader shadowing, mutation or arbitrary suffix. */
|
|
@@ -1521,24 +1626,43 @@ function hasStaticEnvironmentRead(
|
|
|
1521
1626
|
}
|
|
1522
1627
|
|
|
1523
1628
|
function isStaticHerdrPrompt(command: string): boolean {
|
|
1629
|
+
// Preserve shell word boundaries and executable identity before proving composition.
|
|
1630
|
+
if (!literalBindingText(command, "", "")) return false;
|
|
1524
1631
|
const parsed = parseStaticShellCommands(command);
|
|
1525
1632
|
if (!parsed || typeof parsed === "string") return false;
|
|
1526
|
-
const commands = parsed.pipelines.flat().map(
|
|
1633
|
+
const commands = parsed.pipelines.flat().map((words) => unwrapProofCommand(words, "\0"));
|
|
1527
1634
|
const prompts = commands.filter(
|
|
1528
|
-
(argv) => argv[0] === "herdr" && argv[1] === "agent" && argv[2] === "prompt",
|
|
1635
|
+
(argv) => argv?.[0] === "herdr" && argv[1] === "agent" && argv[2] === "prompt",
|
|
1529
1636
|
);
|
|
1530
1637
|
return (
|
|
1531
1638
|
prompts.length > 0 &&
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1639
|
+
parsed.pipelines.every((pipeline) =>
|
|
1640
|
+
pipeline.every((words, index) => {
|
|
1641
|
+
const argv = unwrapProofCommand(words, "\0");
|
|
1642
|
+
if (!argv) return false;
|
|
1643
|
+
if (argv[0] === "herdr" && argv[1] === "agent" && argv[2] === "prompt")
|
|
1644
|
+
return index === 0 && argv.length >= 5;
|
|
1645
|
+
// cat is a reader at the start of an independent pipeline, never a consumer.
|
|
1646
|
+
if (argv[0] === "cat")
|
|
1647
|
+
return (
|
|
1648
|
+
index === 0 &&
|
|
1649
|
+
argv.length > 1 &&
|
|
1650
|
+
argv.slice(1).every((arg) => arg === "--" || !arg.startsWith("-"))
|
|
1651
|
+
);
|
|
1652
|
+
return isPassiveTextCommand(argv);
|
|
1653
|
+
}),
|
|
1536
1654
|
)
|
|
1537
1655
|
);
|
|
1538
1656
|
}
|
|
1539
1657
|
|
|
1540
1658
|
function isHerdrPrompt(command: string): boolean {
|
|
1541
|
-
|
|
1659
|
+
if (/^herdr\s+agent\s+prompt\b/.test(command.trim())) return true;
|
|
1660
|
+
const parsed = parseStaticShellCommands(command, true);
|
|
1661
|
+
if (!parsed || typeof parsed === "string") return false;
|
|
1662
|
+
return parsed.pipelines.flat().some((words) => {
|
|
1663
|
+
const argv = unwrapStaticCommand(words);
|
|
1664
|
+
return argv[0] === "herdr" && argv[1] === "agent" && argv[2] === "prompt";
|
|
1665
|
+
});
|
|
1542
1666
|
}
|
|
1543
1667
|
|
|
1544
1668
|
function hasEnvironmentRead(
|
|
@@ -1710,7 +1834,7 @@ function hasSensitiveFileRead(command: string, isPathBlocked: (path: string) =>
|
|
|
1710
1834
|
if (/\bcat\b[^;&|]*<<-?\s*\S+[\s\S]*\b(?:secret|credential)\b/i.test(command)) return true;
|
|
1711
1835
|
const parsed = parseStaticShellCommands(command);
|
|
1712
1836
|
const readers =
|
|
1713
|
-
/\b(?:cat|cp|grep|rg|sed|awk|jq|head|tail|less|more|source|ls|sort|uniq|cut|wc|mv|find)\b/i;
|
|
1837
|
+
/\b(?:cat|cp|grep|rg|sed|awk|jq|head|tail|less|more|source|ls|sort|uniq|cut|wc|mv|find|shasum|sha1sum|sha256sum|sha512sum|md5sum|cksum)\b/i;
|
|
1714
1838
|
if (parsed && parsed !== "brace-expansion") {
|
|
1715
1839
|
return parsed.pipelines.flat().some((words) => {
|
|
1716
1840
|
const argv = unwrapStaticCommand(words);
|