@sema-agent/core 7.24.3 → 7.25.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 (106) hide show
  1. package/CHANGELOG.md +101 -1
  2. package/dist/agents/peer-session-drain.d.ts +16 -1
  3. package/dist/agents/peer-session-drain.js +16 -1
  4. package/dist/agents/send-message-tool.d.ts +10 -2
  5. package/dist/agents/send-message-tool.js +18 -7
  6. package/dist/agents/subagent.js +13 -10
  7. package/dist/core/background-agent-store.d.ts +3 -3
  8. package/dist/core/checkpoint-store.d.ts +1 -0
  9. package/dist/core/delegation-frames.d.ts +5 -0
  10. package/dist/core/gate-fold.js +85 -0
  11. package/dist/core/gate-lanes.js +23 -1
  12. package/dist/core/gate-outcome.d.ts +8 -2
  13. package/dist/core/gate-outcome.js +3 -1
  14. package/dist/core/hooks.d.ts +29 -0
  15. package/dist/core/hooks.js +2 -0
  16. package/dist/core/mailbox-store.d.ts +5 -0
  17. package/dist/core/mailbox-store.js +1 -1
  18. package/dist/core/parent-spec-seat.d.ts +7 -1
  19. package/dist/core/parent-spec-seat.js +2 -0
  20. package/dist/core/permission-rule-consent.js +29 -47
  21. package/dist/core/permission-rule-provider.js +4 -3
  22. package/dist/core/permission-rule-store.d.ts +143 -7
  23. package/dist/core/permission-rule-store.js +165 -15
  24. package/dist/core/permission-rules.js +14 -10
  25. package/dist/core/rule-denial-sentence.d.ts +75 -0
  26. package/dist/core/rule-denial-sentence.js +16 -0
  27. package/dist/core/runner/contracts.d.ts +36 -2
  28. package/dist/core/runner/gate-exit.d.ts +14 -0
  29. package/dist/core/runner/gate-exit.js +4 -0
  30. package/dist/core/runner/prepare-delegation-surface.d.ts +4 -4
  31. package/dist/core/runner/prepare-delegation-surface.js +2 -1
  32. package/dist/core/runner/prepare-gate-stations.d.ts +4 -0
  33. package/dist/core/runner/prepare-gate-stations.js +7 -4
  34. package/dist/core/runner/prepare-hands-readface.d.ts +8 -4
  35. package/dist/core/runner/prepare-hands-readface.js +25 -10
  36. package/dist/core/runner/prepare-inherited-gate.d.ts +23 -0
  37. package/dist/core/runner/prepare-inherited-gate.js +11 -6
  38. package/dist/core/runner/prepare-policy-chain.d.ts +15 -1
  39. package/dist/core/runner/prepare-policy-chain.js +42 -22
  40. package/dist/core/runner/prepare-suspend-saga.d.ts +5 -2
  41. package/dist/core/runner/prepare-suspend-saga.js +4 -3
  42. package/dist/core/runner/prepare-task.js +10 -9
  43. package/dist/core/runner/prepare-wiring-manifest.d.ts +11 -4
  44. package/dist/core/runner/prepare-wiring-manifest.js +8 -4
  45. package/dist/core/runner/resume-apply.js +2 -0
  46. package/dist/core/runner/run-harness-handlers.js +1 -0
  47. package/dist/core/runner/run-identity-wiring.d.ts +2 -2
  48. package/dist/core/runner/run-leg.js +28 -1
  49. package/dist/core/runner/run-notification-lane.d.ts +2 -2
  50. package/dist/core/runner/run-notification-lane.js +2 -1
  51. package/dist/core/runner/run-settle-and-teardown.js +1 -0
  52. package/dist/core/runner/session-rule-policy.d.ts +73 -2
  53. package/dist/core/runner/session-rule-policy.js +121 -21
  54. package/dist/core/session-policy-store.d.ts +27 -3
  55. package/dist/core/session-policy-store.js +12 -4
  56. package/dist/core/store-contracts/background-agent-store-contract.js +10 -0
  57. package/dist/core/store-contracts/peer-directory-contract.d.ts +23 -0
  58. package/dist/core/store-contracts/peer-directory-contract.js +82 -0
  59. package/dist/core/task-event.d.ts +6 -0
  60. package/dist/core/task-notification.d.ts +79 -4
  61. package/dist/core/task-notification.js +90 -1
  62. package/dist/core/task-registry-agent.js +1 -2
  63. package/dist/core/task-registry-monitor.js +2 -1
  64. package/dist/core/task-registry-shared.d.ts +53 -5
  65. package/dist/core/task-registry-shared.js +13 -2
  66. package/dist/core/task-registry.js +8 -5
  67. package/dist/core/task-spec.d.ts +12 -5
  68. package/dist/core/tool-catalog-entries.js +1 -0
  69. package/dist/core/tool-spec.d.ts +1 -1
  70. package/dist/core/wiring-manifest.d.ts +2 -2
  71. package/dist/engine/loop/agent-loop.js +1 -1
  72. package/dist/engine/loop/types.d.ts +4 -0
  73. package/dist/index.d.ts +6 -3
  74. package/dist/index.js +4 -3
  75. package/dist/orchestration/workflow.js +12 -3
  76. package/dist/prompts/coordinator.d.ts +2 -1
  77. package/dist/stores/file/adoption/adopt.js +23 -1
  78. package/dist/stores/file/permission-rule-store.js +3 -1
  79. package/dist/stores/file/session-policy-store.d.ts +31 -0
  80. package/dist/stores/file/session-policy-store.js +87 -7
  81. package/dist/tools/fs/bash-program-position.d.ts +4 -26
  82. package/dist/tools/fs/bash-program-position.js +0 -11
  83. package/dist/tools/fs/bash-readonly-classifier.d.ts +20 -309
  84. package/dist/tools/fs/bash-readonly-classifier.js +28 -766
  85. package/dist/tools/fs/fs-bash.d.ts +43 -207
  86. package/dist/tools/fs/fs-bash.js +20 -341
  87. package/dist/tools/fs/index.d.ts +12 -13
  88. package/dist/tools/fs/index.js +3 -4
  89. package/dist/tools/fs/read-deny.d.ts +25 -0
  90. package/dist/tools/fs/read-deny.js +19 -0
  91. package/dist/tools/fs/safety.js +2 -6
  92. package/dist/tools/fs/shell-read-check.d.ts +99 -0
  93. package/dist/tools/fs/shell-read-check.js +229 -0
  94. package/dist/tools/fs/shell-read-establish.d.ts +70 -115
  95. package/dist/tools/fs/shell-read-establish.js +216 -102
  96. package/dist/tools/fs/shell-read-judge.d.ts +231 -0
  97. package/dist/tools/fs/shell-read-judge.js +170 -0
  98. package/dist/tools/fs/shell-read-plan.d.ts +3 -0
  99. package/dist/tools/fs/shell-read-plan.js +82 -19
  100. package/dist/tools/monitor.d.ts +5 -5
  101. package/dist/tools/monitor.js +3 -5
  102. package/dist/tools/task-list.d.ts +4 -0
  103. package/package.json +1 -1
  104. package/test/export-surface.snapshot.json +19 -3
  105. package/wire-changes/core@7.24.4.json +54 -0
  106. package/wire-changes/core@7.25.0.json +671 -0
