@sema-agent/core 7.21.0 → 7.22.0

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.
Files changed (81) hide show
  1. package/CHANGELOG.md +129 -0
  2. package/dist/agents/send-message-tool.js +1 -0
  3. package/dist/agents/subagent.d.ts +1 -0
  4. package/dist/agents/subagent.js +2 -1
  5. package/dist/core/ask-origin.d.ts +1 -0
  6. package/dist/core/ask-origin.js +3 -2
  7. package/dist/core/auto-mode-rebuild.js +6 -2
  8. package/dist/core/auto-mode.d.ts +40 -18
  9. package/dist/core/auto-mode.js +63 -45
  10. package/dist/core/checkpoint-store.d.ts +8 -8
  11. package/dist/core/engine-notice.d.ts +49 -1
  12. package/dist/core/engine-notice.js +14 -1
  13. package/dist/core/governance-codes.d.ts +1 -1
  14. package/dist/core/governance-codes.js +4 -0
  15. package/dist/core/mcp.d.ts +65 -0
  16. package/dist/core/mcp.js +190 -41
  17. package/dist/core/memory-engine/consolidation.d.ts +5 -0
  18. package/dist/core/memory-engine/consolidation.js +7 -3
  19. package/dist/core/memory-engine/engine.d.ts +8 -0
  20. package/dist/core/memory-engine/engine.js +25 -19
  21. package/dist/core/memory-engine/layout.d.ts +47 -9
  22. package/dist/core/memory-engine/layout.js +157 -57
  23. package/dist/core/memory-engine/session-incarnation.d.ts +27 -0
  24. package/dist/core/memory-engine/session-incarnation.js +63 -0
  25. package/dist/core/parent-spec-seat.d.ts +15 -3
  26. package/dist/core/parent-spec-seat.js +5 -0
  27. package/dist/core/permission-rule-model.d.ts +1 -2
  28. package/dist/core/retention.d.ts +8 -1
  29. package/dist/core/retention.js +2 -0
  30. package/dist/core/runner/classifier-ladder.js +1 -0
  31. package/dist/core/runner/contracts.d.ts +11 -0
  32. package/dist/core/runner/mcp-redial.d.ts +109 -0
  33. package/dist/core/runner/mcp-redial.js +60 -0
  34. package/dist/core/runner/memory-consolidation-driver.d.ts +7 -4
  35. package/dist/core/runner/memory-consolidation-driver.js +1 -1
  36. package/dist/core/runner/prepare-caps-and-workflow.js +9 -12
  37. package/dist/core/runner/prepare-hands-readface.d.ts +3 -0
  38. package/dist/core/runner/prepare-hands-readface.js +13 -3
  39. package/dist/core/runner/prepare-memory.js +3 -3
  40. package/dist/core/runner/prepare-protocol-tools.d.ts +6 -1
  41. package/dist/core/runner/prepare-protocol-tools.js +20 -38
  42. package/dist/core/runner/prepare-task.js +3 -3
  43. package/dist/core/runner/prepare-wiring-manifest.d.ts +4 -0
  44. package/dist/core/runner/prepare-wiring-manifest.js +2 -1
  45. package/dist/core/runner/run-identity-wiring.d.ts +3 -1
  46. package/dist/core/runner/run-identity-wiring.js +4 -1
  47. package/dist/core/runner/run-settle-and-teardown.d.ts +3 -1
  48. package/dist/core/runner/run-settle-and-teardown.js +6 -2
  49. package/dist/core/runner/run-terminal-adoption.d.ts +4 -1
  50. package/dist/core/runner/run-terminal-adoption.js +2 -6
  51. package/dist/core/runner/runtask.js +8 -5
  52. package/dist/core/runner/stream-lifecycle-verbs.d.ts +5 -1
  53. package/dist/core/runner/stream-lifecycle-verbs.js +23 -2
  54. package/dist/core/runner-deps.d.ts +17 -8
  55. package/dist/core/skill-listing-budget.d.ts +5 -4
  56. package/dist/core/task-result.d.ts +3 -2
  57. package/dist/core/task-spec.d.ts +2 -1
  58. package/dist/core/task-stream.d.ts +26 -0
  59. package/dist/core/tool-spec.d.ts +5 -1
  60. package/dist/core/trace.d.ts +5 -0
  61. package/dist/core/types.d.ts +2 -2
  62. package/dist/core/types.js +1 -1
  63. package/dist/core/wiring-manifest.d.ts +23 -0
  64. package/dist/core/wiring-manifest.js +3 -0
  65. package/dist/index.d.ts +4 -3
  66. package/dist/index.js +2 -2
  67. package/dist/orchestration/workflow-governance.js +1 -1
  68. package/dist/orchestration/workflow-primitives.d.ts +2 -2
  69. package/dist/tools/fs/bash-program-position.d.ts +26 -5
  70. package/dist/tools/fs/bash-program-position.js +63 -16
  71. package/dist/tools/fs/bash-readonly-classifier.d.ts +27 -17
  72. package/dist/tools/fs/bash-readonly-classifier.js +134 -55
  73. package/dist/tools/fs/fs-bash.js +43 -18
  74. package/dist/tools/fs/index.d.ts +4 -2
  75. package/dist/tools/fs/read-deny.d.ts +25 -11
  76. package/dist/tools/fs/read-deny.js +5 -1
  77. package/dist/tools/fs/safety.d.ts +1 -4
  78. package/package.json +1 -1
  79. package/test/export-surface.snapshot.json +9 -1
  80. package/wire-changes/core@7.21.1.json +41 -0
  81. package/wire-changes/core@7.22.0.json +637 -0
