@sema-agent/core 7.26.0 → 7.26.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.
Files changed (50) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/dist/agents/verify.d.ts +16 -6
  3. package/dist/agents/verify.js +12 -5
  4. package/dist/core/checkpoint-store.d.ts +13 -10
  5. package/dist/core/gate-lanes.js +2 -1
  6. package/dist/core/gate-outcome.d.ts +54 -5
  7. package/dist/core/gate-outcome.js +17 -0
  8. package/dist/core/permission-rule-provider.js +6 -1
  9. package/dist/core/permission-rule-store.d.ts +36 -0
  10. package/dist/core/permission-rule-store.js +119 -19
  11. package/dist/core/permission-rule-sync.js +14 -5
  12. package/dist/core/runner/contracts.d.ts +48 -0
  13. package/dist/core/runner/decide-continuation.js +3 -1
  14. package/dist/core/runner/denial-limit-arms.d.ts +4 -3
  15. package/dist/core/runner/denial-limit-arms.js +2 -1
  16. package/dist/core/runner/prepare-inherited-gate.d.ts +1 -1
  17. package/dist/core/runner/prepare-inherited-gate.js +2 -1
  18. package/dist/core/runner/resume-apply.js +3 -1
  19. package/dist/core/runner/resume-policy-outcome.js +6 -6
  20. package/dist/core/runner/run-harness-handlers.js +2 -5
  21. package/dist/core/runner/run-identity-wiring.d.ts +6 -0
  22. package/dist/core/runner/run-identity-wiring.js +8 -3
  23. package/dist/core/runner/run-leg.js +9 -3
  24. package/dist/core/runner/runtask.js +2 -2
  25. package/dist/core/runner/session-rule-policy.d.ts +1 -1
  26. package/dist/core/runner/session-rule-policy.js +6 -7
  27. package/dist/core/session-policy-store.d.ts +18 -0
  28. package/dist/core/session-policy-store.js +3 -0
  29. package/dist/core/store-contracts/permission-rule-write-contract.js +16 -3
  30. package/dist/core/store-contracts/read-back-shape-contract.d.ts +56 -0
  31. package/dist/core/store-contracts/read-back-shape-contract.js +78 -0
  32. package/dist/core/tool-policy.d.ts +36 -12
  33. package/dist/core/tool-policy.js +22 -9
  34. package/dist/index.d.ts +4 -0
  35. package/dist/index.js +4 -0
  36. package/dist/stores/file/adoption/adopt.js +4 -10
  37. package/dist/stores/file/permission-rule-store.d.ts +5 -1
  38. package/dist/stores/file/permission-rule-store.js +5 -3
  39. package/dist/stores/file/session-policy-store.d.ts +32 -0
  40. package/dist/stores/file/session-policy-store.js +72 -50
  41. package/dist/tools/fs/shell-read-check.d.ts +58 -1
  42. package/dist/tools/fs/shell-read-check.js +47 -33
  43. package/dist/tools/fs/shell-read-establish.js +19 -8
  44. package/dist/tools/fs/shell-read-judge.d.ts +3 -0
  45. package/dist/tools/fs/shell-read-judge.js +3 -1
  46. package/dist/tools/fs/shell-read-plan.js +16 -17
  47. package/package.json +1 -1
  48. package/test/export-surface.snapshot.json +52 -2
  49. package/wire-changes/core@7.26.1.json +605 -0
  50. package/wire-changes/core@7.26.2.json +106 -0
@@ -40,6 +40,34 @@ export function readSessionPolicyMark(path) {
40
40
  return refuse("carries a lineage that is not a whole number");
41
41
  return mark;
42
42
  }