@@ -0,0 +1,231 @@
1
+ /**
2
+ * The shell read JUDGE and its DECISION TABLE — the one place a shell command's read candidates become a
3
+ * verdict, for every shell face (the full `Bash`, the read-only `Bash`, `Monitor`) and both moments (the
4
+ * gate station before approval, the rewrite re-judge after it).
5
+ *
6
+ * · `judgeRead` judges ONE established candidate: the deny half first, over every view the candidate has
7
+ * (its canonical target, its folded spelling, the kernel spelling of a `..` operand, the folded spelling
8
+ * of a candidate that could not be established), then containment over the canonical target alone. The
9
+ * deny judge is the boundary's own `denyMatch` closure (`readDenySeat`, read-deny.ts) — the same function
10
+ * object every read face mounts; nothing here compiles a pattern or re-derives a view set. The containment
11
+ * judge is `withinAnyRoot` over the roots the boundary declares.
12
+ * · `foldShellReadFindings` folds a command's findings — every segment, every nested plan — to ONE word by
13
+ * the priority `identity_changed > deny_hit > outside_roots > unestablishable > declined > none`.
14
+ * · `SHELL_READ_DECISION` is the total table (finding × seat → disposition) that is the ONLY source of
15
+ * "refuse / mandated ask / ordinary ask / vouch" for a shell read. A seat is a TRUST POLICY word, not a
16
+ * tool name: the four seats differ on one row (`declined`) and nowhere else.
17
+ *
18
+ * The floor this module is (`shell.read_boundary.friction_floor`) is stated on {@link judgeRead}.
19
+ */
20
+ import type { BashReadonlyRootBoundary } from "./bash-readonly-classifier.js";
21
+ import type { ReadCandidate, ShellReadPlan, ShellReadSegment } from "./shell-read-plan.js";
22
+ import type { AssertAllKeysHandled } from "../../core/ask-origin.js";
23
+ /** WHAT the judge found about a command — a closed set, registered in `docs/CLOSED-SETS.md`. */
24
+ export declare const SHELL_READ_FINDINGS: readonly ["deny_hit", "outside_roots", "unestablishable", "declined", "identity_changed", "none"];
25
+ export type ShellReadFinding = (typeof SHELL_READ_FINDINGS)[number];
26
+ /** WHO is asking — the trust-policy seats the decision table is keyed on. `bash_off` covers the `off` and
27
+ * `always` doctrines (and a bypass mode); `bash_classify` the `classify` doctrine; `readonly` the read-only
28
+ * shell face (`handsReadOnly`); `rejudge` the post-approval re-judgment of rewritten bytes and the
29
+ * pre-spawn identity re-check. */
30
+ export declare const SHELL_READ_SEATS: readonly ["bash_off", "bash_classify", "readonly", "rejudge"];
31
+ export type ShellReadSeat = (typeof SHELL_READ_SEATS)[number];
32
+ /** The fold priority: a command's finding is the highest-ranked finding of any candidate in it. */
33
+ export declare const SHELL_READ_FINDING_RANK: {
34
+ readonly identity_changed: 6;
35
+ readonly deny_hit: 5;
36
+ readonly outside_roots: 4;
37
+ readonly unestablishable: 3;
38
+ readonly declined: 2;
39
+ readonly none: 0;
40
+ };
41
+ /** Compile-time fence: `never` while every finding has a rank. */
42
+ export type RankTableCoversEveryFinding = AssertAllKeysHandled<Exclude<ShellReadFinding, keyof typeof SHELL_READ_FINDING_RANK>>;
43
+ /** What a seat does with a finding. `refuse` = a deny at the station (or a veto after a person's yes);
44
+ * `mandated` = an ask no automatic lane may clear; `ordinary` = an ask any answerer may clear (a person,
45
+ * the auto-mode classifier, a stored allow rule) but no answerless engine table may; `vouch` = the station
46
+ * has no question. */
47
+ export type ShellReadDisposition = "refuse" | "mandated" | "ordinary" | "vouch";
48
+ /**
49
+ * THE decision table — total over findings × seats; no cell is n/a. Row by row:
50
+ * · `deny_hit` — refused on every seat: a deny row is a floor no root grant and no approval moves.
51
+ * · `outside_roots` — asked where a person can be asked (the two full-shell seats), refused where none
52
+ * can (the read-only face has no approval channel; a rewrite that leaves the roots after approval was
53
+ * seen by nobody).
54
+ * · `unestablishable` — the same shape: the floor could not say where the command reads, so it is not
55
+ * vouched anywhere; a person may still answer it on the full shell.
56
+ * · `declined` — the one row the seats disagree on, and the one deliberate fail-open of this table: the
57
+ * scan could not read the program bash will run or the operands arrive on a stream. The `off` doctrine
58
+ * mounts nothing else that could ask, so it mandates; the `classify` doctrine composes a shape classifier
59
+ * beside this station and the ruled trade is an ORDINARY ask there — an answerer may settle it, and an
60
+ * out-of-root read hidden inside such a command is then not asked again. The read-only face refuses:
61
+ * its allowlist is a deployment knob, so this cell is reachable and must not be n/a.
62
+ * · `identity_changed` — a candidate established at approval time resolves elsewhere at spawn time:
63
+ * refused everywhere (it is only ever produced on the `rejudge` seat).
64
+ * · `none` — vouched: the station has no question (the classify seat's shape classifier may still ask).
65
+ */
66
+ export declare const SHELL_READ_DECISION: {
67
+ readonly deny_hit: {
68
+ readonly bash_off: "refuse";
69
+ readonly bash_classify: "refuse";
70
+ readonly readonly: "refuse";
71
+ readonly rejudge: "refuse";
72
+ };
73
+ readonly outside_roots: {
74
+ readonly bash_off: "mandated";
75
+ readonly bash_classify: "mandated";
76
+ readonly readonly: "refuse";
77
+ readonly rejudge: "refuse";
78
+ };
79
+ readonly unestablishable: {
80
+ readonly bash_off: "mandated";
81
+ readonly bash_classify: "mandated";
82
+ readonly readonly: "refuse";
83
+ readonly rejudge: "refuse";
84
+ };
85
+ readonly declined: {
86
+ readonly bash_off: "mandated";
87
+ readonly bash_classify: "ordinary";
88
+ readonly readonly: "refuse";
89
+ readonly rejudge: "refuse";
90
+ };
91
+ readonly identity_changed: {
92
+ readonly bash_off: "refuse";
93
+ readonly bash_classify: "refuse";
94
+ readonly readonly: "refuse";
95
+ readonly rejudge: "refuse";
96
+ };
97
+ readonly none: {
98
+ readonly bash_off: "vouch";
99
+ readonly bash_classify: "vouch";
100
+ readonly readonly: "vouch";
101
+ readonly rejudge: "vouch";
102
+ };
103
+ };
104
+ /** Compile-time fence: `never` while every finding has a row. */
105
+ export type DecisionTableCoversEveryFinding = AssertAllKeysHandled<Exclude<ShellReadFinding, keyof typeof SHELL_READ_DECISION>>;
106
+ /** Compile-time fence: `never` while every seat has a cell on the `none` row (the `satisfies` above holds
107
+ * every other row to the same shape). */
108
+ export type DecisionTableCoversEverySeat = AssertAllKeysHandled<Exclude<ShellReadSeat, keyof (typeof SHELL_READ_DECISION)["none"]>>;
109
+ /** The disposition of one command-level finding on one seat — the table, read. */
110
+ export declare function decideShellRead(finding: ShellReadFinding, seat: ShellReadSeat): ShellReadDisposition;
111
+ /**
112
+ * The boundary a shell read is judged against: the declared roots (canonical), the working directory and
113
+ * home the plan's bases start from, the deny seat (the SAME `denyMatch` closure every read face mounts),
114
+ * the containment face, and — for the read-only face — the engine's own overflow-spool fence as one more
115
+ * member of "inside" (`alsoInside`), asked once per target that landed outside the roots.
116
+ */
117
+ export interface ShellReadBoundary extends BashReadonlyRootBoundary {
118
+ readonly alsoInside?: (canonicalTarget: string) => Promise<boolean>;
119
+ }
120
+ /** How far one candidate got before it was judged. `lexical` = the folded spelling is the judged target
121
+ * (an isolated sandbox, or a resolver that declares no authority and did not answer); `canonical` = the
122
+ * environment resolved it; `expansion` = one path a pattern expanded to or a traversal enumerated;
123
+ * `unestablishable` = the floor could not say where it lands (`cause` says why). */
124
+ export type ReadEstablishment = "lexical" | "canonical" | "expansion" | "unestablishable";
125
+ export interface EstablishedRead {
126
+ readonly candidate: ReadCandidate;
127
+ readonly established: ReadEstablishment;
128
+ /** The judged target: an absolute path (canonical where established so, the folded spelling otherwise).
129
+ * Absent exactly when `established === "unestablishable"`. */
130
+ readonly target?: string;
131
+ /** Every spelling the deny judge sees BESIDE the target: the folded spelling, the kernel spelling of a
132
+ * `..` operand, the pattern an expansion came from, the placeholder-carrying fold of a substitution. */
133
+ readonly spellings: readonly string[];
134
+ /** The pattern or traversal root an `expansion` came from, as folded. */
135
+ readonly origin?: string;
136
+ /** Why the candidate could not be established. */
137
+ readonly cause?: string;
138
+ }
139
+ /** What the judge found about ONE candidate. `kind` is one of the four candidate-level findings
140
+ * (`declined` is a segment fact, `identity_changed` a re-establishment fact — neither is a candidate's). */
141
+ export interface ReadFinding {
142
+ readonly read: EstablishedRead;
143
+ readonly kind: Extract<ShellReadFinding, "deny_hit" | "outside_roots" | "unestablishable" | "none">;
144
+ /** The deny row that matched and the view it matched on. */
145
+ readonly deny?: {
146
+ readonly pattern: string;
147
+ readonly matchedView: string;
148
+ };
149
+ readonly containment: "inside" | "outside" | "open" | "unknown";
150
+ }
151
+ /**
152
+ * Judge ONE established candidate. Deny first, over every view (a hit on any view decides — a symlink whose
153
+ * SPELLING names a guarded path is refused even when its target is elsewhere, and a substitution's folded
154
+ * spelling is judged although the candidate could not be established); containment over the canonical
155
+ * target alone, and only where the boundary's face closes it.
156
+ *
157
+ * @contract shell.read_boundary.friction_floor — what this judge answers is a question about TEXT and the
158
+ * FILESYSTEM the command will run in: which files the words of a command may name, resolved against the
159
+ * declared roots, the declared home and the observed working directory, and — where the environment can
160
+ * answer — where those spellings really land (symlinks resolved, patterns expanded, traversal roots
161
+ * enumerated, each within a bound). It is not a question about what the process will open. A deployment's
162
+ * containment for what a shell may reach is the `ExecutionEnv` the command runs in (a sandbox, a
163
+ * container, a remote adapter); everything here is the friction floor under that containment. Under an
164
+ * isolated sandbox the floor is lexical (the sandbox is the boundary and its filesystem is not this
165
+ * process's to resolve). A deny-listed read is REFUSED on every seat; an out-of-root read asks where a
166
+ * person can be asked and is refused where none can; a read the floor cannot establish (an expansion it
167
+ * cannot finish, a spelling the shell rewrites, a working directory it cannot place) is asked or refused
168
+ * the same way — never vouched. Containment judges what a READ program opens: a program path is executed,
169
+ * not disclosed, and a leading environment word's value reaches the program through its environment, so
170
+ * neither is a containment candidate — both still meet the deny judge, on every spelling.
171
+ */
172
+ export declare function judgeRead(read: EstablishedRead, boundary: ShellReadBoundary): ReadFinding;
173
+ /** One segment's judged reads and declines, with its nested plans judged the same way. */
174
+ export interface JudgedSegment {
175
+ readonly segment: ShellReadSegment;
176
+ readonly findings: readonly ReadFinding[];
177
+ readonly nested: readonly JudgedPlan[];
178
+ }
179
+ export interface JudgedPlan {
180
+ readonly plan: ShellReadPlan;
181
+ readonly segments: readonly JudgedSegment[];
182
+ }
183
+ /** The command-level fold and the finding that produced it. `witness` is absent for `none`; for `declined`
184
+ * the segment that declined stands in for a candidate. */
185
+ export interface ShellReadFold {
186
+ readonly finding: ShellReadFinding;
187
+ readonly witness?: ReadFinding;
188
+ readonly declined?: ShellReadSegment;
189
+ }
190
+ /**
191
+ * Fold a judged plan to ONE finding by {@link SHELL_READ_FINDING_RANK}, over every segment and every nested
192
+ * plan. Two structural rules ride the fold:
193
+ * · a plan the lexer refused (`unreadable`) is `unestablishable`, unconditionally — a quote-blind re-read
194
+ * yields more words, never zero, so an unreadable text is never "no candidates";
195
+ * · a SHELL re-entry segment (`sh -c S`, `eval S`, `env -S 'S'`, a launcher handing text to a shell) is at
196
+ * least `unestablishable` — what runs is text the shell parses, not this plan — and its nested plan may
197
+ * only RAISE that to `deny_hit`: the nested text's deny hits count (a refusal is a refusal whatever runs
198
+ * it), its containment and its silence do not (an inner `cat inside.txt` vouches nothing about the
199
+ * outer text). A substitution body (`$(…)`, a backtick, `<(…)`) is nested WITHOUT re-entry and folds
200
+ * in full.
201
+ */
202
+ export declare function foldShellReadFindings(judged: JudgedPlan): ShellReadFold;
203
+ /** Judge every established read of a plan, in place. `establish` runs per segment so a caller can bound it. */
204
+ export declare function judgeEstablishedPlan(plan: ShellReadPlan, established: ReadonlyMap<ShellReadSegment, readonly EstablishedRead[]>, boundary: ShellReadBoundary, nestedOf: (plan: ShellReadPlan) => JudgedPlan): JudgedPlan;
205
+ /** Every finding of a judged plan, flattened (segments and nested plans alike). */
206
+ export declare function allFindings(judged: JudgedPlan): ReadFinding[];
207
+ /**
208
+ * The deepest directory containing both, or `undefined` when they share none (two path families).
209
+ *
210
+ * LINEAR, and that is the point: both paths are already folded, so the answer is their common prefix cut
211
+ * at a separator. Climbing one directory at a time re-folds the whole remaining path each step — the same
212
+ * answer at quadratic cost, measured at 4.9 s of synchronous work for one 32 KB command naming a
213
+ * 16 000-segment operand. The family-aware comparator keeps the last word: a prefix it cannot confirm
214
+ * contains both paths yields no candidate.
215
+ */
216
+ export declare function deepestCommonDir(a: string, b: string): string | undefined;
217
+ /**
218
+ * The ONE directory whose addition to the declared roots would answer every `outside_roots` finding of a
219
+ * command — offered only when re-judging the same established reads with that root added leaves no
220
+ * finding at all (MEASURED, not argued: a deny row or an unestablishable candidate beside the out-of-root
221
+ * one leaves the ask standing whatever root is granted, and then nothing is offered). `exact` ⇒ the value
222
+ * is the one path the command NAMED, offered verbatim, because the containing directory would hand out the
223
+ * home or a root. A `..` spelling gets no offer: the folded and the kernel reading of it can disagree about
224
+ * whether the target is back inside, which no replay of the text settles.
225
+ */
226
+ export declare function readRootGrantCandidateOf(judged: JudgedPlan, boundary: ShellReadBoundary): {
227
+ readonly dir: string;
228
+ readonly covers?: "exact";
229
+ } | undefined;
230
+ /** The same judged plan under another boundary — the established reads are kept, only the judgment reruns. */
231
+ export declare function rejudge(judged: JudgedPlan, boundary: ShellReadBoundary): JudgedPlan;
@@ -0,0 +1,170 @@
1
+ import { hasWin32AliasedComponent, normalizeAbsPathLexically, withinAnyRoot } from "./safety.js";
2
+ export const SHELL_READ_FINDINGS = ["deny_hit", "outside_roots", "unestablishable", "declined", "identity_changed", "none"];
3
+ export const SHELL_READ_SEATS = ["bash_off", "bash_classify", "readonly", "rejudge"];
4
+ export const SHELL_READ_FINDING_RANK = {
5
+ identity_changed: 6,
6
+ deny_hit: 5,
7
+ outside_roots: 4,
8
+ unestablishable: 3,
9
+ declined: 2,
10
+ none: 0,
11
+ };
12
+ export const SHELL_READ_DECISION = {
13
+ deny_hit: { bash_off: "refuse", bash_classify: "refuse", readonly: "refuse", rejudge: "refuse" },
14
+ outside_roots: { bash_off: "mandated", bash_classify: "mandated", readonly: "refuse", rejudge: "refuse" },
15
+ unestablishable: { bash_off: "mandated", bash_classify: "mandated", readonly: "refuse", rejudge: "refuse" },
16
+ declined: { bash_off: "mandated", bash_classify: "ordinary", readonly: "refuse", rejudge: "refuse" },
17
+ identity_changed: { bash_off: "refuse", bash_classify: "refuse", readonly: "refuse", rejudge: "refuse" },
18
+ none: { bash_off: "vouch", bash_classify: "vouch", readonly: "vouch", rejudge: "vouch" },
19
+ };
20
+ export function decideShellRead(finding, seat) {
21
+ return SHELL_READ_DECISION[finding][seat];
22
+ }
23
+ export function judgeRead(read, boundary) {
24
+ const deny = boundary.denyMatch;
25
+ if (deny !== undefined) {
26
+ const views = [...(read.target !== undefined ? [read.target] : []), ...read.spellings];
27
+ for (const view of views) {
28
+ const folded = normalizeAbsPathLexically(view);
29
+ const pattern = deny(folded);
30
+ if (pattern !== null)
31
+ return { read, kind: "deny_hit", deny: { pattern, matchedView: folded }, containment: containmentOf(read, boundary) };
32
+ }
33
+ }
34
+ const containment = containmentOf(read, boundary);
35
+ const provenance = read.candidate.provenance;
36
+ if (provenance === "env_assignment" || (provenance === "program_path" && read.candidate.expands !== "unresolvable"))
37
+ return { read, kind: "none", containment };
38
+ if (read.established === "unestablishable")
39
+ return { read, kind: "unestablishable", containment };
40
+ return { read, kind: containment === "outside" ? "outside_roots" : "none", containment };
41
+ }
42
+ function containmentOf(read, boundary) {
43
+ if (boundary.face === "open")
44
+ return "open";
45
+ if (read.target === undefined)
46
+ return "unknown";
47
+ return withinAnyRoot(boundary.roots, read.target) ? "inside" : "outside";
48
+ }
49
+ export function foldShellReadFindings(judged) {
50
+ let best = { finding: "none" };
51
+ const raise = (next) => {
52
+ if (SHELL_READ_FINDING_RANK[next.finding] > SHELL_READ_FINDING_RANK[best.finding])
53
+ best = next;
54
+ };
55
+ const walk = (j, denyOnly) => {
56
+ if (j.plan.unreadable !== undefined && !denyOnly)
57
+ raise({ finding: "unestablishable" });
58
+ for (const s of j.segments) {
59
+ for (const f of s.findings) {
60
+ if (denyOnly && f.kind !== "deny_hit")
61
+ continue;
62
+ if (f.kind !== "none")
63
+ raise({ finding: f.kind, witness: f });
64
+ }
65
+ if (!denyOnly) {
66
+ if (s.segment.declines.length > 0)
67
+ raise({ finding: "declined", declined: s.segment });
68
+ if (s.segment.reentry === "shell")
69
+ raise({ finding: "unestablishable", declined: s.segment });
70
+ if (s.segment.unreadProgramText !== undefined)
71
+ raise({ finding: "unestablishable", declined: s.segment });
72
+ const move = s.segment.baseMove;
73
+ if (move !== undefined && (typeof move.target === "string" || move.target.expands !== false))
74
+ raise({ finding: "unestablishable", declined: s.segment });
75
+ }
76
+ const nestedDenyOnly = denyOnly || s.segment.reentry === "shell";
77
+ for (const n of s.nested)
78
+ walk(n, nestedDenyOnly);
79
+ }
80
+ };
81
+ walk(judged, false);
82
+ return best;
83
+ }
84
+ export function judgeEstablishedPlan(plan, established, boundary, nestedOf) {
85
+ return {
86
+ plan,
87
+ segments: plan.segments.map((segment) => ({
88
+ segment,
89
+ findings: (established.get(segment) ?? []).map((r) => judgeRead(r, boundary)),
90
+ nested: segment.nested.map(nestedOf),
91
+ })),
92
+ };
93
+ }
94
+ export function allFindings(judged) {
95
+ const out = [];
96
+ const walk = (j) => {
97
+ for (const s of j.segments) {
98
+ out.push(...s.findings);
99
+ for (const n of s.nested)
100
+ walk(n);
101
+ }
102
+ };
103
+ walk(judged);
104
+ return out;
105
+ }
106
+ function parentDirLexically(p) {
107
+ return normalizeAbsPathLexically(`${p}/..`);
108
+ }
109
+ export function deepestCommonDir(a, b) {
110
+ if (a === b)
111
+ return a;
112
+ const shorter = Math.min(a.length, b.length);
113
+ let end = 0;
114
+ let i = 0;
115
+ while (i < shorter && a[i] === b[i]) {
116
+ if (a[i] === "/" || a[i] === "\\")
117
+ end = i;
118
+ i++;
119
+ }
120
+ if (i === shorter && (a[shorter] === "/" || a[shorter] === "\\" || b[shorter] === "/" || b[shorter] === "\\"))
121
+ end = shorter;
122
+ const cut = a.slice(0, end);
123
+ const candidate = cut.includes("/") || cut.includes("\\") ? cut : a.slice(0, end + 1);
124
+ if (candidate.length === 0 || !withinAnyRoot([candidate], a) || !withinAnyRoot([candidate], b))
125
+ return undefined;
126
+ return candidate;
127
+ }
128
+ export function readRootGrantCandidateOf(judged, boundary) {
129
+ const outside = allFindings(judged).filter((f) => f.kind === "outside_roots");
130
+ if (outside.length === 0)
131
+ return undefined;
132
+ const targets = [...new Set(outside.map((f) => f.read.target))];
133
+ const paths = targets.filter((p) => !targets.some((q) => q !== p && withinAnyRoot([p], q)));
134
+ if (outside.some((f) => f.read.candidate.spelled.split(/[\\/]/).includes("..")))
135
+ return undefined;
136
+ if (paths.some(hasWin32AliasedComponent))
137
+ return undefined;
138
+ const readsDirectory = new Set(outside.filter((f) => f.read.candidate.provenance === "traversal_root" || f.read.candidate.provenance === "cd_target").map((f) => f.read.target));
139
+ const dirOf = (path) => (readsDirectory.has(path) ? path : parentDirLexically(path));
140
+ let candidate = dirOf(paths[0]);
141
+ for (const path of paths.slice(1)) {
142
+ if (candidate === undefined)
143
+ return undefined;
144
+ const d = dirOf(path);
145
+ if (d === undefined)
146
+ return undefined;
147
+ candidate = deepestCommonDir(candidate, d);
148
+ }
149
+ if (candidate === undefined)
150
+ return undefined;
151
+ const tooWide = parentDirLexically(candidate) === candidate ||
152
+ (boundary.homeDir !== undefined && withinAnyRoot([candidate], boundary.homeDir)) ||
153
+ boundary.roots.some((root) => withinAnyRoot([candidate], root));
154
+ const proposed = tooWide ? (paths.length === 1 ? { dir: paths[0], covers: "exact" } : undefined) : { dir: candidate };
155
+ if (proposed === undefined)
156
+ return undefined;
157
+ const widened = { ...boundary, roots: [...boundary.roots, proposed.dir] };
158
+ const replay = rejudge(judged, widened);
159
+ return foldShellReadFindings(replay).finding === "none" ? proposed : undefined;
160
+ }
161
+ export function rejudge(judged, boundary) {
162
+ return {
163
+ plan: judged.plan,
164
+ segments: judged.segments.map((s) => ({
165
+ segment: s.segment,
166
+ findings: s.findings.map((f) => judgeRead(f.read, boundary)),
167
+ nested: s.nested.map((n) => rejudge(n, boundary)),
168
+ })),
169
+ };
170
+ }
@@ -85,6 +85,9 @@ export interface ShellReadSegment {
85
85
  /** `shell` = the program runs its argument as shell text; `argv` = its operands arrive on a stream;
86
86
  * `interpreter` = it runs an inline program in a language this parser does not read (a shape fact only). */
87
87
  readonly reentry?: "shell" | "argv" | "interpreter";
88
+ /** Program text the station READS as a rule (a `sed` script names the files it opens) but could not read here —
89
+ * supplied from a file, or not parseable to its end. What such a segment reads is then not established. */
90
+ readonly unreadProgramText?: string;
88
91
  readonly declines: readonly ShellScanDecline[];
89
92
  readonly reads: readonly ReadCandidate[];
90
93
  readonly redirects: readonly ShellReadRedirect[];
@@ -20,7 +20,8 @@ export function planShellReads(command, decl, depth = 0) {
20
20
  const shape = { grouped: false, backgrounded: false, strayRedirection: false };
21
21
  if (depth > NESTING_MAX)
22
22
  return { segments: [], unreadable: `command text nested more than ${NESTING_MAX} levels deep`, shape };
23
- const lexed = lexShellText(foldHomeVariableSpelling(command, decl.homeDir), { mode: "lenient" });
23
+ const homeReassigned = /(^|[\s;&|(){}`])(?:export\s+)?HOME\+?=/.test(command);
24
+ const lexed = lexShellText(foldHomeVariableSpelling(command, homeReassigned ? undefined : decl.homeDir), { mode: "lenient" });
24
25
  if ("reject" in lexed)
25
26
  return { segments: [], unreadable: lexed.reject, shape };
26
27
  const nestedPlan = (text) => planShellReads(text, decl, depth + 1);
@@ -140,12 +141,13 @@ export function planShellReads(command, decl, depth = 0) {
140
141
  launcherChain: f.launcherChain,
141
142
  ...(f.verb !== undefined ? { verb: f.verb } : {}),
142
143
  ...(f.reentry !== undefined ? { reentry: f.reentry } : {}),
144
+ ...(f.unreadProgramText !== undefined ? { unreadProgramText: f.unreadProgramText } : {}),
143
145
  declines: f.declines,
144
146
  reads: dedupe(f.reads).map(withBases),
145
147
  redirects: f.redirects.map((r) => (r.target === undefined ? { op: r.op, direction: r.direction } : { op: r.op, direction: r.direction, target: withBases(r.target) })),
146
148
  ...(f.baseMove !== undefined ? { baseMove: { target: typeof f.baseMove.target === "string" ? f.baseMove.target : withBases(f.baseMove.target) } } : {}),
147
149
  bases,
148
- nested: f.nested,
150
+ nested: f.nested.map((n) => rebasePlan(n, decl.cwd, bases)),
149
151
  reach: reaches[si] ?? { andJoined: false, orBroken: false, moveProvable: false },
150
152
  });
151
153
  }
@@ -251,8 +253,24 @@ function segmentFacts(segment, p, decl, nestedPlan) {
251
253
  const controlIndices = new Set(pos.controlWords);
252
254
  const assignmentIndices = new Set(pos.assignments);
253
255
  const chainEnd = pos.program?.index ?? toks.folded.length;
256
+ const launcherValueIndices = new Set();
257
+ for (const l of pos.launchers) {
258
+ const row = LAUNCHER_TABLE.get(programBasename(l.name));
259
+ if (row === undefined)
260
+ continue;
261
+ for (let j = l.index + 1; j < chainEnd; j++) {
262
+ const w = toks.folded[j];
263
+ if (!w.startsWith("-") || w === "-")
264
+ break;
265
+ const arity = row.optionArity.get(w);
266
+ if (arity === undefined)
267
+ break;
268
+ if (arity === 1)
269
+ launcherValueIndices.add(++j);
270
+ }
271
+ }
254
272
  for (let j = 0; j < chainEnd; j++) {
255
- if (launcherIndices.has(j) || controlIndices.has(j) || assignmentIndices.has(j) || j === written)
273
+ if (launcherIndices.has(j) || controlIndices.has(j) || assignmentIndices.has(j) || launcherValueIndices.has(j) || j === written)
256
274
  continue;
257
275
  const word = toks.folded[j];
258
276
  const raw = toks.raw[j];
@@ -348,7 +366,8 @@ function segmentFacts(segment, p, decl, nestedPlan) {
348
366
  else {
349
367
  unlistedWords(reads, toks, program.index, launcherIndices, "unlisted_word", program);
350
368
  }
351
- if (basename === "sed" && listed) {
369
+ let unreadProgramText;
370
+ if (basename === "sed") {
352
371
  const slot = sedScriptSlotFiles(args);
353
372
  for (const f of slot.reads)
354
373
  reads.push(bare(f, f, "script_slot_file"));
@@ -356,6 +375,9 @@ function segmentFacts(segment, p, decl, nestedPlan) {
356
375
  reads.push(bare(f, f, "write_redirect_target"));
357
376
  for (const c of slot.commands)
358
377
  nested.push(nestedPlan(c));
378
+ if (slot.commands.length > 0 || slot.executesPatternSpace)
379
+ reentry = "shell";
380
+ unreadProgramText = slot.unread;
359
381
  }
360
382
  return {
361
383
  ...base,
@@ -363,6 +385,7 @@ function segmentFacts(segment, p, decl, nestedPlan) {
363
385
  launcherChain,
364
386
  verb: verb,
365
387
  ...(reentry !== undefined ? { reentry } : {}),
388
+ ...(unreadProgramText !== undefined ? { unreadProgramText } : {}),
366
389
  declines,
367
390
  reads,
368
391
  ...(baseMove !== undefined ? { baseMove } : {}),
@@ -371,6 +394,38 @@ function segmentFacts(segment, p, decl, nestedPlan) {
371
394
  loopEnd: false,
372
395
  };
373
396
  }
397
+ function rebasePlan(plan, from, to) {
398
+ if (to !== "unmodelled" && to.length === 1 && to[0] === from)
399
+ return plan;
400
+ const fromSep = from.endsWith("/") || from.endsWith("\\") ? from : `${from}/`;
401
+ const moveBase = (b) => {
402
+ if (to === "unmodelled")
403
+ return [];
404
+ if (b === from)
405
+ return [...to];
406
+ if (b.startsWith(fromSep))
407
+ return to.map((t) => `${t.endsWith("/") || t.endsWith("\\") ? t : `${t}/`}${b.slice(fromSep.length)}`);
408
+ return [b];
409
+ };
410
+ const moveCandidate = (c) => {
411
+ if (c.bases === "unmodelled" || c.bases.length === 0)
412
+ return c;
413
+ if (to === "unmodelled")
414
+ return { ...c, bases: "unmodelled" };
415
+ return { ...c, bases: [...new Set(c.bases.flatMap(moveBase))] };
416
+ };
417
+ return {
418
+ ...plan,
419
+ segments: plan.segments.map((s) => ({
420
+ ...s,
421
+ reads: s.reads.map(moveCandidate),
422
+ redirects: s.redirects.map((r) => (r.target === undefined ? r : { ...r, target: moveCandidate(r.target) })),
423
+ ...(s.baseMove !== undefined ? { baseMove: { target: typeof s.baseMove.target === "string" ? s.baseMove.target : moveCandidate(s.baseMove.target) } } : {}),
424
+ bases: s.bases === "unmodelled" || to === "unmodelled" ? "unmodelled" : [...new Set(s.bases.flatMap(moveBase))],
425
+ nested: s.nested.map((n) => rebasePlan(n, from, to)),
426
+ })),
427
+ };
428
+ }
374
429
  function dedupe(reads) {
375
430
  const seen = new Set();
376
431
  const out = [];
@@ -387,7 +442,7 @@ function bare(raw, folded, provenance) {
387
442
  return { spelled: folded, raw, provenance, expands: expandsOf(raw, folded) };
388
443
  }
389
444
  function expandsOf(raw, folded) {
390
- if (folded.includes(SUBSTITUTION_PLACEHOLDER) || hasUnquotedExpansionMetachar(raw))
445
+ if (folded.includes(SUBSTITUTION_PLACEHOLDER) || hasUnquotedExpansionMetachar(raw) || /[`(]/.test(raw) || raw.includes(")"))
391
446
  return "unresolvable";
392
447
  if (hasUnquotedGlobMetachar(raw))
393
448
  return "glob";
@@ -413,6 +468,8 @@ function pushAssignment(reads, toks, k, provenance) {
413
468
  const payload = assignmentWordPayload(word);
414
469
  if (payload === undefined)
415
470
  return;
471
+ if (payload.kind === "value" && payload.value === SUBSTITUTION_PLACEHOLDER)
472
+ return;
416
473
  if (payload.kind === "value")
417
474
  reads.push(bare(toks.raw[k], payload.value, provenance));
418
475
  else
@@ -429,6 +486,8 @@ function unlistedWords(reads, toks, from, launcherIndices, provenance, program)
429
486
  for (let j = from; j < toks.folded.length; j++) {
430
487
  if (launcherIndices.has(j))
431
488
  continue;
489
+ if (noPathOperands && j > argvStart)
490
+ break;
432
491
  if (expandsOf(toks.raw[j], toks.folded[j]) !== "unresolvable")
433
492
  continue;
434
493
  expanding.add(j);
@@ -476,7 +535,7 @@ function unlistedWords(reads, toks, from, launcherIndices, provenance, program)
476
535
  for (const c of named)
477
536
  reads.push(bare(toks.raw[j], c, "option_value"));
478
537
  if (named.length > 0)
479
- positional.push(value);
538
+ positional.push({ word: value, raw: toks.raw[j] });
480
539
  }
481
540
  }
482
541
  continue;
@@ -501,23 +560,27 @@ function unlistedWords(reads, toks, from, launcherIndices, provenance, program)
501
560
  if (m === undefined || m.dashIsStdin === true)
502
561
  stdinOperand = true;
503
562
  else
504
- positional.push(word);
563
+ positional.push({ word, raw });
505
564
  }
506
565
  else
507
- positional.push(word);
566
+ positional.push({ word, raw });
508
567
  }
509
568
  reads.push(bare(raw, word, j === argvStart ? "program_path" : provenance));
510
- if (j < argvStart || !declaredNotPaths)
569
+ if (j < argvStart)
511
570
  pushAssignment(reads, toks, j, "env_assignment");
571
+ else if (!declaredNotPaths)
572
+ pushAssignment(reads, toks, j, provenance);
512
573
  }