@@ -12,23 +12,33 @@ export function isBareControlWord(toks, i, words) {
12
12
  export const ARGV_REENTRY_PROGRAM_RE = /^xargs$/;
13
13
  export const ARGV_REENTRY_DEFAULT_PROGRAM = "echo";
14
14
  const NO_DECLARED_OPTIONS = { optionArity: new Map() };
15
+ const launcherRow = (pairs, extra = {}) => ({ optionArity: new Map(pairs), ...extra });
15
16
  export const LAUNCHER_TABLE = new Map([
16
- ["env", NO_DECLARED_OPTIONS], ["command", NO_DECLARED_OPTIONS], ["builtin", NO_DECLARED_OPTIONS], ["exec", NO_DECLARED_OPTIONS],
17
+ ["env", launcherRow([["-i", 0], ["-0", 0], ["-v", 0], ["--ignore-environment", 0], ["--null", 0], ["--debug", 0], ["-u", 1], ["--unset", 1]], { assignmentOperands: true })],
18
+ ["command", launcherRow([["-p", 0]])],
19
+ ["builtin", NO_DECLARED_OPTIONS], ["exec", NO_DECLARED_OPTIONS],
17
20
  ["noglob", NO_DECLARED_OPTIONS], ["xargs", NO_DECLARED_OPTIONS], ["busybox", NO_DECLARED_OPTIONS],
18
- ["sudo", NO_DECLARED_OPTIONS], ["doas", NO_DECLARED_OPTIONS], ["pkexec", NO_DECLARED_OPTIONS], ["su", NO_DECLARED_OPTIONS],
19
- ["runuser", NO_DECLARED_OPTIONS], ["chroot", NO_DECLARED_OPTIONS], ["setpriv", NO_DECLARED_OPTIONS],
20
- ["nohup", NO_DECLARED_OPTIONS], ["nice", NO_DECLARED_OPTIONS], ["ionice", NO_DECLARED_OPTIONS], ["chrt", NO_DECLARED_OPTIONS],
21
- ["taskset", NO_DECLARED_OPTIONS], ["stdbuf", NO_DECLARED_OPTIONS], ["timeout", NO_DECLARED_OPTIONS],
21
+ ["sudo", { optionArity: new Map(), assignmentOperands: true }], ["doas", NO_DECLARED_OPTIONS], ["pkexec", { optionArity: new Map(), whenNoProgram: "shell" }],
22
+ ["su", { optionArity: new Map(), shellText: true }], ["runuser", { optionArity: new Map(), shellText: true }],
23
+ ["chroot", { optionArity: new Map(), requiredOperands: 1, whenNoProgram: "shell" }], ["setpriv", NO_DECLARED_OPTIONS],
24
+ ["nohup", NO_DECLARED_OPTIONS],
25
+ ["nice", launcherRow([["-n", 1], ["--adjustment", 1]], { negativeIntegerIsOwnArgument: true })],
26
+ ["ionice", NO_DECLARED_OPTIONS], ["chrt", { optionArity: new Map(), requiredOperands: 1 }], ["taskset", { optionArity: new Map(), requiredOperands: 1 }],
27
+ ["stdbuf", launcherRow([["-i", 1], ["-o", 1], ["-e", 1], ["--input", 1], ["--output", 1], ["--error", 1]])],
28
+ ["timeout", launcherRow([
29
+ ["--foreground", 0], ["--preserve-status", 0], ["--verbose", 0], ["-v", 0],
30
+ ["-k", 1], ["-s", 1], ["--kill-after", 1], ["--signal", 1],
31
+ ], { requiredOperands: 1 })],
22
32
  ["time", {
23
33
  optionArity: new Map([
24
34
  ["-p", 0], ["-a", 0], ["-v", 0], ["-q", 0], ["--portability", 0], ["--append", 0], ["--verbose", 0], ["--quiet", 0], ["--help", 0], ["--version", 0],
25
35
  ["-f", 1], ["-o", 1], ["--format", 1], ["--output", 1],
26
36
  ]),
27
37
  }],
28
- ["watch", NO_DECLARED_OPTIONS], ["setsid", NO_DECLARED_OPTIONS], ["flock", NO_DECLARED_OPTIONS], ["unshare", NO_DECLARED_OPTIONS],
29
- ["nsenter", NO_DECLARED_OPTIONS], ["script", NO_DECLARED_OPTIONS], ["numactl", NO_DECLARED_OPTIONS], ["prlimit", NO_DECLARED_OPTIONS],
38
+ ["watch", { optionArity: new Map(), shellText: true }], ["setsid", NO_DECLARED_OPTIONS], ["flock", { optionArity: new Map(), requiredOperands: 1 }], ["unshare", { optionArity: new Map(), whenNoProgram: "shell" }],
39
+ ["nsenter", { optionArity: new Map(), whenNoProgram: "shell" }], ["script", { optionArity: new Map(), shellText: true }], ["numactl", NO_DECLARED_OPTIONS], ["prlimit", NO_DECLARED_OPTIONS],
30
40
  ["systemd-run", NO_DECLARED_OPTIONS],
31
- ["strace", NO_DECLARED_OPTIONS], ["ltrace", NO_DECLARED_OPTIONS], ["valgrind", NO_DECLARED_OPTIONS], ["firejail", NO_DECLARED_OPTIONS],
41
+ ["strace", NO_DECLARED_OPTIONS], ["ltrace", NO_DECLARED_OPTIONS], ["valgrind", NO_DECLARED_OPTIONS], ["firejail", { optionArity: new Map(), whenNoProgram: "shell" }],
32
42
  ["bwrap", NO_DECLARED_OPTIONS],
33
43
  ]);
34
44
  export const COMMAND_LAUNCHERS = new Set(LAUNCHER_TABLE.keys());
@@ -38,15 +48,33 @@ function pastLauncherOptions(launcher, folded, i) {
38
48
  return i;
39
49
  for (;;) {
40
50
  const tok = folded[i];
41
- const arity = tok === undefined ? undefined : row.optionArity.get(tok);
42
- if (arity === undefined)
51
+ if (tok === undefined)
43
52
  return i;
44
- if (arity === 1 && folded[i + 1] === undefined)
53
+ if (row.negativeIntegerIsOwnArgument === true && /^-\d+$/.test(tok)) {
54
+ i += 1;
55
+ continue;
56
+ }
57
+ const step = launcherOptionStep(row, tok);
58
+ if (step === undefined)
59
+ return i;
60
+ if (step === 2 && folded[i + 1] === undefined)
45
61
  return i;
46
- i += arity + 1;
62
+ i += step;
47
63
  }
48
64
  }
49
- const LAUNCHER_BARE_NUMBER = /^\d+[smhd]?$/;
65
+ function launcherOptionStep(row, tok) {
66
+ const exact = row.optionArity.get(tok);
67
+ if (exact !== undefined)
68
+ return exact === 1 ? 2 : 1;
69
+ const eq = tok.startsWith("--") ? tok.indexOf("=") : -1;
70
+ if (eq > 2 && row.optionArity.get(tok.slice(0, eq)) === 1)
71
+ return 1;
72
+ if (/^-[A-Za-z]./.test(tok) && row.optionArity.get(tok.slice(0, 2)) === 1)
73
+ return 1;
74
+ if (/^-[A-Za-z]+$/.test(tok) && [...tok.slice(1)].every((ch) => row.optionArity.get(`-${ch}`) === 0))
75
+ return 1;
76
+ return undefined;
77
+ }
50
78
  export const PROGRAM_POSITION_REFUSAL_KINDS = ["option_at_program_position", "control_syntax_at_program_position"];
51
79
  export function readProgramPosition(toks) {
52
80
  const { folded } = toks;
@@ -79,21 +107,38 @@ export function readProgramPosition(toks) {
79
107
  }
80
108
  const launchers = [{ index: i, name: basename }];
81
109
  const owner = head;
110
+ const shellRefusal = () => ({ kind: "control_syntax_at_program_position", owner, word: "sh" });
111
+ let owedOperands = LAUNCHER_TABLE.get(basename)?.requiredOperands ?? 0;
112
+ let lastRow = LAUNCHER_TABLE.get(basename);
113
+ const chain0 = (program, refusal) => ({ controlWords, written, assignments, launchers, argvFromStdin: false, runsDefaultProgram: false, program, refusal });
114
+ if (lastRow?.shellText === true)
115
+ return chain0(undefined, shellRefusal());
82
116
  let argvFromStdin = ARGV_REENTRY_PROGRAM_RE.test(basename);
83
117
  let lastReentry = argvFromStdin ? i : -1;
84
118
  const chain = (program, refusal) => ({ controlWords, written, assignments, launchers, argvFromStdin, runsDefaultProgram: lastReentry === folded.length - 1, program, refusal });
85
119
  for (let j = pastLauncherOptions(basename, folded, i + 1); j < folded.length; j++) {
86
120
  const t = folded[j];
87
- if (ASSIGNMENT_WORD.test(t) || LAUNCHER_BARE_NUMBER.test(t))
121
+ if (owedOperands > 0 && !t.startsWith("-")) {
122
+ owedOperands -= 1;
123
+ continue;
124
+ }
125
+ if (lastRow?.assignmentOperands === true && !t.startsWith("-") && t.includes("="))
88
126
  continue;
89
127
  const wordBasename = programBasename(t);
90
128
  if (ARGV_REENTRY_PROGRAM_RE.test(wordBasename)) {
91
129
  argvFromStdin = true;
92
130
  lastReentry = j;
131
+ lastRow = undefined;
132
+ owedOperands = 0;
93
133
  continue;
94
134
  }
95
135
  if (COMMAND_LAUNCHERS.has(wordBasename)) {
96
136
  launchers.push({ index: j, name: wordBasename });
137
+ lastRow = LAUNCHER_TABLE.get(wordBasename);
138
+ if (lastRow?.shellText === true)
139
+ return chain(undefined, shellRefusal());
140
+ owedOperands = lastRow?.requiredOperands ?? 0;
141
+ j = pastLauncherOptions(wordBasename, folded, j + 1) - 1;
97
142
  continue;
98
143
  }
99
144
  if (t.startsWith("-"))
@@ -103,13 +148,15 @@ export function readProgramPosition(toks) {
103
148
  }
104
149
  return chain({ index: j, name: t, basename: wordBasename });
105
150
  }
151
+ if (lastRow?.whenNoProgram === "shell")
152
+ return chain(undefined, shellRefusal());
106
153
  return chain(undefined);
107
154
  }
108
- export function argvReentryUnjudged(pos, noPathPrograms) {
155
+ export function argvReentryUnjudged(pos, acceptedTargets) {
109
156
  if (!pos.argvFromStdin)
110
157
  return false;
111
158
  const runs = pos.program?.basename ?? (pos.runsDefaultProgram ? ARGV_REENTRY_DEFAULT_PROGRAM : undefined);
112
- return runs === undefined || !noPathPrograms.has(runs);
159
+ return runs === undefined || !acceptedTargets.has(runs);
113
160
  }
114
161
  export function programPositionRefusalSentence(refusal) {
115
162
  switch (refusal.kind) {
@@ -293,6 +293,15 @@ export interface BashReadonlyRootBoundary {
293
293
  * roots containment + command allowlist double gate is the deployment's own read-safety
294
294
  * declaration — v1 ruling). */
295
295
  denyMatch?: (resolvedPath: string) => string | null;
296
+ /** What the wired {@link denyMatch} judge would apply — the ACTIVE deny patterns. Read for ONE question:
297
+ * is there anything for a traversal to defeat? EMPTY ⇒ the judge refuses nothing, so a walk under it
298
+ * bypasses nothing and {@link CompoundReadonlyVerdict.recursiveReadPaths} is not minted. ABSENT ⇒ the
299
+ * caller did not say, which is not "none": the walk is reported, the reading every caller had before
300
+ * this seat existed. It is deliberately NOT the reference implementation's question at the same place —
301
+ * its deny rules are ROOTED, so "is the guarded place inside this traversal?" is lexically answerable
302
+ * there; these patterns match a segment run ANYWHERE in a path ({@link
303
+ * import("./read-deny.js").ReadDenyEntry}), so only emptiness is. */
304
+ denyEntries?: readonly string[];
296
305
  /** The resolved read-face containment state. Under "open" the CONTAINMENT half of
297
306
  * this boundary is structurally satisfied (an out-of-roots operand is not a demotion; it reports
298
307
  * as a checked candidate instead), while the deny half above keeps judging in both faces.
@@ -425,20 +434,25 @@ export interface CompoundReadonlyVerdict {
425
434
  */
426
435
  recursiveReadPaths?: readonly string[];
427
436
  /**
428
- * The ONE directory whose addition to the declared roots would make every objection this walk raised go
429
- * away. It rides the SAME arm as the containment sentence and is dropped wherever that sentence is (a
430
- * SHAPE refusal takes both — `tail -f <out-of-root>` stays refused after any grant), and on that arm the
431
- * claim is MEASURED, not argued: the walk runs again with the proposed root in place and the directory is
432
- * offered only if that walk has no objection left. `..`-traversing spellings are excluded on their own
433
- * account ({@link outOfRootSpellings}): that is a disagreement between the folded text and the kernel,
434
- * which no replay of the text can settle. VALUE: the deepest directory PROPERLY
435
- * containing every {@link outOfRootPaths} entry, folded lexically — zero I/O, so a path that is itself a
436
- * directory contributes its PARENT (`ls /etc/ssl` ⇒ `/etc`) where the reference implementation narrows
437
- * with a stat; refused rather than widened when the result would be a family root or would contain the
438
- * declared home or a declared {@link BashReadonlyRootBoundary.roots} entry. Meaning and spelling
439
- * contract: {@link import("../../core/tool-spec.js").ReadRootGrantCandidate}.
437
+ * The ONE path whose addition to the declared roots would make every objection THIS WALK raised go away,
438
+ * MEASURED not argued: the walk runs again with the proposed root in place and the value is offered only
439
+ * if that walk has no objection left (`..` spellings are excluded on their own account — a folded-vs-kernel
440
+ * disagreement no replay of the text settles). It rides the containment sentence, and a per-segment NAME
441
+ * refusal ({@link segmentNameRefusal}), which is not a refusal of the READ; a SHAPE refusal takes it with
442
+ * it. VALUE: the deepest directory PROPERLY containing every {@link outOfRootPaths} entry, folded (zero
443
+ * I/O, so a directory contributes its PARENT); where that would contain the declared home or a root, the
444
+ * single path read is offered VERBATIM instead ({@link outOfRootGrantCoversExactPath}) — `cat
445
+ * ~/.gitconfig` is one file and the alternative is a card with no way out. Contract:
446
+ * {@link import("../../core/tool-spec.js").ReadRootGrantCandidate}.
440
447
  */
441
448
  outOfRootGrantCandidate?: string;
449
+ /** {@link outOfRootGrantCandidate} is the path the command NAMED, offered verbatim, rather than a
450
+ * directory containing it — minted where that directory would have handed out the home or the workspace.
451
+ * A consumer adds the string ITSELF; expanding it into a directory pattern is what this prevents. */
452
+ outOfRootGrantCoversExactPath?: true;
453
+ /** The verdict's {@link reason} is a per-segment NAME refusal (an unlisted program, a path-prefixed name,
454
+ * a control word before a reader) rather than the walk's own sentence or a refused SHAPE. */
455
+ segmentNameRefusal?: true;
442
456
  }
443
457
  /**
444
458
  * The single minting point for the out-of-root-read approval option text, so a gate rendering
@@ -745,11 +759,7 @@ export declare const POLL_LOOP_MAX_SLEEP_SECONDS = 600;
745
759
  * run. (The substitution is a flagged, high-severity shape kept per the ruling that names
746
760
  * `$(seq <int> <int>)` as the one exemption — recorded for a
747
761
  * tightening decision, since the brace form is a drop-in equivalent.)
748
- * · STDIN-BLOCKING BODY (an availability residual, not a safety one). CLOSED for the shape this note
749
- * used to record: a quoted multiword operand like `grep "x y"` read, to the then whitespace-blind
750
- * tokenizer, as two operands meeting grep's file floor, while bash passes ONE pattern and no file and
751
- * grep blocks on stdin. the lexer now counts the operand bash actually passes, so
752
- * that command demotes on both this arm and the plain face. What REMAINS residual is the general
762
+ * · STDIN-BLOCKING BODY (an availability residual, not a safety one). What REMAINS residual is the general
753
763
  * statement: this arm inherits whatever the shared availability arms can see, they are best-effort
754
764
  * (an operand that only turns out to be unreadable at run time is not in the text), and a hang is
755
765
  * bounded by the tool timeout — outside the irreversibility / egress envelope this arm governs. The
@@ -173,18 +173,18 @@ function outOfRootGrantCandidateOf(paths, boundary) {
173
173
  }
174
174
  if (candidate === undefined)
175
175
  return undefined;
176
- if (parentDirLexically(candidate) === candidate)
177
- return undefined;
178
- if (boundary.homeDir !== undefined && withinAnyRoot([candidate], boundary.homeDir))
179
- return undefined;
180
- if (boundary.roots.some((root) => withinAnyRoot([candidate], root)))
181
- return undefined;
182
- return candidate;
176
+ const tooWide = parentDirLexically(candidate) === candidate ||
177
+ (boundary.homeDir !== undefined && withinAnyRoot([candidate], boundary.homeDir)) ||
178
+ boundary.roots.some((root) => withinAnyRoot([candidate], root));
179
+ if (tooWide)
180
+ return paths.length === 1 ? { path: paths[0], exact: true } : undefined;
181
+ return { path: candidate };
183
182
  }
184
183
  const NO_PATH_OPERAND_COMMANDS = new Set([
185
- "pwd", "echo", "whoami", "uname", "which", "tr", "basename", "dirname",
184
+ "pwd", "echo", "printf", "whoami", "uname", "which", "tr", "basename", "dirname",
186
185
  "cal", "uptime", "id", "groups", "nproc", "locale", "free", "true", "false", "seq", "expr", "type", "sleep",
187
186
  ]);
187
+ const XARGS_TARGETS_ACCEPTED_UNJUDGED = NO_PATH_OPERAND_COMMANDS;
188
188
  function isPathShapedToken(tok) {
189
189
  return tok.includes("/") || tok.startsWith("~") || tok === "." || tok === "..";
190
190
  }
@@ -322,6 +322,9 @@ function refusedCommandExempt(command, allow, boundary, depth, baseMovedOutside
322
322
  if (!assignmentJudgedInside(words.folded[k]))
323
323
  return false;
324
324
  }
325
+ const argv = argvProgramOf(words);
326
+ const argvStart = argv?.from ?? words.folded.length;
327
+ const declaredNotPaths = ASSIGNMENT_VALUE_NOT_PATHS.has(argv?.program ?? "");
325
328
  let endOfOptions = false;
326
329
  for (let j = from; j < words.folded.length; j++) {
327
330
  const word = words.folded[j];
@@ -340,7 +343,7 @@ function refusedCommandExempt(command, allow, boundary, depth, baseMovedOutside
340
343
  if (!stdinFileJudgedInside(candidate))
341
344
  return false;
342
345
  }
343
- if (!optionShaped && !assignmentJudgedInside(word))
346
+ if (!optionShaped && (j < argvStart || !declaredNotPaths) && !assignmentJudgedInside(word))
344
347
  return false;
345
348
  }
346
349
  return true;
@@ -362,7 +365,7 @@ function refusedCommandExempt(command, allow, boundary, depth, baseMovedOutside
362
365
  if (program === undefined) {
363
366
  if (pos.launchers.length === 0)
364
367
  continue;
365
- if (argvReentryUnjudged(pos, NO_PATH_OPERAND_COMMANDS))
368
+ if (argvReentryUnjudged(pos, XARGS_TARGETS_ACCEPTED_UNJUDGED))
366
369
  return false;
367
370
  if (!unlistedSegmentJudgedInside(words, pos.launchers[0].index, pos.assignments))
368
371
  return false;
@@ -370,12 +373,10 @@ function refusedCommandExempt(command, allow, boundary, depth, baseMovedOutside
370
373
  }
371
374
  if (program.name.includes(SUBSTITUTION_PLACEHOLDER) || /[$`]/.test(program.name))
372
375
  return false;
373
- if (argumentAsProgramRefusal(program.basename, program.basename, words.folded.slice(program.index + 1)) !== undefined)
374
- return false;
375
376
  if (SHELL_REENTRY_PROGRAM_RE.test(program.basename))
376
377
  return false;
377
378
  if (pos.launchers.length > 0) {
378
- if (argvReentryUnjudged(pos, NO_PATH_OPERAND_COMMANDS))
379
+ if (argvReentryUnjudged(pos, XARGS_TARGETS_ACCEPTED_UNJUDGED))
379
380
  return false;
380
381
  if (!listed(program.basename)) {
381
382
  if (!unlistedSegmentJudgedInside(words, pos.launchers[0].index, pos.assignments))
@@ -609,6 +610,32 @@ const VERB_OPTION_SLOTS = {
609
610
  "verbose", "verify", "version",
610
611
  ],
611
612
  },
613
+ find: {
614
+ shortValue: { D: "label", O: "count", f: "path" },
615
+ shortPlain: "HLPdsx",
616
+ longValue: {},
617
+ longOptionalValue: {},
618
+ longPlain: ["help", "version"],
619
+ dashWordValue: {
620
+ amin: "count", anewer: "path", atime: "count", cmin: "count", cnewer: "path", context: "label",
621
+ ctime: "count", fstype: "label", gid: "count", group: "label", ilname: "path", iname: "name-pattern",
622
+ inum: "count", ipath: "path", iregex: "name-pattern", iwholename: "path", links: "count",
623
+ lname: "path", maxdepth: "count", mindepth: "count", mmin: "count", mtime: "count", name: "name-pattern",
624
+ newer: "path", newerat: "path", neweraa: "path", neweram: "path", newerac: "path",
625
+ newerct: "path", newerca: "path", newercm: "path", newercc: "path",
626
+ newermt: "path", newerma: "path", newermm: "path", newermc: "path",
627
+ newerbt: "path", newerba: "path", newerbm: "path", newerbc: "path",
628
+ path: "path", perm: "label", printf: "label", regex: "name-pattern", regextype: "label",
629
+ samefile: "path", size: "count", type: "label", uid: "count", used: "count", user: "label",
630
+ wholename: "path", xtype: "label",
631
+ },
632
+ dashWordPlain: [
633
+ "and", "daystart", "depth", "empty", "executable", "false", "follow", "help", "ignore_readdir_race",
634
+ "mount", "noignore_readdir_race", "noleaf", "not", "nogroup", "nouser", "or", "print",
635
+ "print0", "prune", "quit", "readable", "true", "version", "writable", "xdev",
636
+ ],
637
+ optionsArePositionIndependent: true,
638
+ },
612
639
  curl: {
613
640
  shortValue: {
614
641
  A: "label", b: "indirect", C: "count", c: "path", D: "path", d: "indirect", E: "path", e: "label",
@@ -699,6 +726,17 @@ function readVerbOptionSlot(name, tok) {
699
726
  return { form: "unexplained" };
700
727
  return withValue([...kinds][0]);
701
728
  }
729
+ const word = row.dashWordValue !== undefined || row.dashWordPlain !== undefined ? /^-([A-Za-z_][\w-]+)$/.exec(tok)?.[1] : undefined;
730
+ if (word !== undefined) {
731
+ if (row.shortValue[word[0]] !== undefined)
732
+ return { form: "unexplained" };
733
+ const kind = row.dashWordValue?.[word];
734
+ if (kind !== undefined)
735
+ return { form: "separated", kind };
736
+ if (row.dashWordPlain?.includes(word) === true)
737
+ return { form: "no-value" };
738
+ return { form: "unexplained" };
739
+ }
702
740
  for (let i = 1; i < tok.length; i++) {
703
741
  const ch = tok[i];
704
742
  const kind = row.shortValue[ch];
@@ -711,6 +749,9 @@ function readVerbOptionSlot(name, tok) {
711
749
  }
712
750
  return { form: "no-value" };
713
751
  }
752
+ function verbOptionsArePositionIndependent(name) {
753
+ return VERB_OPTION_SLOTS[name]?.optionsArePositionIndependent === true;
754
+ }
714
755
  function localFileUrlPath(value) {
715
756
  const m = /^file:(?:\/\/[^/?#]*)?(\/[^?#]*)/i.exec(value.trim());
716
757
  if (m === null)
@@ -800,23 +841,27 @@ function takesSeparatedValue(name, tok) {
800
841
  }
801
842
  return false;
802
843
  }
844
+ const GREP_RECURSIVE_READ_FORM = {
845
+ discloses: "contents",
846
+ shortLetters: "rR",
847
+ valueOwners: "efmABCD",
848
+ longNames: ["recursive", "dereference-recursive"],
849
+ enumOptions: [{ shortLetter: "d", longName: "directories", recursiveValue: "recurse" }],
850
+ dashIsStdin: true,
851
+ };
803
852
  const RECURSIVE_READ_FORMS = {
804
- grep: {
805
- shortLetters: "rR",
806
- valueOwners: "efmABCD",
807
- longNames: ["recursive", "dereference-recursive"],
808
- enumOptions: [{ shortLetter: "d", longName: "directories", recursiveValue: "recurse" }],
809
- dashIsStdin: true,
810
- },
811
- ls: { shortLetters: "R", longNames: ["recursive"] },
812
- du: { always: true },
813
- find: { always: true },
814
- rg: { always: true, dashIsStdin: true },
815
- tree: { always: true },
816
- ag: { always: true, dashIsStdin: true },
817
- ack: { always: true, dashIsStdin: true },
818
- tar: { shortLetters: "cru", valueOwners: "fCTXbg", longNames: ["create", "append", "update"], bundledModeLetters: "cru", dashIsStdin: true },
819
- diff: { shortLetters: "r", valueOwners: "UCWISFXx", longNames: ["recursive"], dashIsStdin: true },
853
+ grep: GREP_RECURSIVE_READ_FORM,
854
+ egrep: GREP_RECURSIVE_READ_FORM,
855
+ fgrep: GREP_RECURSIVE_READ_FORM,
856
+ ls: { discloses: "names", shortLetters: "R", longNames: ["recursive"] },
857
+ du: { discloses: "names", always: true },
858
+ find: { discloses: "names", always: true },
859
+ rg: { discloses: "contents", always: true, dashIsStdin: true },
860
+ tree: { discloses: "names", always: true },
861
+ ag: { discloses: "contents", always: true, dashIsStdin: true },
862
+ ack: { discloses: "contents", always: true, dashIsStdin: true },
863
+ tar: { discloses: "contents", shortLetters: "cru", valueOwners: "fCTXbg", longNames: ["create", "append", "update"], bundledModeLetters: "cru", dashIsStdin: true },
864
+ diff: { discloses: "contents", shortLetters: "r", valueOwners: "UCWISFXx", longNames: ["recursive"], dashIsStdin: true },
820
865
  };
821
866
  const FIND_DANGEROUS_PREDICATES = new Set([
822
867
  "-delete", "-exec", "-execdir", "-ok", "-okdir", "-fprint", "-fprint0", "-fls", "-fprintf", "-files0-from",
@@ -1458,6 +1503,8 @@ function segmentSelectsRecursiveRead(name, args) {
1458
1503
  const model = RECURSIVE_READ_FORMS[name];
1459
1504
  if (model === undefined)
1460
1505
  return false;
1506
+ if (model.discloses !== "contents")
1507
+ return false;
1461
1508
  if (model.always === true)
1462
1509
  return true;
1463
1510
  if (model.bundledModeLetters !== undefined) {
@@ -1540,6 +1587,17 @@ function assignmentWordPayload(word) {
1540
1587
  return { kind: "unresolved", reason: `"${word}" carries a tilde whose expansion the text does not settle` };
1541
1588
  return value === "" ? undefined : { kind: "value", value };
1542
1589
  }
1590
+ const ASSIGNMENT_VALUE_NOT_PATHS = new Set(["make", "gmake", "bmake", "cmake", "ninja", "meson", "docker", "podman"]);
1591
+ function argvProgramOf(tokens) {
1592
+ let lead = 0;
1593
+ while (lead < tokens.folded.length && ASSIGNMENT_OR_APPEND_WORD.test(tokens.folded[lead]))
1594
+ lead++;
1595
+ const pos = readProgramPosition({ folded: tokens.folded.slice(lead), raw: tokens.raw.slice(lead) });
1596
+ if (pos.program === undefined)
1597
+ return undefined;
1598
+ const at = lead + pos.program.index;
1599
+ return ASSIGNMENT_OR_APPEND_WORD.test(tokens.folded[at] ?? "") ? undefined : { program: pos.program.basename, from: at };
1600
+ }
1543
1601
  function attachedOptionPayloads(tok) {
1544
1602
  const literal = literalTildeSpelling;
1545
1603
  const out = [];
@@ -1564,9 +1622,12 @@ function attachedOptionPayloads(tok) {
1564
1622
  function collectSegmentBoundaryFindings(tokens, boundary) {
1565
1623
  const name = tokens.folded[0];
1566
1624
  const slotVerb = tokens.slotVerb ?? name;
1625
+ const declaredNotPaths = ASSIGNMENT_VALUE_NOT_PATHS.has(tokens.argvProgram ?? slotVerb);
1626
+ const argvFrom = tokens.argvFrom ?? (tokens.slotVerb === undefined ? 1 : Number.MAX_SAFE_INTEGER);
1567
1627
  if (NO_PATH_OPERAND_COMMANDS.has(name))
1568
1628
  return [];
1569
- const args = tokens.folded.slice(1);
1629
+ const declaresNoPathOperands = tokens.slotVerb !== undefined && NO_PATH_OPERAND_COMMANDS.has(tokens.slotVerb) && tokens.argvFrom !== undefined;
1630
+ const args = declaresNoPathOperands ? tokens.folded.slice(1, tokens.argvFrom + 1) : tokens.folded.slice(1);
1570
1631
  const findings = [];
1571
1632
  const candidates = [];
1572
1633
  const bareWordOperands = [];
@@ -1574,6 +1635,7 @@ function collectSegmentBoundaryFindings(tokens, boundary) {
1574
1635
  (isPathShapedToken(text) || isAbsolutePathForm(text) ? candidates : bareWordOperands).push({ text, globbed });
1575
1636
  };
1576
1637
  const positionalOperands = [];
1638
+ const argvOperandsStartAt = tokens.slotVerb !== undefined ? 1 : 0;
1577
1639
  let stdinOperand = false;
1578
1640
  const argGlobs = (k) => {
1579
1641
  const raw = tokens.raw[k + 1];
@@ -1610,7 +1672,6 @@ function collectSegmentBoundaryFindings(tokens, boundary) {
1610
1672
  const eoo = args.indexOf("--");
1611
1673
  const patternSuppliedByFlag = name === "grep" && (eoo === -1 ? args : args.slice(0, eoo)).some(isGrepPatternFlagToken);
1612
1674
  let sawOperand = false;
1613
- const argvOperandsStartAt = tokens.slotVerb !== undefined ? 1 : 0;
1614
1675
  let sawArgvOperand = false;
1615
1676
  let endOfOptions = false;
1616
1677
  for (let k = 0; k < args.length; k++) {
@@ -1624,7 +1685,8 @@ function collectSegmentBoundaryFindings(tokens, boundary) {
1624
1685
  }
1625
1686
  if (t.startsWith("-") && t !== "-") {
1626
1687
  const slot = readVerbOptionSlot(slotVerb, t);
1627
- if (sawArgvOperand) {
1688
+ const permutes = !verbOptionsArePositionIndependent(slotVerb);
1689
+ if (sawArgvOperand && permutes) {
1628
1690
  judgeOperand(t, argGlobs(k));
1629
1691
  if (slot !== undefined && slot.form === "separated") {
1630
1692
  const ahead = args[k + 1];
@@ -1634,7 +1696,7 @@ function collectSegmentBoundaryFindings(tokens, boundary) {
1634
1696
  }
1635
1697
  }
1636
1698
  }
1637
- if (slot !== undefined && slot.form !== "unexplained" && !(slot.form === "separated" && sawArgvOperand)) {
1699
+ if (slot !== undefined && slot.form !== "unexplained" && !(slot.form === "separated" && sawArgvOperand && permutes)) {
1638
1700
  if (slot.form === "attached") {
1639
1701
  for (const c of slotValueCandidates(literalTildeSpelling(slot.value), slot.kind))
1640
1702
  judgeOperand(c, argGlobs(k));
@@ -1663,23 +1725,27 @@ function collectSegmentBoundaryFindings(tokens, boundary) {
1663
1725
  if (t === "-") {
1664
1726
  if (isGrepPatternSlot)
1665
1727
  continue;
1666
- const m = RECURSIVE_READ_FORMS[name];
1728
+ const m = RECURSIVE_READ_FORMS[slotVerb];
1667
1729
  if (m === undefined || m.dashIsStdin === true) {
1668
1730
  stdinOperand = true;
1669
1731
  continue;
1670
1732
  }
1671
- positionalOperands.push(t);
1733
+ if (k >= argvOperandsStartAt)
1734
+ positionalOperands.push(t);
1672
1735
  continue;
1673
1736
  }
1674
1737
  if (isGrepPatternSlot)
1675
1738
  continue;
1676
- positionalOperands.push(t);
1739
+ if (k >= argvOperandsStartAt)
1740
+ positionalOperands.push(t);
1677
1741
  judgeOperand(t, argGlobs(k));
1678
1742
  const payload = assignmentWordPayload(t);
1679
- if (payload?.kind === "value")
1680
- judgeOperand(payload.value, argGlobs(k));
1681
- else if (payload?.kind === "unresolved")
1682
- findings.push({ kind: "unresolvable", operand: t, reason: `"${name}": ${payload.reason} ${NOT_AUTO_ALLOWED}` });
1743
+ if (payload !== undefined && (k + 1 < argvFrom || !declaredNotPaths)) {
1744
+ if (payload.kind === "value")
1745
+ judgeOperand(payload.value, argGlobs(k));
1746
+ else
1747
+ findings.push({ kind: "unresolvable", operand: t, reason: `"${name}": ${payload.reason} ${NOT_AUTO_ALLOWED}` });
1748
+ }
1683
1749
  }
1684
1750
  }
1685
1751
  for (let k = 0; k < args.length; k++) {
@@ -1713,7 +1779,7 @@ function collectSegmentBoundaryFindings(tokens, boundary) {
1713
1779
  if (!globbed && withinAnyRoot(boundary.roots, spellings.folded))
1714
1780
  findings.push({ kind: "inside", path: spellings.folded, ...(spellings.spelled !== undefined ? { spelled: spellings.spelled } : {}) });
1715
1781
  }
1716
- if (boundary.denyMatch !== undefined && segmentSelectsRecursiveRead(name, args)) {
1782
+ if (boundary.denyMatch !== undefined && (boundary.denyEntries?.length ?? 1) > 0 && segmentSelectsRecursiveRead(slotVerb, args.slice(argvOperandsStartAt))) {
1717
1783
  const roots = positionalOperands.length > 0 ? positionalOperands : stdinOperand ? [] : ["."];
1718
1784
  for (const operand of roots) {
1719
1785
  const resolved = resolveOperandLexically(boundary.cwd ?? boundary.roots[0], operand, boundary.homeDir);
@@ -1803,6 +1869,12 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary, opts)
1803
1869
  nameRefusal ??= `command "${written}" is not in the read-only allowlist`;
1804
1870
  continue;
1805
1871
  }
1872
+ const program = pos.program;
1873
+ if (program !== undefined) {
1874
+ const familyRefusal = argumentAsProgramRefusal(program.basename, program.basename, folded.slice(program.index + 1));
1875
+ if (familyRefusal !== undefined)
1876
+ nameRefusal ??= familyRefusal;
1877
+ }
1806
1878
  const parsed = parseLeadingCommandName(toks.raw.slice(from).join(" "), { quotedOperatorsAreText: true, pathPrefixedNameIsText: true });
1807
1879
  if ("reject" in parsed)
1808
1880
  return { reason: parsed.reject, refusedWhole: true };
@@ -1812,12 +1884,6 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary, opts)
1812
1884
  nameRefusal ??= "the command must be a bare name resolved via PATH (no path prefix)";
1813
1885
  if (pos.refusal !== undefined)
1814
1886
  return { reason: programPositionRefusalSentence(pos.refusal), refusedWhole: true };
1815
- const program = pos.program;
1816
- if (program !== undefined) {
1817
- const familyRefusal = argumentAsProgramRefusal(program.basename, program.basename, folded.slice(program.index + 1));
1818
- if (familyRefusal !== undefined)
1819
- return { reason: familyRefusal, refusedWhole: true };
1820
- }
1821
1887
  if (allow.has(name)) {
1822
1888
  if (pathPrefixed || from > 0)
1823
1889
  reheaded.set(si, { from, head: name });
@@ -1833,7 +1899,7 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary, opts)
1833
1899
  if (program !== undefined && !listed && SHELL_REENTRY_PROGRAM_RE.test(program.basename)) {
1834
1900
  return { reason: `"${written}" runs "${program.basename}", which runs its argument as shell text this walk does not judge ${NOT_AUTO_ALLOWED}`, refusedWhole: true };
1835
1901
  }
1836
- if (argvReentryUnjudged(pos, NO_PATH_OPERAND_COMMANDS)) {
1902
+ if (argvReentryUnjudged(pos, XARGS_TARGETS_ACCEPTED_UNJUDGED)) {
1837
1903
  return { reason: `"${written}" takes the operands of the program it runs from its standard input, which this walk does not judge ${NOT_AUTO_ALLOWED}`, refusedWhole: true };
1838
1904
  }
1839
1905
  if (!listed || program === undefined)
@@ -1895,7 +1961,13 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary, opts)
1895
1961
  const unlisted = tokenizeSegment(segments[si]);
1896
1962
  if (unlisted.folded.length === 0)
1897
1963
  continue;
1898
- foldedSegments.push({ folded: [LAUNCHER_SCAN_HEAD, ...unlisted.folded], raw: [LAUNCHER_SCAN_HEAD, ...unlisted.raw], slotVerb: programBasename(unlisted.folded[0]) });
1964
+ const unlistedArgv = argvProgramOf(unlisted);
1965
+ foldedSegments.push({
1966
+ folded: [LAUNCHER_SCAN_HEAD, ...unlisted.folded],
1967
+ raw: [LAUNCHER_SCAN_HEAD, ...unlisted.raw],
1968
+ slotVerb: programBasename(unlisted.folded[0]),
1969
+ ...(unlistedArgv !== undefined ? { argvProgram: unlistedArgv.program, argvFrom: unlistedArgv.from + 1 } : {}),
1970
+ });
1899
1971
  pushReach();
1900
1972
  continue;
1901
1973
  }
@@ -1989,8 +2061,14 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary, opts)
1989
2061
  const walk = evaluateReadBoundary(foldedSegments, boundary, true, foldedReach);
1990
2062
  if (shapeRefusal === undefined)
1991
2063
  return walk;
1992
- const { reason: _walkSentence, outOfRootGrantCandidate: _walkGrantCandidate, ...structure } = walk;
1993
- return { ...structure, reason: shapeRefusal };
2064
+ const { reason: _walkSentence, outOfRootGrantCandidate: walkGrantCandidate, outOfRootGrantCoversExactPath: walkGrantExact, ...structure } = walk;
2065
+ const nameOnly = shapeRefusal === nameRefusal;
2066
+ return {
2067
+ ...structure,
2068
+ reason: shapeRefusal,
2069
+ ...(nameOnly && walkGrantCandidate !== undefined ? { outOfRootGrantCandidate: walkGrantCandidate, ...(walkGrantExact === true ? { outOfRootGrantCoversExactPath: true } : {}) } : {}),
2070
+ ...(nameOnly ? { segmentNameRefusal: true } : {}),
2071
+ };
1994
2072
  }
1995
2073
  if (shapeRefusal !== undefined)
1996
2074
  return { reason: shapeRefusal };
@@ -2164,14 +2242,14 @@ function evaluateReadBoundary(foldedSegments, boundary, mintGrantCandidate, reac
2164
2242
  const proposed = outOfRootGrantCandidateOf(outside.map((o) => o.path), boundary);
2165
2243
  if (proposed === undefined)
2166
2244
  return undefined;
2167
- const replay = evaluateReadBoundary(foldedSegments, { ...boundary, roots: [...boundary.roots, proposed] }, false, reach);
2245
+ const replay = evaluateReadBoundary(foldedSegments, { ...boundary, roots: [...boundary.roots, proposed.path] }, false, reach);
2168
2246
  return replay.reason === undefined && (replay.undecidedPaths ?? []).length === 0 ? proposed : undefined;
2169
2247
  })();
2170
2248
  return {
2171
2249
  reason: `"${outside[0].command}" reads ${paths}, outside the allowed directories for this session: ${allowed} ${NOT_AUTO_ALLOWED}`,
2172
2250
  outOfRootRead: true,
2173
2251
  outOfRootPaths: outside.map((o) => o.path),
2174
- ...(grantCandidate !== undefined ? { outOfRootGrantCandidate: grantCandidate } : {}),
2252
+ ...(grantCandidate !== undefined ? { outOfRootGrantCandidate: grantCandidate.path, ...(grantCandidate.exact === true ? { outOfRootGrantCoversExactPath: true } : {}) } : {}),
2175
2253
  ...outsideField,
2176
2254
  ...checkedField,
2177
2255
  ...undecidedField,
@@ -2187,7 +2265,7 @@ export function classifySimpleCommandReadBoundary(command, boundary) {
2187
2265
  if (pos.program !== undefined) {
2188
2266
  const familyRefusal = argumentAsProgramRefusal(pos.program.basename, pos.program.basename, toks.folded.slice(pos.program.index + 1));
2189
2267
  if (familyRefusal !== undefined)
2190
- return { reason: familyRefusal, refusedWhole: true };
2268
+ return { reason: familyRefusal };
2191
2269
  }
2192
2270
  return evaluateReadBoundary([toks], boundary, true);
2193
2271
  }
@@ -2240,7 +2318,8 @@ export function classifyOutOfRootReadGate(command, allow, boundary) {
2240
2318
  if (UNMODELLED_BASE_MOVERS.has(name))
2241
2319
  baseMoved = true;
2242
2320
  if (!allow.has(name) && name !== "cd") {
2243
- pushReader({ folded: [LAUNCHER_SCAN_HEAD, ...toks.folded], raw: [LAUNCHER_SCAN_HEAD, ...toks.raw], slotVerb: name });
2321
+ const argv = argvProgramOf(toks);
2322
+ pushReader({ folded: [LAUNCHER_SCAN_HEAD, ...toks.folded], raw: [LAUNCHER_SCAN_HEAD, ...toks.raw], slotVerb: name, ...(argv !== undefined ? { argvProgram: argv.program, argvFrom: argv.from + 1 } : {}) });
2244
2323
  continue;
2245
2324
  }
2246
2325
  pushReader(i === 0 && name === written ? toks : { folded: [name, ...toks.folded.slice(i + 1)], raw: [toks.raw[i] ?? name, ...toks.raw.slice(i + 1)] });