43
+ export function judgeSessionPolicyRowObject(raw) {
44
+ let parsed;
45
+ try {
46
+ parsed = JSON.parse(raw);
47
+ }
48
+ catch {
49
+ return { corrupt: { grade: "unparseable", reason: "unparseable JSON (torn/zero-byte write?)" } };
50
+ }
51
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
52
+ return { corrupt: { grade: "malformed", reason: "not a JSON object" } };
53
+ }
54
+ return { row: parsed };
55
+ }
56
+ export function judgeSessionPolicyRowShape(row) {
57
+ const bad = (reason) => ({ corrupt: { grade: "malformed", reason } });
58
+ if (typeof row.rev !== "number" || !Number.isFinite(row.rev) || row.rev < 1)
59
+ return bad("rev is not a positive number");
60
+ if (row.gen !== undefined && (typeof row.gen !== "number" || !Number.isInteger(row.gen) || row.gen < 0))
61
+ return bad("gen is not a whole number");
62
+ const strArr = (x) => x === undefined || (Array.isArray(x) && x.every((e) => typeof e === "string"));
63
+ if (!strArr(row.toolAllow) || !strArr(row.toolDeny) || !strArr(row.commandAllow) || !strArr(row.commandDeny) || !strArr(row.allowDirs)) {
64
+ return bad("malformed rule field");
65
+ }
66
+ const { __sid: _sid, __principal: _principal, ...clean } = row;
67
+ void _sid;
68
+ void _principal;
69
+ return { rules: clean };
70
+ }
43
71
  export class FileSessionPolicyStore {
44
72
  dir;
45
73
  onCorruptRead;
@@ -75,7 +103,9 @@ export class FileSessionPolicyStore {
75
103
  return join(this.dir, `${this.basenameFor(sessionId, principal)}${MARK_SUFFIX}`);
76
104
  }
77
105
  writeMarkFile(path, rev, sessionId, principal, gen) {
78
- const mark = { __hwm: rev, __gen: gen, __sid: sessionId, __principal: principal };
106
+ this.writeMark(path, { __hwm: rev, __gen: gen, __sid: sessionId, __principal: principal });
107
+ }
108
+ writeMark(path, mark) {
79
109
  atomicWriteFile(join(this.dir, "tmp"), path, JSON.stringify(mark));
80
110
  }
81
111
  read(sessionId, principal) {
@@ -88,35 +118,17 @@ export class FileSessionPolicyStore {
88
118
  return null;
89
119
  throw err;
90
120
  }
91
- try {
92
- const parsed = JSON.parse(raw);
93
- if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
94
- this.discloseCorrupt(sessionId, principal, "not a JSON object");
95
- return null;
96
- }
97
- const r = parsed;
98
- if (typeof r.rev !== "number" || !Number.isFinite(r.rev) || r.rev < 1) {
99
- this.discloseCorrupt(sessionId, principal, "rev is not a positive number");
100
- return null;
101
- }
102
- if (r.gen !== undefined && (typeof r.gen !== "number" || !Number.isInteger(r.gen) || r.gen < 0)) {
103
- this.discloseCorrupt(sessionId, principal, "gen is not a whole number");
104
- return null;
105
- }
106
- const strArr = (x) => x === undefined || (Array.isArray(x) && x.every((e) => typeof e === "string"));
107
- if (!strArr(r.toolAllow) || !strArr(r.toolDeny) || !strArr(r.commandAllow) || !strArr(r.commandDeny) || !strArr(r.allowDirs)) {
108
- this.discloseCorrupt(sessionId, principal, "malformed rule field");
109
- return null;
110
- }
111
- const { __sid: _sid, __principal: _principal, ...clean } = r;
112
- void _sid;
113
- void _principal;
114
- return clean;
121
+ const judged = judgeSessionPolicyRowObject(raw);
122
+ if ("corrupt" in judged) {
123
+ this.discloseCorrupt(sessionId, principal, judged.corrupt.reason);
124
+ return null;
115
125
  }
116
- catch {
117
- this.discloseCorrupt(sessionId, principal, "unparseable JSON (torn/zero-byte write?)");
126
+ const shaped = judgeSessionPolicyRowShape(judged.row);
127
+ if ("corrupt" in shaped) {
128
+ this.discloseCorrupt(sessionId, principal, shaped.corrupt.reason);
118
129
  return null;
119
130
  }
131
+ return shaped.rules;
120
132
  }
121
133
  async getRules(sessionId, principal) {
122
134
  return this.read(sessionId, principal);
@@ -134,8 +146,12 @@ export class FileSessionPolicyStore {
134
146
  }
135
147
  }
136
148
  const mark = readSessionPolicyMark(this.markPathFor(sessionId, principal));
149
+ const rowFilePresent = prior === null && existsSync(this.pathFor(sessionId, principal));
150
+ if (mark?.__lineage === "lost" || (rowFilePresent && mark === null)) {
151
+ throw new SessionPolicyError("corrupt", `session rules for ${JSON.stringify(sessionId)} have no lineage evidence (${mark?.__lineage === "lost" ? "the erase left a lost-lineage mark" : "an unreadable row and no high-water mark"}) — refusing to restart the key's lineage by guessing; delete the mark${rowFilePresent ? " and the row file" : ""} to restart it`);
152
+ }
137
153
  const floor = Math.max(prior?.rev ?? 0, mark?.__hwm ?? 0);
138
- const gen = prior?.gen ?? (existsSync(this.pathFor(sessionId, principal)) ? (mark?.__gen ?? 0) + 1 : (mark?.__gen ?? 0));
154
+ const gen = prior !== null ? (prior.gen ?? 0) : rowFilePresent ? (mark?.__gen ?? 0) + 1 : (mark?.__gen ?? 0);
139
155
  const next = { ...clean, rev: floor + 1, gen };
140
156
  const attributedPrincipal = (principal ?? this.anonymousAlias) ?? null;
141
157
  this.writeMarkFile(this.markPathFor(sessionId, principal), next.rev, sessionId, attributedPrincipal, gen);
@@ -169,24 +185,28 @@ export class FileSessionPolicyStore {
169
185
  }
170
186
  continue;
171
187
  }
172
- let parsed;
173
- try {
174
- parsed = JSON.parse(raw);
188
+ const judged = judgeSessionPolicyRowObject(raw);
189
+ if ("corrupt" in judged) {
190
+ this.disclose({ sessionId, path: full, reason: judged.corrupt.reason });
191
+ continue;
175
192
  }
176
- catch {
177
- this.disclose({ sessionId, path: full, reason: "unparseable JSON (torn/zero-byte write?)" });
193
+ if (typeof judged.row.__sid !== "string") {
194
+ this.disclose({ sessionId, path: full, reason: "session metadata is not a string" });
178
195
  continue;
179
196
  }
180
- if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
181
- this.disclose({ sessionId, path: full, reason: "not a JSON object" });
197
+ if (judged.row.__sid !== sessionId)
198
+ continue;
199
+ const shaped = judgeSessionPolicyRowShape(judged.row);
200
+ if ("corrupt" in shaped) {
201
+ this.disclose({ sessionId, path: full, reason: shaped.corrupt.reason });
182
202
  continue;
183
203
  }
184
- const r = parsed;
185
- if (r.__sid !== sessionId)
204
+ const attributed = judged.row.__principal;
205
+ if (attributed !== undefined && attributed !== null && typeof attributed !== "string") {
206
+ this.disclose({ sessionId, path: full, reason: "principal metadata is not a string" });
186
207
  continue;
187
- const { __sid: _sid, __principal, ...rules } = r;
188
- void _sid;
189
- out.push({ principal: __principal ?? undefined, rules: rules });
208
+ }
209
+ out.push({ principal: attributed ?? undefined, rules: shaped.rules });
190
210
  }
191
211
  return out;
192
212
  }
@@ -212,21 +232,25 @@ export class FileSessionPolicyStore {
212
232
  catch {
213
233
  continue;
214
234
  }
215
- let parsed;
216
- try {
217
- parsed = JSON.parse(raw);
218
- }
219
- catch {
235
+ const judged = judgeSessionPolicyRowObject(raw);
236
+ if ("corrupt" in judged) {
237
+ if (judged.corrupt.grade !== "unparseable")
238
+ continue;
220
239
  const tornMarkPath = join(this.dir, `${file.slice(0, -".json".length)}${MARK_SUFFIX}`);
221
240
  try {
222
241
  const tornMark = readSessionPolicyMark(tornMarkPath);
223
- if (tornMark !== null && tornMark.__sid === sessionId) {
224
- this.writeMarkFile(tornMarkPath, tornMark.__hwm, sessionId, tornMark.__principal ?? null, (tornMark.__gen ?? 0) + 1);
242
+ if (tornMark === null) {
243
+ this.writeMark(tornMarkPath, { __hwm: 0, __gen: 0, __lineage: "lost" });
244
+ this.disclose({ sessionId, path: join(this.dir, file), reason: "erase purged a torn row that had no high-water mark: the key's lineage is lost, writes are refused until the mark is deleted" });
245
+ }
246
+ else {
247
+ this.writeMark(tornMarkPath, { ...tornMark, __gen: (tornMark.__gen ?? 0) + 1 });
225
248
  }
226
249
  }
227
250
  catch (err) {
228
251
  if (!(err instanceof SessionPolicyError))
229
252
  throw err;
253
+ this.disclose({ sessionId, path: join(this.dir, file), reason: `erase purged a torn row whose high-water mark is corrupt (${err.message}); the mark is left for the operator` });
230
254
  }
231
255
  try {
232
256
  unlinkSync(join(this.dir, file));
@@ -237,9 +261,7 @@ export class FileSessionPolicyStore {
237
261
  }
238
262
  continue;
239
263
  }
240
- if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
241
- continue;
242
- const row = parsed;
264
+ const row = judged.row;
243
265
  if (row.__sid !== sessionId)
244
266
  continue;
245
267
  const markPath = join(this.dir, `${file.slice(0, -".json".length)}${MARK_SUFFIX}`);
@@ -12,10 +12,15 @@
12
12
  * tools' execute legs judge nothing: what they run is what this station admitted, and the admission only
13
13
  * re-establishes the SAME candidates (a symlink re-pointed while the approver was thinking) — it never
14
14
  * parses text or judges a word the station did not.
15
+ *
16
+ * That pipeline is written ONCE (`runShellReadStation`) and has two entries: the gate input above, and
17
+ * {@link judgeShellRead}, the offline face. The steps between stay private — the station's working, not
18
+ * its answer, and a caller composing them would be a second judgment of the same question.
15
19
  */
16
20
  import type { ExecutionEnv } from "../../internal/harness-types.js";
17
21
  import { violationDetails } from "./safety.js";
18
- import { type ShellReadBoundary, type ShellReadDisposition, type ShellReadFinding, type ShellReadSeat } from "./shell-read-judge.js";
22
+ import { type ShellReadPlan } from "./shell-read-plan.js";
23
+ import { type EstablishedRead, type ShellReadBoundary, type ShellReadDisposition, type ShellReadFinding, type ShellReadSeat } from "./shell-read-judge.js";
19
24
  import type { ReadRootGrantCandidate } from "../../core/tool-spec.js";
20
25
  /** The ONE mint of the containment card — every arm that refuses or asks on a containment finding (a refusal, a
21
26
  * mandated card's own card, a station that could not answer, the pre-spawn re-establishment) spells it here. */
@@ -85,6 +90,58 @@ export interface ShellReadStationOptions {
85
90
  /** The listed read verbs (decides whether a segment is read by verb slots or word by word). */
86
91
  readonly readers?: ReadonlySet<string>;
87
92
  }
93
+ /** What a consumer hands {@link judgeShellRead}: the command as written, the environment whose filesystem
94
+ * answers where its words land (symlinks resolved, patterns expanded — a `NodeExecutionEnv` over a real
95
+ * directory is what the gate itself runs on), the boundary it is judged against, and the trust-policy seat
96
+ * it is judged on. The working directory a relative operand starts from is the boundary's own `cwd`. */
97
+ export interface ShellReadJudgeInput {
98
+ readonly command: string;
99
+ readonly env: ExecutionEnv;
100
+ readonly boundary: ShellReadBoundary;
101
+ readonly seat: ShellReadSeat;
102
+ /** The listed read verbs — which words of a segment are read operands. Defaults to the classify face's. */
103
+ readonly readers?: ReadonlySet<string>;
104
+ /** The face named in a refusal sentence. Defaults to `Bash`. */
105
+ readonly tool?: string;
106
+ /** Handed to the ENVIRONMENT's own primitives (`canonicalPath`, `listDir`) while the reads are
107
+ * established; whether an abort stops one is the adapter's answer, and a walk short enough to finish
108
+ * first finishes. It is NOT a deadline and it does not reach `boundary.alsoInside`, whose signature
109
+ * carries no signal — see {@link judgeShellRead} on which layer owns the bound. */
110
+ readonly signal?: AbortSignal;
111
+ }
112
+ /** What {@link judgeShellRead} answers about ONE command. */
113
+ export interface ShellReadJudgment {
114
+ /** What the station read out of the command's text before any filesystem was consulted. */
115
+ readonly plan: ShellReadPlan;
116
+ /** Every read candidate the station established, in the plan's own order, nested plans included — each
117
+ * carrying how far it got (`EstablishedRead.established`) and the target it was judged on. */
118
+ readonly establishment: readonly EstablishedRead[];
119
+ readonly verdict: ShellReadVerdict;
120
+ /** The directory-scoped grant offer THIS STATION is willing to make — the verdict's candidate through the
121
+ * same validator the gate applies, so a candidate that validator drops is absent here as it is there. It
122
+ * is the station's offer, not the composed card's: over an ask another layer already raised, the fold
123
+ * adds the mandate and nothing else, so the offer does not land there. */
124
+ readonly offer?: ReadRootGrantCandidate;
125
+ }
126
+ /**
127
+ * The station's judgment of ONE command, OUTSIDE a run — the consumer face of everything above. Same plan,
128
+ * same establishment, same judge, same table, same verdict the gate fold reads: this is
129
+ * {@link createShellReadStation}'s own derivation with the admission bookkeeping left out, so a caller
130
+ * holding a command, an environment and a boundary gets the decision the gate would reach for them.
131
+ *
132
+ * An ENVIRONMENT question, not a text question: a symlink that leaves the roots, a pattern that expands
133
+ * onto a deny row, a traversal root that cannot be enumerated are answered by `env`, not guessed from the
134
+ * spelling. {@link judgeShellReadLexically} is the no-environment reading and not a substitute — it is
135
+ * LAXER where only a filesystem sees the escape (an in-root symlink pointing out vouches) and answers a
136
+ * pattern on its spelling alone, so the two diverge in both directions.
137
+ *
138
+ * It carries NO deadline. Bounding an environment-facing wait and substituting a fail-closed verdict is the
139
+ * gate FOLD's policy on the gate's one deadline seat; the station has never held one on either entry, and a
140
+ * second default here would decide the same number twice and turn a slow-but-correct filesystem's true
141
+ * answer into a fail-closed one unasked. A consumer wanting that posture races its own deadline and reads
142
+ * the row the fold reads: `SHELL_READ_DECISION.unestablishable[seat]`.
143
+ */
144
+ export declare function judgeShellRead(input: ShellReadJudgeInput): Promise<ShellReadJudgment>;
88
145
  /**
89
146
  * The station's verdict with NO environment — the lexical reading (what an isolated sandbox gets, and what a
90
147
  * pin or a lexical pre-read can compute): the same plan, the same judge, the same table, over the folded
@@ -7,6 +7,7 @@ import { bashClassifyDefaultAllow } from "./bash-readonly-classifier.js";
7
7
  import { planShellReads } from "./shell-read-plan.js";
8
8
  import { createShellReadEstablisher, establishShellReadsLexically } from "./shell-read-establish.js";
9
9
  import { allFindings, decideShellRead, foldShellReadFindings, judgeEstablishedPlan, readRootGrantCandidateOf, } from "./shell-read-judge.js";
10
+ import { normalizeReadRootCandidate } from "../../core/read-root-candidate.js";
10
11
  import { wordsOf, lexShellText } from "./bash-lexer.js";
11
12
  function containmentDetails(paths) {
12
13
  return { type: "readonly_out_of_root", code: "readonly_out_of_root", paths };
@@ -113,6 +114,48 @@ function judgePlan(plan, established, boundary) {
113
114
  return nested === undefined ? { plan: n, segments: [] } : judgePlan(n, nested, boundary);
114
115
  });
115
116
  }
117
+ async function applyAlsoInside(judged, boundary) {
118
+ const alsoInside = boundary.alsoInside;
119
+ if (alsoInside === undefined)
120
+ return judged;
121
+ const rescued = new Set();
122
+ for (const f of allFindings(judged)) {
123
+ if (f.kind !== "outside_roots" || f.read.target === undefined || rescued.has(f.read.target))
124
+ continue;
125
+ if (await alsoInside(f.read.target))
126
+ rescued.add(f.read.target);
127
+ }
128
+ if (rescued.size === 0)
129
+ return judged;
130
+ const rewrite = (j) => ({
131
+ plan: j.plan,
132
+ segments: j.segments.map((s) => ({
133
+ segment: s.segment,
134
+ findings: s.findings.map((f) => (f.kind === "outside_roots" && f.read.target !== undefined && rescued.has(f.read.target) ? { ...f, kind: "none", containment: "inside" } : f)),
135
+ nested: s.nested.map(rewrite),
136
+ })),
137
+ });
138
+ return rewrite(judged);
139
+ }
140
+ async function runShellReadStation(tool, command, declared, seat, readers, establisher, signal) {
141
+ const plan = planShellReads(command, declarationsOf(declared, readers));
142
+ const established = await establisher.establish(plan, declared, signal);
143
+ const boundary = { ...declared, roots: established.roots };
144
+ const judged = await applyAlsoInside(judgePlan(plan, established, boundary), boundary);
145
+ const verdict = verdictOf(tool, seat, foldShellReadFindings(judged), judged, boundary);
146
+ return { plan, established, judged, boundary, verdict };
147
+ }
148
+ export async function judgeShellRead(input) {
149
+ const readers = input.readers ?? new Set(bashClassifyDefaultAllow());
150
+ const run = await runShellReadStation(input.tool ?? "Bash", input.command, input.boundary, input.seat, readers, createShellReadEstablisher(input.env, input.boundary.roots), input.signal);
151
+ const offer = normalizeReadRootCandidate(run.verdict.readRootCandidate);
152
+ return {
153
+ plan: run.plan,
154
+ establishment: allFindings(run.judged).map((f) => f.read),
155
+ verdict: run.verdict,
156
+ ...(offer !== undefined ? { offer } : {}),
157
+ };
158
+ }
116
159
  export function judgeShellReadLexically(command, boundary, seat, opts) {
117
160
  const readers = opts?.readers ?? new Set(bashClassifyDefaultAllow());
118
161
  const plan = planShellReads(command, declarationsOf(boundary, readers));
@@ -141,29 +184,6 @@ export function createShellReadStation(opts) {
141
184
  }
142
185
  admissions.set(toolCallId, a);
143
186
  };
144
- const applyAlsoInside = async (judged, boundary) => {
145
- const alsoInside = boundary.alsoInside;
146
- if (alsoInside === undefined)
147
- return judged;
148
- const rescued = new Set();
149
- for (const f of allFindings(judged)) {
150
- if (f.kind !== "outside_roots" || f.read.target === undefined || rescued.has(f.read.target))
151
- continue;
152
- if (await alsoInside(f.read.target))
153
- rescued.add(f.read.target);
154
- }
155
- if (rescued.size === 0)
156
- return judged;
157
- const rewrite = (j) => ({
158
- plan: j.plan,
159
- segments: j.segments.map((s) => ({
160
- segment: s.segment,
161
- findings: s.findings.map((f) => (f.kind === "outside_roots" && f.read.target !== undefined && rescued.has(f.read.target) ? { ...f, kind: "none", containment: "inside" } : f)),
162
- nested: s.nested.map(rewrite),
163
- })),
164
- });
165
- return rewrite(judged);
166
- };
167
187
  const check = {
168
188
  judge(toolName, args, cwd, signal, callOpts) {
169
189
  if (!opts.tools.has(toolName))
@@ -176,16 +196,10 @@ export function createShellReadStation(opts) {
176
196
  return null;
177
197
  const seat = callOpts.seat ?? opts.seatOf(toolName);
178
198
  return (async () => {
179
- const plan = planShellReads(command, declarationsOf(boundary, readers));
180
- const establisher = establisherFor(boundary.roots);
181
- const established = await establisher.establish(plan, boundary, signal);
182
- const judgedBoundary = { ...boundary, roots: established.roots };
183
- const judged = await applyAlsoInside(judgePlan(plan, established, judgedBoundary), judgedBoundary);
184
- const fold = foldShellReadFindings(judged);
185
- const verdict = verdictOf(toolName, seat, fold, judged, judgedBoundary);
186
- if (verdict.disposition !== "refuse" && callOpts.toolCallId !== undefined)
187
- remember(callOpts.toolCallId, { toolName, boundary: judgedBoundary, established, command });
188
- return verdict;
199
+ const run = await runShellReadStation(toolName, command, boundary, seat, readers, establisherFor(boundary.roots), signal);
200
+ if (run.verdict.disposition !== "refuse" && callOpts.toolCallId !== undefined)
201
+ remember(callOpts.toolCallId, { toolName, boundary: run.boundary, established: run.established, command });
202
+ return run.verdict;
189
203
  })();
190
204
  },
191
205
  unanswered(toolName, cause) {
@@ -281,7 +281,9 @@ export function createShellReadEstablisher(env, readRoots) {
281
281
  return { paths: frontier, canonical: canonicalOfMatch };
282
282
  };
283
283
  const enumerateTraversal = async (rootCanonical, signal) => {
284
- const members = [];
284
+ const found = [];
285
+ const listedDirs = new Set();
286
+ let root;
285
287
  const stack = [{ dir: rootCanonical, spell: rootCanonical }];
286
288
  const visited = new Set([rootCanonical]);
287
289
  let dirsUsed = 0;
@@ -291,18 +293,23 @@ export function createShellReadEstablisher(env, readRoots) {
291
293
  return { unverifiable: `"${rootCanonical}" holds more than ${MAX_GLOB_DIRS} directories, too many to enumerate to verify what a recursive read would open` };
292
294
  const listed = await env.listDir(dir, signal);
293
295
  if (!listed.ok) {
296
+ if (dir === rootCanonical && listed.error.code === "not_directory")
297
+ root = "file";
294
298
  if (listed.error.code === "not_directory" || listed.error.code === "not_found")
295
299
  continue;
296
300
  return { unverifiable: `"${dir}" cannot be enumerated to verify what a recursive read would open (${listed.error.code})` };
297
301
  }
302
+ listedDirs.add(dir);
303
+ if (dir === rootCanonical)
304
+ root = "directory";
298
305
  for (const entry of listed.value) {
299
- if (members.length >= MAX_GLOB_MATCHES)
306
+ if (found.length >= MAX_GLOB_MATCHES)
300
307
  return { unverifiable: `"${rootCanonical}" holds more than ${MAX_GLOB_MATCHES} entries, too many to enumerate to verify what a recursive read would open` };
301
308
  const path = joinUnder(spell, entry.name);
302
309
  const canonicalPath = joinUnder(dir, entry.name);
303
310
  if (entry.kind === "symlink") {
304
311
  const canon = await env.canonicalPath(canonicalPath, signal);
305
- members.push({ path, ...(canon.ok ? { canonical: canon.value } : {}), symlink: true });
312
+ found.push({ path, ...(canon.ok ? { canonical: canon.value } : {}), symlink: true });
306
313
  if (canon.ok && !visited.has(canon.value)) {
307
314
  visited.add(canon.value);
308
315
  stack.push({ dir: canon.value, spell: path });
@@ -316,10 +323,11 @@ export function createShellReadEstablisher(env, readRoots) {
316
323
  }
317
324
  continue;
318
325
  }
319
- members.push({ path, canonical: canonicalPath, symlink: false });
326
+ found.push({ path, canonical: canonicalPath, symlink: false });
320
327
  }
321
328
  }
322
- return { members };
329
+ const members = found.map(({ path, canonical, symlink }) => ({ path, ...(canonical !== undefined ? { canonical } : {}), node: (symlink && canonical !== undefined && listedDirs.has(canonical) ? "directory" : "file") }));
330
+ return { ...(root !== undefined ? { root } : {}), members };
323
331
  };
324
332
  const canonicalReadOf = async (candidate, spelling, spellings, established, origin, canonicalKnown, signal) => {
325
333
  const real = canonicalKnown !== undefined ? { value: canonicalKnown, exact: true } : await resolve(spelling, signal);
@@ -338,16 +346,19 @@ export function createShellReadEstablisher(env, readRoots) {
338
346
  for (const candidate of judgedCandidatesOf(segment)) {
339
347
  const traversal = candidate.provenance === "traversal_root";
340
348
  const land = async (read, spellings, folded) => {
341
- out.push(read);
342
- if (!traversal || (read.established !== "canonical" && read.established !== "expansion") || read.target === undefined)
349
+ if (!traversal || (read.established !== "canonical" && read.established !== "expansion") || read.target === undefined) {
350
+ out.push(read);
343
351
  return;
352
+ }
344
353
  const walkedTree = await enumerateTraversal(read.target, signal);
345
354
  if ("unverifiable" in walkedTree) {
355
+ out.push(read);
346
356
  out.push({ candidate, established: "unestablishable", spellings, origin: folded, cause: walkedTree.unverifiable });
347
357
  return;
348
358
  }
359
+ out.push({ ...read, ...(walkedTree.root !== undefined ? { node: walkedTree.root } : {}) });
349
360
  for (const m of walkedTree.members)
350
- out.push(await canonicalReadOf(candidate, m.path, [m.path], "expansion", folded, m.canonical, signal));
361
+ out.push({ ...(await canonicalReadOf(candidate, m.path, [m.path], "expansion", folded, m.canonical, signal)), node: m.node });
351
362
  };
352
363
  for (const lexical of lexicalReadsOf(candidate, boundary)) {
353
364
  if (lexical.established !== "lexical") {
@@ -133,6 +133,9 @@ export interface EstablishedRead {
133
133
  readonly spellings: readonly string[];
134
134
  /** The pattern or traversal root an `expansion` came from, as folded. */
135
135
  readonly origin?: string;
136
+ /** What the environment said the target IS, where a read asked — only a traversal asks (it lists the root and
137
+ * every member it walks; a link's answer is its target's). Absent = nothing asked; a plain read never lists. */
138
+ readonly node?: "directory" | "file";
136
139
  /** Why the candidate could not be established. */
137
140
  readonly cause?: string;
138
141
  }
@@ -135,7 +135,9 @@ export function readRootGrantCandidateOf(judged, boundary) {
135
135
  return undefined;
136
136
  if (paths.some(hasWin32AliasedComponent))
137
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));
138
+ const readsDirectory = new Set(outside
139
+ .filter((f) => (f.read.candidate.provenance === "traversal_root" && f.read.node !== "file") || f.read.candidate.provenance === "cd_target")
140
+ .map((f) => f.read.target));
139
141
  const dirOf = (path) => (readsDirectory.has(path) ? path : parentDirLexically(path));
140
142
  let candidate = dirOf(paths[0]);
141
143
  for (const path of paths.slice(1)) {
@@ -1,5 +1,5 @@
1
1
  import { lexShellText, substitutionsInHeredocBody, wordsOf, literalTildeSpelling, SUBSTITUTION_PLACEHOLDER, } from "./bash-lexer.js";
2
- import { LAUNCHER_TABLE, SHELL_BLOCK_WORDS, programBasename, programPositionRunsShell, readProgramPosition, shellScanDeclineOf, } from "./bash-program-position.js";
2
+ import { LAUNCHER_TABLE, SHELL_BLOCK_WORDS, programBasename, programPositionRunsShell, readProgramPosition, shellScanDeclineOf, writtenAssignmentPrefix, } from "./bash-program-position.js";
3
3
  import { ASSIGNMENT_VALUE_NOT_PATHS, INTERPRETER_INLINE_PROGRAMS, NO_PATH_OPERAND_COMMANDS, POLL_LOOP_MAX_BEATS, RECURSIVE_READ_FORMS, SHELL_REENTRY_PROGRAM_RE, UNMODELLED_BASE_MOVERS, argvProgramOf, assignmentWordPayload, attachedOptionPayloads, foldHomeVariableSpelling, resolveOperandLexically, hasUnquotedExpansionMetachar, hasUnquotedGlobMetachar, inlineProgramClause, interpreterKey, isPathShapedToken, readVerbOptionSlot, sedScriptSlotFiles, segmentSelectsRecursiveRead, slotValueCandidates, suppliesFirstOperandSlot, verbFirstOperandSlot, verbOptionsArePositionIndependent, } from "./bash-readonly-classifier.js";
4
4
  import { isAbsolutePathForm } from "./safety.js";
5
5
  export const READ_CANDIDATE_PROVENANCES = [
@@ -241,8 +241,9 @@ function segmentFacts(segment, p, decl, nestedPlan) {
241
241
  const basename = program?.basename;
242
242
  const listed = basename !== undefined && (decl.readers.has(basename) || basename === "cd");
243
243
  const verb = program === undefined ? undefined : { name: program.name, basename: program.basename, listed };
244
+ const envOpens = basename !== undefined && !NO_PATH_OPERAND_COMMANDS.has(basename);
244
245
  for (const k of pos.assignments)
245
- pushAssignment(reads, toks, k, "env_assignment");
246
+ pushAssignment(reads, toks.folded[k], toks.raw[k], "env_assignment", envOpens);
246
247
  for (const l of pos.launchers) {
247
248
  const raw = toks.raw[l.index];
248
249
  const folded = toks.folded[l.index];
@@ -286,7 +287,7 @@ function segmentFacts(segment, p, decl, nestedPlan) {
286
287
  continue;
287
288
  }
288
289
  if (assignmentWordPayload(word, toks.raw[j]) !== undefined) {
289
- pushAssignment(reads, toks, j, "env_assignment");
290
+ pushAssignment(reads, word, toks.raw[j], "env_assignment", envOpens);
290
291
  continue;
291
292
  }
292
293
  reads.push(bare(raw, word, "unlisted_word"));
@@ -463,15 +464,17 @@ function redirectFact(r) {
463
464
  return { op: r.op, direction: "in" };
464
465
  return { op: r.op, direction: "out", target: bare(r.target.raw, r.target.folded, "write_redirect_target") };
465
466
  }
466
- function pushAssignment(reads, toks, k, provenance) {
467
- const word = toks.folded[k];
468
- const payload = assignmentWordPayload(word, toks.raw[k]);
469
- if (payload === undefined)
467
+ function pushAssignment(reads, folded, raw, provenance, opensValue = true) {
468
+ const payload = assignmentWordPayload(folded, raw);
469
+ const prefix = writtenAssignmentPrefix(folded)?.prefix;
470
+ if (payload === undefined || prefix === undefined)
470
471
  return;
471
- if (payload.kind === "unresolved")
472
- reads.push({ spelled: word, raw: toks.raw[k], provenance, expands: "unresolvable" });
472
+ const v = { spelled: folded.slice(prefix.length), raw: raw.startsWith(prefix) ? raw.slice(prefix.length) : raw, provenance };
473
+ const unsettled = provenance !== "env_assignment" ? { ...v, expands: "unresolvable" } : v.spelled.startsWith("~") && !v.spelled.includes(":") ? bare(v.raw, v.spelled, provenance) : undefined;
474
+ if (payload.kind === "unresolved" && opensValue && unsettled !== undefined)
475
+ reads.push(unsettled);
473
476
  if (payload.value !== undefined && payload.value !== SUBSTITUTION_PLACEHOLDER)
474
- reads.push(bare(toks.raw[k], payload.value, provenance));
477
+ reads.push(bare(raw, payload.value, provenance));
475
478
  }
476
479
  function unlistedWords(reads, toks, from, launcherIndices, provenance, program) {
477
480
  const argv = program !== undefined ? { program: program.basename, from: program.index } : argvProgramOf(toks);
@@ -565,9 +568,9 @@ function unlistedWords(reads, toks, from, launcherIndices, provenance, program)
565
568
  }
566
569
  reads.push(bare(raw, word, j === argvStart ? "program_path" : provenance));
567
570
  if (j < argvStart)
568
- pushAssignment(reads, toks, j, "env_assignment");
571
+ pushAssignment(reads, word, raw, "env_assignment", slotVerb !== undefined && !noPathOperands);
569
572
  else if (!declaredNotPaths)
570
- pushAssignment(reads, toks, j, provenance);
573
+ pushAssignment(reads, word, raw, provenance);
571
574
  }
572
575
  if (slotVerb !== undefined && argv !== undefined && segmentSelectsRecursiveRead(slotVerb, toks.folded.slice(argvStart + 1))) {
573
576
  const roots = positional.length > 0 ? positional : stdinOperand ? [] : [{ word: ".", raw: "." }];
@@ -740,11 +743,7 @@ function listedVerbCandidates(name, args, rawArgs, reads) {
740
743
  positional.push({ word: t, raw: rawArgs[k] });
741
744
  push(k, t, "operand");
742
745
  if (!ASSIGNMENT_VALUE_NOT_PATHS.has(name)) {
743
- const payload = assignmentWordPayload(t, rawArgs[k]);
744
- if (payload?.kind === "unresolved")
745
- reads.push({ spelled: t, raw: rawArgs[k], provenance: "operand", expands: "unresolvable" });
746
- if (payload?.value !== undefined)
747
- push(k, payload.value, "operand");
746
+ pushAssignment(reads, t, rawArgs[k], "operand");
748
747
  }
749
748
  }
750
749
  for (let k = 0; k < args.length; k++) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/core",
3
- "version": "7.26.0",
3
+ "version": "7.26.2",
4
4
  "description": "Stateless, task-oriented AI agent core",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",