513
574
  if (slotVerb !== undefined && argv !== undefined && segmentSelectsRecursiveRead(slotVerb, toks.folded.slice(argvStart + 1))) {
514
- const roots = positional.length > 0 ? positional : stdinOperand ? [] : ["."];
575
+ const roots = positional.length > 0 ? positional : stdinOperand ? [] : [{ word: ".", raw: "." }];
515
576
  for (const root of roots)
516
- reads.push({ spelled: root, raw: root, provenance: "traversal_root", expands: false });
577
+ reads.push({ spelled: root.word, raw: root.raw, provenance: "traversal_root", expands: expandsOf(root.raw, root.word) });
517
578
  }
518
579
  for (let j = from; j < toks.folded.length; j++) {
519
580
  if (launcherIndices.has(j) || j === argvStart)
520
581
  continue;
582
+ if (noPathOperands && j > argvStart)
583
+ break;
521
584
  const raw = toks.raw[j];
522
585
  if (expandsOf(raw, toks.folded[j]) === "glob")
523
586
  reads.push(bare(raw, toks.folded[j], provenance));
@@ -645,7 +708,7 @@ function listedVerbCandidates(name, args, rawArgs, reads) {
645
708
  for (const c of named)
646
709
  push(k, c, "option_value");
647
710
  if (named.length > 0)
648
- positional.push(value);
711
+ positional.push({ word: value, raw: rawArgs[k] });
649
712
  }
650
713
  }
651
714
  continue;
@@ -665,7 +728,7 @@ function listedVerbCandidates(name, args, rawArgs, reads) {
665
728
  stdinOperand = true;
666
729
  continue;
667
730
  }
668
- positional.push(t);
731
+ positional.push({ word: t, raw: rawArgs[k] });
669
732
  continue;
670
733
  }
671
734
  if (inFirstOperandSlot) {
@@ -673,17 +736,17 @@ function listedVerbCandidates(name, args, rawArgs, reads) {
673
736
  for (const c of named)
674
737
  push(k, c, "operand");
675
738
  if (named.length > 0)
676
- positional.push(t);
739
+ positional.push({ word: t, raw: rawArgs[k] });
677
740
  continue;
678
741
  }
679
- positional.push(t);
742
+ positional.push({ word: t, raw: rawArgs[k] });
680
743
  push(k, t, "operand");
681
744
  if (!ASSIGNMENT_VALUE_NOT_PATHS.has(name)) {
682
745
  const payload = assignmentWordPayload(t);
683
746
  if (payload?.kind === "value")
684
- push(k, payload.value, "env_assignment");
747
+ push(k, payload.value, "operand");
685
748
  else if (payload !== undefined)
686
- reads.push({ spelled: t, raw: rawArgs[k], provenance: "env_assignment", expands: "unresolvable" });
749
+ reads.push({ spelled: t, raw: rawArgs[k], provenance: "operand", expands: "unresolvable" });
687
750
  }
688
751
  }
689
752
  for (let k = 0; k < args.length; k++) {
@@ -693,8 +756,8 @@ function listedVerbCandidates(name, args, rawArgs, reads) {
693
756
  reads.push(bare(rawArgs[k], args[k], "operand"));
694
757
  }
695
758
  if (segmentSelectsRecursiveRead(name, args)) {
696
- const roots = positional.length > 0 ? positional : stdinOperand ? [] : ["."];
759
+ const roots = positional.length > 0 ? positional : stdinOperand ? [] : [{ word: ".", raw: "." }];
697
760
  for (const root of roots)
698
- reads.push({ spelled: root, raw: root, provenance: "traversal_root", expands: false });
761
+ reads.push({ spelled: root.word, raw: root.raw, provenance: "traversal_root", expands: expandsOf(root.raw, root.word) });
699
762
  }
700
763
  }