@sema-agent/core 7.49.1 → 7.49.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 (33) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/dist/core/arg-summary.js +23 -5
  3. package/dist/core/credential-vocabulary.js +19 -2
  4. package/dist/core/gate-fold.js +2 -2
  5. package/dist/core/runner/prepare-gate-stations.js +2 -2
  6. package/dist/core/runner/prepare-lsp.d.ts +4 -4
  7. package/dist/core/runner/prepare-lsp.js +2 -2
  8. package/dist/core/runner/prepare-safety-scan.js +10 -6
  9. package/dist/core/runner-deps.d.ts +2 -2
  10. package/dist/core/task-spec.d.ts +2 -2
  11. package/dist/core/tool-catalog-entries.js +1 -1
  12. package/dist/core/tool-conformance.js +2 -1
  13. package/dist/core/tool-roster.d.ts +11 -6
  14. package/dist/core/tool-roster.js +8 -4
  15. package/dist/core/write-protect.d.ts +3 -1
  16. package/dist/core/write-protect.js +4 -5
  17. package/dist/tools/fs/bash-readonly-classifier.d.ts +2 -2
  18. package/dist/tools/fs/bash-readonly-classifier.js +5 -3
  19. package/dist/tools/fs/read-deny-home.d.ts +5 -1
  20. package/dist/tools/fs/read-deny-home.js +40 -19
  21. package/dist/tools/fs/shell-read-check.d.ts +1 -1
  22. package/dist/tools/fs/shell-read-check.js +29 -15
  23. package/dist/tools/fs/shell-read-environment.d.ts +74 -0
  24. package/dist/tools/fs/shell-read-environment.js +71 -0
  25. package/dist/tools/fs/shell-read-establish.d.ts +8 -4
  26. package/dist/tools/fs/shell-read-establish.js +34 -36
  27. package/dist/tools/fs/shell-read-identity.d.ts +3 -2
  28. package/dist/tools/fs/shell-read-identity.js +5 -3
  29. package/dist/tools/fs/shell-read-judge.d.ts +69 -18
  30. package/dist/tools/fs/shell-read-judge.js +27 -14
  31. package/package.json +1 -1
  32. package/wire-changes/core@7.49.1.json +57 -0
  33. package/wire-changes/core@7.49.2.json +138 -0
@@ -199,13 +199,18 @@ export async function whereItsAnsweredPrefixLeads(env, at, family = pathFamilyOf
199
199
  };
200
200
  return await withGateTimeout(walk(), DEFAULT_GATE_AWAIT_TIMEOUT_MS, signal, "ruleRowHead").catch(() => undefined);
201
201
  }
202
- const NO_CHAIN = { ancestors: [], whole: false };
202
+ const NO_CHAIN = { ancestors: [], whole: false, unread: [] };
203
+ const unreadChain = (at) => ({ ancestors: [], whole: false, unread: [at] });
204
+ const identityUnread = (id) => id === undefined || (!id.ok && id.error.code !== "not_found" && id.error.code !== "not_directory" && id.error.code !== "not_supported");
203
205
  async function chainAbove(env, identity, canonical, signal, deadline) {
204
206
  let own;
205
207
  const ancestors = [];
208
+ const unread = [];
206
209
  let whole = true;
207
210
  for (let dir = normalizeAbsPathLexically(canonical), first = true;; first = false) {
208
211
  const id = deadline === undefined ? await identityOf(env, identity, dir, signal) : await until(identityOf(env, identity, dir, signal), deadline, signal);
212
+ if (identityUnread(id))
213
+ unread.push(dir);
209
214
  if (id === undefined && deadline !== undefined && Date.now() >= deadline) {
210
215
  whole = false;
211
216
  break;
@@ -221,7 +226,7 @@ async function chainAbove(env, identity, canonical, signal, deadline) {
221
226
  break;
222
227
  dir = parent;
223
228
  }
224
- return { ...(own !== undefined ? { own } : {}), ancestors, whole };
229
+ return { ...(own !== undefined ? { own } : {}), ancestors, whole, unread };
225
230
  }
226
231
  async function identityOf(env, identity, path, signal) {
227
232
  try {
@@ -238,13 +243,13 @@ async function readAuxiliaryHome(env, home, opts, signal) {
238
243
  if (spelled?.canonical === undefined) {
239
244
  const answered = spelled?.answered === true;
240
245
  const linked = opts.configRoot === true && opts.entries !== undefined ? configRootSpelledLinks(opts.entries, [home]) : new Map();
241
- reading = { place: [home], chain: answered ? { ancestors: [], whole: true } : NO_CHAIN, linked, answered };
246
+ reading = { place: [home], chain: answered ? { ancestors: [], whole: true, unread: [] } : unreadChain(home), linked, answered };
242
247
  }
243
248
  else {
244
249
  const place = spelled.spellings;
245
250
  const identity = env.fileIdentity;
246
251
  const [chain, heads] = await Promise.all([
247
- typeof identity === "function" ? chainAbove(env, identity, spelled.canonical, signal, deadline) : Promise.resolve({ ancestors: [], whole: true }),
252
+ typeof identity === "function" ? chainAbove(env, identity, spelled.canonical, signal, deadline) : Promise.resolve({ ancestors: [], whole: true, unread: [] }),
248
253
  opts.entries === undefined
249
254
  ? Promise.resolve({ linked: new Map(), whole: true })
250
255
  : readDenyLinksOf(env, opts.entries, opts.configRoot === true ? { at: "unanchored", roots: place } : { at: "home", homes: place }, signal, deadline),
@@ -257,16 +262,16 @@ async function readAuxiliaryHome(env, home, opts, signal) {
257
262
  }
258
263
  function homeViewSourceOver(env, auxiliary) {
259
264
  const declared = declaredHomeOf(env);
260
- const identity = env.fileIdentity;
261
265
  let declaredChain;
262
- const readDeclared = () => {
266
+ const readDeclared = (at) => {
267
+ const identity = at.fileIdentity;
263
268
  if (declared === undefined || typeof identity !== "function")
264
269
  return Promise.resolve(NO_CHAIN);
265
270
  if (declaredChain === undefined) {
266
271
  const reading = withGateTimeout((async () => {
267
- const spelled = await homeSpellingsOf(env, declared);
268
- return spelled.canonical === undefined ? NO_CHAIN : await chainAbove(env, identity, spelled.canonical);
269
- })(), DEFAULT_GATE_AWAIT_TIMEOUT_MS, undefined, "homeChain").catch(() => NO_CHAIN);
272
+ const spelled = await homeSpellingsOf(at, declared);
273
+ return spelled.canonical === undefined ? (spelled.answered ? NO_CHAIN : unreadChain(declared)) : await chainAbove(at, identity, spelled.canonical);
274
+ })(), DEFAULT_GATE_AWAIT_TIMEOUT_MS, undefined, "homeChain").catch(() => unreadChain(declared));
270
275
  declaredChain = reading;
271
276
  void reading.then((c) => {
272
277
  if (!c.whole && declaredChain === reading)
@@ -276,10 +281,16 @@ function homeViewSourceOver(env, auxiliary) {
276
281
  return declaredChain;
277
282
  };
278
283
  return {
279
- views(signal) {
284
+ views(signal, judgmentEnv) {
285
+ const at = judgmentEnv ?? env;
286
+ const identity = at.fileIdentity;
280
287
  let index;
288
+ const unread = [];
281
289
  const indexOf = () => (index ??= (async () => {
282
- const chains = [await readDeclared(), ...(await Promise.all(auxiliary)).map((a) => a.chain)];
290
+ const chains = [await readDeclared(at), ...(await Promise.all(auxiliary)).map((a) => a.chain)];
291
+ for (const at of chains.flatMap((c) => c.unread))
292
+ if (!unread.includes(at))
293
+ unread.push(at);
283
294
  const out = new Map();
284
295
  for (const { own } of chains)
285
296
  if (own !== undefined && !out.has(own.id))
@@ -300,15 +311,15 @@ function homeViewSourceOver(env, auxiliary) {
300
311
  if (index === undefined || typeof identity !== "function")
301
312
  return undefined;
302
313
  if (!leafIsFile) {
303
- const id = await identityOf(env, identity, path, signal);
304
- if (id === undefined)
305
- return undefined;
306
- if (id.ok) {
314
+ const id = await identityOf(at, identity, path, signal);
315
+ if (identityUnread(id))
316
+ return void unread.push(path);
317
+ if (id?.ok === true) {
307
318
  const at = index.get(id.value);
308
319
  if (at !== undefined)
309
320
  return at;
310
321
  }
311
- else if (id.error.code !== "not_found" && id.error.code !== "not_directory")
322
+ else if (id?.error.code === "not_supported")
312
323
  return undefined;
313
324
  }
314
325
  const parent = normalizeAbsPathLexically(`${path}/..`);
@@ -329,6 +340,7 @@ function homeViewSourceOver(env, auxiliary) {
329
340
  const view = await viewOf(spelled, opts?.leaf === "file");
330
341
  return view === undefined || view === spelled ? undefined : view;
331
342
  },
343
+ unread,
332
344
  };
333
345
  },
334
346
  };
@@ -357,7 +369,7 @@ export function homeViewsAt(env, homes) {
357
369
  return homeViewSourceOver(env, readAuxiliaryHomes(env, homes, {}));
358
370
  }
359
371
  export function homeViewsOf(env, judge, signal) {
360
- return (judge?.homeViews ?? homeViewsAt(env, [])).views(signal);
372
+ return (judge?.homeViews ?? homeViewsAt(env, [])).views(signal, env);
361
373
  }
362
374
  export function carryHomeViews(judge, source) {
363
375
  return { ...judge, homeViews: source };
@@ -421,9 +433,18 @@ export function readDenyPlacedLater(unplaced, place) {
421
433
  return current;
422
434
  };
423
435
  const homeViews = {
424
- views(signal) {
436
+ views(signal, judgmentEnv) {
425
437
  let views;
426
- return { of: async (path, opts) => await (await (views ??= settled().then((m) => m.homeViews.views(signal)))).of(path, opts) };
438
+ const unread = [];
439
+ return {
440
+ of: async (path, opts) => {
441
+ const inner = await (views ??= settled().then((m) => m.homeViews.views(signal, judgmentEnv)));
442
+ const view = await inner.of(path, opts);
443
+ unread.splice(0, unread.length, ...inner.unread);
444
+ return view;
445
+ },
446
+ unread,
447
+ };
427
448
  },
428
449
  };
429
450
  const matcher = {
@@ -225,7 +225,7 @@ export interface ShellReadJudgment {
225
225
  * gate FOLD's policy on the gate's one deadline seat; the station has never held one on either entry, and a
226
226
  * second default here would decide the same number twice and turn a slow-but-correct filesystem's true
227
227
  * answer into a fail-closed one unasked. A consumer wanting that posture races its own deadline and reads
228
- * the row the fold reads: `SHELL_READ_DECISION.unestablishable[seat]`.
228
+ * the row the gate's fallback reads: `SHELL_READ_DECISION.unanswered[seat]`.
229
229
  */
230
230
  export declare function judgeShellRead(input: ShellReadJudgeInput): Promise<ShellReadJudgment>;
231
231
  /**
@@ -1,3 +1,4 @@
1
+ import { observeEnvironment } from "./shell-read-environment.js";
1
2
  import { errorResult } from "../../core/tools.js";
2
3
  import { BASH_READONLY_CONFINEMENT_NOTE } from "./fs-shared.js";
3
4
  import { isAbsoluteForFamily, normalizeAbsPathLexically, pathFamilyOf, violationDetails, withinAnyRoot } from "./safety.js";
@@ -9,7 +10,7 @@ import { createShellReadEstablisher, establishShellReadsLexically } from "./shel
9
10
  import { declaredReadDenyHomes, homeViewsAt } from "./read-deny-home.js";
10
11
  import { programArgs } from "./shell-read-write-targets.js";
11
12
  import { programRow, selectedOptions } from "./program-read-model.js";
12
- import { allFindings, anyFaceGuards, decideShellRead, decideShellReadForRedirects, denyRowAtOrBelow, foldShellReadFindings, judgeEstablishedPlan, readRootGrantCandidateOf, stationGuards, writeFaceGuards, } from "./shell-read-judge.js";
13
+ import { allFindings, anyFaceGuards, decideShellRead, denyRowAtOrBelow, foldShellReadFindings, judgeEstablishedPlan, readRootGrantCandidateOf, stationGuards, writeFaceGuards, } from "./shell-read-judge.js";
13
14
  import { seatOf } from "../../core/tool-name-readings.js";
14
15
  import { normalizeReadRootCandidate } from "../../core/read-root-candidate.js";
15
16
  import { argvOf, lexShellText } from "../../shell/lex.js";
@@ -21,7 +22,7 @@ function unestablishedDetails() {
21
22
  return { type: "read_unestablished", code: "read_unestablished" };
22
23
  }
23
24
  function findingDetails(finding, paths) {
24
- return finding === "unestablishable" || finding === "declined" ? unestablishedDetails() : containmentDetails(paths);
25
+ return finding === "unestablishable" || finding === "declined" || finding === "unanswered" ? unestablishedDetails() : containmentDetails(paths);
25
26
  }
26
27
  const REMOVAL_COMMANDS = new Set(["rm", "rmdir"]);
27
28
  const WRITES_BENEATH = {
@@ -155,7 +156,7 @@ async function writeGuardsOf(judged, boundary, env, signal) {
155
156
  const dir = fold(parentOf(view));
156
157
  return writtenBy.some((paths, i) => i !== at && (i < at || concurrent) && paths.some((w) => withinAnyRoot([fold(w)], dir) || withinAnyRoot([dir], fold(w))));
157
158
  };
158
- const answer = async (view, key) => (await judge(view, { ...(signal !== undefined ? { signal } : {}), ...(key ? { canonical: true } : {}) })) ?? "clear";
159
+ const answer = async (view, key) => (await judge(view, { fs: env, ...(signal !== undefined ? { signal } : {}), ...(key ? { canonical: true } : {}) })) ?? "clear";
159
160
  const asked = [...[...asKey].map(([view, v]) => [view, v, true]), ...[...asSpelling].filter(([view]) => !landingKeys.has(view)).map(([view, v]) => [view, v, false])];
160
161
  let unplaced = false;
161
162
  let writeProtected = false;
@@ -207,12 +208,15 @@ function findingAccount(fold, boundary) {
207
208
  case "outside_roots":
208
209
  return `${writes ? "writes" : "reads"} outside the allowed directories for this session: "${fitMiddle(w?.read.target ?? "?", ACCOUNT_PART_MAX)}" is not under ${allowed} ${NOT_AUTO_ALLOWED}`;
209
210
  case "unestablishable":
211
+ case "unanswered":
210
212
  if (writes && boundary.writeRoots === undefined)
211
213
  return `this command writes "${fitMiddle(w.read.target ?? w.read.candidate.spelled, ACCOUNT_PART_MAX)}", and this session declares no directories it may write ${NOT_AUTO_ALLOWED}`;
212
214
  if (writes)
213
215
  return `where this command writes could not be established${w.read.cause !== undefined ? `: ${fitCause(w.read.cause)}` : ""}, so it cannot be confirmed to write only inside the allowed directories for this session (${allowed}) ${NOT_AUTO_ALLOWED}`;
214
216
  if (w?.read.cause !== undefined)
215
217
  return `where this command reads could not be established: ${fitCause(w.read.cause)}, so it cannot be confirmed ${boundary.face === "open" ? "not to read a path the deny list names" : `to read only inside the allowed directories for this session (${allowed})`} ${NOT_AUTO_ALLOWED}`;
218
+ if (fold.cause !== undefined)
219
+ return `where this command reads could not be established: ${fitCause(fold.cause)}, so it cannot be confirmed ${boundary.face === "open" ? "not to read a path the deny list names" : `to read only inside the allowed directories for this session (${allowed})`} ${NOT_AUTO_ALLOWED}`;
216
220
  if (fold.declined?.baseMove !== undefined)
217
221
  return `"${fitMiddle(fold.declined.text.trim(), ACCOUNT_PART_MAX)}" moves the working directory somewhere this command's text does not settle ${NOT_AUTO_ALLOWED}`;
218
222
  if (fold.declined?.unreadProgramText !== undefined)
@@ -276,7 +280,7 @@ const DENY_REFUSAL_NOTE = "The read boundary judges the paths this command names
276
280
  "own pattern) reaches into the tree a recursive read walks.";
277
281
  function verdictOf(tool, seat, fold, judged, boundary) {
278
282
  const redirectsOnly = fold.finding === "outside_roots" && fold.unestablished !== true && outsideFindingsAreRedirections(judged);
279
- const disposition = decideShellReadForRedirects(fold.finding, seat, redirectsOnly);
283
+ const disposition = decideShellRead(fold.finding, seat, { redirectsOnly, narrowedReadFace: fold.unplacedRead === true && boundary.face !== "open" });
280
284
  if (disposition === "vouch")
281
285
  return { disposition, finding: fold.finding };
282
286
  const paths = outsidePathsOf(judged);
@@ -287,14 +291,14 @@ function verdictOf(tool, seat, fold, judged, boundary) {
287
291
  return { disposition, finding: fold.finding, ...writes, sentence: r.sentence, ...(r.details !== undefined ? { details: r.details } : {}) };
288
292
  }
289
293
  const grant = fold.finding === "outside_roots" ? readRootGrantCandidateOf(judged, boundary) : undefined;
290
- const containment = fold.finding === "outside_roots" || fold.finding === "unestablishable";
294
+ const containment = fold.finding === "outside_roots" || fold.finding === "unestablishable" || fold.finding === "unanswered";
291
295
  return {
292
296
  disposition,
293
297
  finding: fold.finding,
294
298
  ...writes,
295
299
  ...unestablished,
296
300
  sentence: findingAccount(fold, boundary),
297
- ...(disposition === "mandated" && containment ? { details: findingDetails(fold.finding, paths) } : {}),
301
+ ...((disposition === "mandated" && containment) || (disposition === "ordinary" && fold.finding === "unestablishable") ? { details: findingDetails(fold.finding, paths) } : {}),
298
302
  ...(grant !== undefined ? { readRootCandidate: { dir: grant.dir, ...(grant.covers !== undefined ? { covers: grant.covers } : {}), clearsThisAsk: true } } : {}),
299
303
  };
300
304
  }
@@ -325,6 +329,7 @@ async function applyAlsoInside(judged, boundary) {
325
329
  plan: j.plan,
326
330
  guards: j.guards,
327
331
  writeGuards: j.writeGuards,
332
+ ...(j.incomplete !== undefined ? { incomplete: j.incomplete } : {}),
328
333
  segments: j.segments.map((s) => ({
329
334
  segment: s.segment,
330
335
  findings: s.findings.map((f) => (outsideRead(f) && rescued.has(f.read.target) ? { ...f, kind: "none", containment: "inside" } : f)),
@@ -335,13 +340,23 @@ async function applyAlsoInside(judged, boundary) {
335
340
  }
336
341
  async function runShellReadStation(tool, command, declared, seat, readers, establisher, env, signal) {
337
342
  const plan = planShellReads(command, declarationsOf(declared, readers, env.cdPath));
338
- const established = await establisher.establish(plan, declared, signal);
343
+ const door = observeEnvironment(env);
344
+ const established = await establisher.establish(plan, declared, signal, door);
339
345
  const boundary = { ...declared, roots: established.roots };
340
- const judged = await applyAlsoInside(judgePlan(plan, established, boundary), boundary);
341
- const judgedVerdict = verdictOf(tool, seat, foldShellReadFindings(judged), judged, boundary);
346
+ const onPlan = await applyAlsoInside(judgePlan(plan, established, boundary), boundary);
347
+ const judgedNow = () => {
348
+ const unfinished = door.settled();
349
+ return unfinished.length > 0 ? { ...onPlan, incomplete: unfinished } : onPlan;
350
+ };
351
+ let judged = judgedNow();
352
+ let judgedVerdict = verdictOf(tool, seat, foldShellReadFindings(judged), judged, boundary);
353
+ const probesFrom = door.mark();
354
+ const guarded = judgedVerdict.disposition === "refuse" ? { unplaced: false, writeProtected: false } : await writeGuardsOf(judged, boundary, door.fs, signal);
355
+ door.silence(probesFrom);
356
+ judged = judgedNow();
357
+ judgedVerdict = verdictOf(tool, seat, foldShellReadFindings(judged), judged, boundary);
342
358
  const unplaced = unplacedOptionsOf(judged);
343
- const placed = writesPlacedOf(judged, boundary);
344
- const guarded = judgedVerdict.disposition === "refuse" ? { unplaced: false, writeProtected: false } : await writeGuardsOf(judged, boundary, env, signal);
359
+ const placed = judgedVerdict.finding !== "unanswered" && writesPlacedOf(judged, boundary);
345
360
  const unresolved = judgedVerdict.disposition !== "refuse" && runOrderOf(judged).runs.some((s) => s.findings.some((f) => f.read.written !== undefined && f.read.unresolved === true));
346
361
  const facts = { ...judgedVerdict, ...(guarded.writeProtected ? { writeProtected: true } : {}), ...(unresolved ? { targetUnresolved: true } : {}) };
347
362
  const verdict = placed && !guarded.unplaced ? { ...facts, writesPlaced: true } : unplaced !== undefined ? { ...facts, unplacedOptions: unplaced } : facts;
@@ -415,13 +430,12 @@ export function createShellReadStation(opts) {
415
430
  const boundary = opts.boundaryOf(member, undefined);
416
431
  if (boundary === undefined || !(anyFaceGuards(boundary) || boundary.writeTargetJudge !== undefined))
417
432
  return { disposition: "vouch", finding: "none" };
418
- const fold = { finding: "unestablishable" };
419
- const disposition = decideShellRead("unestablishable", seat);
433
+ const disposition = decideShellRead("unanswered", seat);
420
434
  const account = `${cause}, so where this command reads could not be established ${NOT_AUTO_ALLOWED}`;
421
435
  const details = unestablishedDetails();
422
436
  if (disposition === "refuse")
423
- return { disposition, finding: "unestablishable", sentence: `Error (${toolName}): ${account}. ${BASH_READONLY_CONFINEMENT_NOTE}`, details };
424
- return { disposition, finding: fold.finding, sentence: account, ...(disposition === "mandated" ? { details } : {}) };
437
+ return { disposition, finding: "unanswered", sentence: `Error (${toolName}): ${account}. ${BASH_READONLY_CONFINEMENT_NOTE}`, details };
438
+ return { disposition, finding: "unanswered", sentence: account, details };
425
439
  },
426
440
  };
427
441
  const admit = async (toolName, toolCallId, command, signal) => {
@@ -0,0 +1,74 @@
1
+ /**
2
+ * The ONE door between the shell read station's establisher and the execution environment's filesystem.
3
+ *
4
+ * The establisher answers "where does each word of this command land" by asking the environment (a resolution, a
5
+ * directory listing, a stat, a link read). An environment that does not give an answer — an error that is not a
6
+ * conclusive "not there", a lookup that came back inconclusive — and a bound the station itself stops at are the same
7
+ * fact for the verdict: the judge did not finish checking the command against the deny rows, so it cannot vouch for it. That
8
+ * fact is recorded HERE, at the door, once per judgment, not by each producer that happens to remember to mark it: the
9
+ * establisher and the write guards are handed an {@link ObservedFilesystem}, never the environment, so a lookup they make
10
+ * cannot escape the record.
11
+ *
12
+ * What is NOT a failure: a conclusive absence (`not_found`, `not_directory` — the answer is "nothing there") and an adapter
13
+ * that declares no filesystem surface (`not_supported` — it keeps the lexical reading). Everything else is.
14
+ *
15
+ * ONE decision, taken once per command: a resolver that did not declare its answers authoritative and answered NOTHING AT ALL,
16
+ * in the whole command, is read as an absence (the lexical reading every such adapter has had). It is a statement about the
17
+ * command, not about a word or a station: the walks and probes that found the resolver silent are marked
18
+ * ({@link ObservedEnvironment.silence}), and {@link ObservedEnvironment.settled} — read by whoever made the door, after EVERY
19
+ * lookup the command makes (the establishment, the write guards) has asked — takes the marked records back only when no lookup
20
+ * was ever answered. A resolver that answered once has shown it can; a silence after that is a failure, wherever it falls. A
21
+ * resolver that declared its answers authoritative is never read as an absence.
22
+ */
23
+ import type { ExecutionEnv } from "../../internal/harness-types.js";
24
+ /** One thing the station could not finish: an environment lookup that gave no answer, or a bound the station stopped at. */
25
+ export interface Incomplete {
26
+ /** The lookup (`canonicalPath`, `listDir`, `fileInfo`, `readLink`, `fileIdentity`) or `bound`. */
27
+ readonly op: string;
28
+ /** The sentence the card discloses ("… cannot be resolved (permission_denied)"). */
29
+ readonly detail: string;
30
+ }
31
+ /** Only {@link observeEnvironment} mints the brand: a bare environment is not assignable where a door is asked for. */
32
+ declare const OBSERVED: unique symbol;
33
+ /** The environment as the station's paths may use it: every path lookup the engine's resolvers make (`canonicalPath`,
34
+ * `listDir`, `fileInfo`, `readLink`, `exists`, `absolutePath`, `canonicalChain`, `fileIdentity`) goes through the door and is
35
+ * recorded when it gives no answer; everything else is the environment's own. It IS an `ExecutionEnv` to the resolvers
36
+ * (`canonicalizeTarget`, the home views, a deployment's guarded-row provider) — so none of them has to be narrowed — and
37
+ * nominal to the station: the establisher, the write guards, the write judgment and the home views are typed over it, so
38
+ * none of them can be handed the bare environment, nor a captured one. */
39
+ export type ObservedFilesystem = ExecutionEnv & {
40
+ readonly [OBSERVED]: true;
41
+ };
42
+ /** The observed door for one command. */
43
+ export interface ObservedEnvironment {
44
+ readonly fs: ObservedFilesystem;
45
+ /** Whether the environment DECLARES its `canonicalPath` the authority on where a path lands: its silence is then a failure
46
+ * that nothing takes back ({@link silence} does nothing). */
47
+ readonly authoritative: boolean;
48
+ /** What the door recorded, in the order it happened, as the command stands on it NOW — the one decision about what a
49
+ * silence means, taken on everything asked so far; call it after the last lookup of the command. */
50
+ settled(): Incomplete[];
51
+ /** The station stopped at its own bound (more directories or names than it enumerates, a link chain past its hop bound). */
52
+ bound(detail: string): void;
53
+ /** Records that came from outside the door and stand as they are: the declared roots' own lookup (`answered`: it was
54
+ * answered, which counts as the resolver's answer) and the home views a read could not be placed against. */
55
+ adopt(records: readonly Incomplete[], answered?: boolean): void;
56
+ /** Whether the resolver answered a path question (a path, or a conclusive "nothing there") at least once. */
57
+ answered(): boolean;
58
+ /** Where a walk's (or a probe's) records start: pass it to {@link silence} once the lookups are done. */
59
+ mark(): number;
60
+ /** The lookups since `from` found the resolver silent: their records are taken back by {@link settled} only when the
61
+ * resolver never answered anything in the command (and never where it is {@link authoritative}). */
62
+ silence(from: number): void;
63
+ }
64
+ /** The declared roots, canonicalized ONCE per establisher (through the same door), with what that lookup could not finish —
65
+ * merged into every establishment. `known`: the caller already holds them as canonical identities and they are used verbatim
66
+ * (re-resolving one that was since swapped for a link would substitute the link's target for the root that was approved). */
67
+ export declare function canonicalRootsOnce(env: ExecutionEnv, readRoots: readonly string[], known: boolean): (signal?: AbortSignal) => Promise<{
68
+ readonly roots: readonly string[];
69
+ readonly incomplete: readonly Incomplete[];
70
+ readonly answered: boolean;
71
+ }>;
72
+ /** Wrap `env` so every lookup that fails inconclusively is recorded; the result a caller sees is the environment's own. */
73
+ export declare function observeEnvironment(env: ExecutionEnv): ObservedEnvironment;
74
+ export {};
@@ -0,0 +1,71 @@
1
+ import { canonicalPathIsAuthoritative } from "../../core/remote-env.js";
2
+ const OBSERVED = Symbol("observedFilesystem");
3
+ const CONCLUSIVE = new Set(["not_found", "not_directory", "not_supported"]);
4
+ const ABSENCES = new Set(["not_found", "not_directory"]);
5
+ export function canonicalRootsOnce(env, readRoots, known) {
6
+ let once = known ? Promise.resolve({ roots: readRoots, incomplete: [], answered: false }) : undefined;
7
+ return (signal) => (once ??= (async () => {
8
+ const seen = observeEnvironment(env);
9
+ const roots = [];
10
+ for (const r of readRoots) {
11
+ const c = await seen.fs.canonicalPath(r, signal);
12
+ roots.push(c.ok ? c.value : r);
13
+ }
14
+ const result = { roots, incomplete: seen.settled(), answered: seen.answered() };
15
+ if (result.incomplete.length > 0)
16
+ once = undefined;
17
+ return result;
18
+ })());
19
+ }
20
+ export function observeEnvironment(env) {
21
+ const authoritative = canonicalPathIsAuthoritative(env);
22
+ const records = [];
23
+ let answers = 0;
24
+ const note = (op, path, what, code) => void records.push({ op, detail: `"${path}" ${what} (${code})`, silent: false });
25
+ const watch = (op, path, what, ask) => new Promise((resolve) => resolve(ask())).then((r) => {
26
+ if (op === "canonicalPath" && (r.ok || ABSENCES.has(r.error.code)))
27
+ answers += 1;
28
+ if (!r.ok && !CONCLUSIVE.has(r.error.code))
29
+ note(op, path, what, r.error.code);
30
+ return r;
31
+ }, (e) => {
32
+ note(op, path, what, e instanceof Error ? e.name : "threw");
33
+ throw e;
34
+ });
35
+ const look = (op, what, member) => member === undefined ? {} : { [op]: (path, signal) => watch(op, path, what, () => member.call(env, path, signal)) };
36
+ const overlay = {
37
+ ...look("canonicalPath", "cannot be resolved", env.canonicalPath),
38
+ ...look("listDir", "cannot be enumerated", env.listDir),
39
+ ...look("fileInfo", "cannot be inspected", env.fileInfo),
40
+ ...look("readLink", "cannot be read as a link", env.readLink),
41
+ ...look("exists", "cannot be checked", env.exists),
42
+ ...look("absolutePath", "cannot be made absolute", env.absolutePath),
43
+ ...look("canonicalChain", "its link chain cannot be read", env.canonicalChain),
44
+ ...look("fileIdentity", "cannot be identified", env.fileIdentity),
45
+ [OBSERVED]: true,
46
+ };
47
+ const fs = new Proxy(env, {
48
+ get: (target, prop) => {
49
+ if (Object.prototype.hasOwnProperty.call(overlay, prop))
50
+ return overlay[prop];
51
+ const value = Reflect.get(target, prop, target);
52
+ return typeof value === "function" ? value.bind(target) : value;
53
+ },
54
+ has: (target, prop) => Object.prototype.hasOwnProperty.call(overlay, prop) || Reflect.has(target, prop),
55
+ });
56
+ return {
57
+ fs,
58
+ authoritative,
59
+ settled: () => records.filter((r) => answers > 0 || !r.silent).map(({ op, detail }) => ({ op, detail })),
60
+ bound: (detail) => void records.push({ op: "bound", detail, silent: false }),
61
+ adopt: (adopted, answered = false) => {
62
+ for (const r of adopted)
63
+ records.push({ ...r, silent: false });
64
+ if (answered)
65
+ answers += 1;
66
+ },
67
+ mark: () => records.length,
68
+ silence: (from) => (authoritative ? undefined : void records.slice(from).forEach((r) => void (r.silent = true))),
69
+ answered: () => answers > 0,
70
+ };
71
+ }
@@ -17,8 +17,8 @@
17
17
  * target, so the target is established where the link lands (read one hop at a time, bounded) — or not at
18
18
  * all when the environment cannot read the link.
19
19
  * 2. An unanswerable filesystem manufactures no refusal on its own: a chain no ancestor answered degrades
20
- * to the lexical spelling on an environment that declares no authority for its resolver, and is
21
- * `unestablishable` on one that does (`canonicalPathIsAuthoritative`).
20
+ * to the lexical spelling on an environment that declares no authority for its resolver AND answered nothing
21
+ * in the whole command, and is `unestablishable` on one that does (`canonicalPathIsAuthoritative`).
22
22
  * 3. An ISOLATED environment stays lexical, disclosed rather than silently degraded: the sandbox is the
23
23
  * containment there and `canonicalPath` is an RPC into a filesystem that is not this host's
24
24
  * (`boundaryIsTheSandbox`). The split is the DECLARED isolation capability, never the remote interface's
@@ -38,6 +38,7 @@
38
38
  * call.
39
39
  */
40
40
  import type { ExecutionEnv } from "../../internal/harness-types.js";
41
+ import { type ObservedEnvironment } from "./shell-read-environment.js";
41
42
  import type { ShellReadPlan, ShellReadSegment } from "./shell-read-plan.js";
42
43
  import type { EstablishedRead, ShellReadBoundary } from "./shell-read-judge.js";
43
44
  import type { HomeViewSource } from "./read-deny-home.js";
@@ -64,8 +65,10 @@ export interface EstablishedPlan {
64
65
  export interface ShellReadEstablisher {
65
66
  /** Whether this environment is judged lexically (an isolated sandbox — property 3). */
66
67
  readonly lexicalOnly: boolean;
67
- /** Establish every read candidate of `plan` (nested plans included) against `boundary`'s roots. */
68
- establish(plan: ShellReadPlan, boundary: ShellReadBoundary, signal?: AbortSignal): Promise<EstablishedPlan>;
68
+ /** Establish every read candidate of `plan` (nested plans included) against `boundary`'s roots. Every lookup goes through
69
+ * `door` (a fresh one when the caller does not keep it): what the establishment could not finish is recorded THERE, and the
70
+ * caller that made the door reads it once, after everything else the command asks of the environment (the write guards) has asked. */
71
+ establish(plan: ShellReadPlan, boundary: ShellReadBoundary, signal?: AbortSignal, door?: ObservedEnvironment): Promise<EstablishedPlan>;
69
72
  /** Establish the same plan again and report every candidate whose canonical target moved — the pre-spawn
70
73
  * identity re-check. Candidates that were lexical or unestablishable are not compared: nothing was
71
74
  * established to move. */
@@ -84,6 +87,7 @@ export declare function establishShellReadsLexically(plan: ShellReadPlan, bounda
84
87
  * as declared (canonical by contract); they are resolved once more per instance because the comparison is
85
88
  * against `realpath` output on both sides — an aliased root would otherwise refuse every read under itself.
86
89
  * `homeViews`: the home views of the placement the deny seats were compiled at (read-deny-home.ts); absent, the declared home's.
90
+ * Each establishment asks the environment through ONE observed door ({@link observeEnvironment}), the caller's when it keeps one.
87
91
  */
88
92
  export declare function createShellReadEstablisher(env: ExecutionEnv, readRoots: readonly string[], opts?: {
89
93
  readonly rootsCanonical?: boolean;
@@ -1,5 +1,6 @@
1
+ import { canonicalRootsOnce, observeEnvironment } from "./shell-read-environment.js";
1
2
  import { anchorSpelling, normalizeAbsPathLexically, pathFamilyOf } from "./safety.js";
2
- import { boundaryIsTheSandbox, canonicalPathIsAuthoritative } from "../../core/remote-env.js";
3
+ import { boundaryIsTheSandbox } from "../../core/remote-env.js";
3
4
  import { resolveOperandSpellings } from "./bash-readonly-classifier.js";
4
5
  import { judgedCandidatesOf, sedBackupNames } from "./shell-read-write-targets.js";
5
6
  import { movedReads, withHomeViews } from "./shell-read-identity.js";
@@ -196,33 +197,44 @@ export function establishShellReadsLexically(plan, boundary) {
196
197
  }
197
198
  export function createShellReadEstablisher(env, readRoots, opts) {
198
199
  const lexicalOnly = boundaryIsTheSandbox(env);
199
- const resolverIsAuthoritative = canonicalPathIsAuthoritative(env);
200
+ const canonicalRoots = canonicalRootsOnce(env, readRoots, opts?.rootsCanonical === true);
201
+ const legOver = (seen) => establishLegOver(seen, readRoots);
202
+ return {
203
+ lexicalOnly,
204
+ establish: async (plan, boundary, signal, door = observeEnvironment(env)) => {
205
+ if (lexicalOnly)
206
+ return establishShellReadsLexically(plan, boundary);
207
+ const { roots, incomplete, answered } = await canonicalRoots(signal);
208
+ door.adopt(incomplete, answered);
209
+ return await withHomeViews(door, opts, await legOver(door)(plan, { ...boundary, roots }, roots, signal), signal);
210
+ },
211
+ reestablish: async (previous, boundary, signal) => {
212
+ if (lexicalOnly)
213
+ return { changed: [] };
214
+ const again = await legOver(observeEnvironment(env))(previous.plan, { ...boundary, roots: previous.roots }, previous.roots, signal);
215
+ return { changed: movedReads(previous, again) };
216
+ },
217
+ };
218
+ }
219
+ function establishLegOver(seen, readRoots) {
220
+ const env = seen.fs;
221
+ const resolverIsAuthoritative = seen.authoritative;
222
+ const bounded = (detail) => (seen.bound(detail), { unverifiable: detail });
200
223
  const family = pathFamilyOf({ root: readRoots[0] ?? "/" });
201
224
  const SEP = family === "posix" ? /\// : /[\\/]/;
202
225
  const TRAILING_SEP = family === "posix" ? /\/+$/ : /[\\/]+$/;
203
226
  const joinUnder = (dir, name) => `${dir.replace(TRAILING_SEP, "")}/${name}`;
204
- let rootsOnce = opts?.rootsCanonical === true ? Promise.resolve(readRoots) : undefined;
205
- const canonicalRoots = async (signal) => {
206
- rootsOnce ??= (async () => {
207
- const out = [];
208
- for (const r of readRoots) {
209
- const c = await env.canonicalPath(r, signal);
210
- out.push(c.ok ? c.value : r);
211
- }
212
- return out;
213
- })();
214
- return await rootsOnce;
215
- };
216
227
  const resolveWithoutParentSteps = async (p, signal, writeTarget = false, linkHops = WRITE_LINK_HOPS) => {
217
228
  const parts = p.split(SEP);
218
229
  const tail = [];
219
230
  let allAbsent = true;
231
+ const began = seen.mark();
220
232
  while (parts.length > 0) {
221
233
  const at = parts.join("/") || "/";
222
234
  const canon = await env.canonicalPath(at, signal);
223
235
  if (!canon.ok && canon.error.code === "not_found" && writeTarget && allAbsent) {
224
236
  const entry = await env.fileInfo(at, signal);
225
- const link = entry.ok && entry.value.kind === "symlink" && linkHops > 0 ? await env.readLink?.(at, signal) : undefined;
237
+ const link = entry.ok && entry.value.kind === "symlink" ? (linkHops > 0 ? await env.readLink?.(at, signal) : void bounded(`"${at}" is a link chain of more than ${WRITE_LINK_HOPS} links, too many to follow to verify where it lands`)) : undefined;
226
238
  if (link?.ok === true) {
227
239
  const landing = anchorSpelling(family, parts.slice(0, -1).join("/") || "/", link.value);
228
240
  return await resolve([landing, ...[...tail].reverse()].join("/"), signal, true, linkHops - 1);
@@ -239,7 +251,7 @@ export function createShellReadEstablisher(env, readRoots, opts) {
239
251
  allAbsent = false;
240
252
  tail.push(parts.pop());
241
253
  }
242
- return undefined;
254
+ return void seen.silence(began);
243
255
  };
244
256
  const parentOf = (canonical) => normalizeAbsPathLexically(`${canonical}/..`);
245
257
  const resolve = async (p, signal, writeTarget = false, linkHops = WRITE_LINK_HOPS) => {
@@ -318,7 +330,7 @@ export function createShellReadEstablisher(env, readRoots, opts) {
318
330
  const next = [];
319
331
  for (const dir of frontier) {
320
332
  if (++dirsUsed > MAX_GLOB_DIRS)
321
- return { unverifiable: `"${p}" expands over more than ${MAX_GLOB_DIRS} directories, too many to enumerate to verify` };
333
+ return bounded(`"${p}" expands over more than ${MAX_GLOB_DIRS} directories, too many to enumerate to verify`);
322
334
  const canonDir = await resolve(dir, signal);
323
335
  if (canonDir !== undefined && canonDir.missing === true)
324
336
  continue;
@@ -340,7 +352,7 @@ export function createShellReadEstablisher(env, readRoots, opts) {
340
352
  if (!globSegmentMatches(re, seg, entry.name))
341
353
  continue;
342
354
  if (++matches > MAX_GLOB_MATCHES)
343
- return { unverifiable: `"${p}" expands to more than ${MAX_GLOB_MATCHES} names, too many to enumerate to verify` };
355
+ return bounded(`"${p}" expands to more than ${MAX_GLOB_MATCHES} names, too many to enumerate to verify`);
344
356
  const candidate = joinUnder(dir, entry.name);
345
357
  if (canonDirHere !== undefined && entry.kind !== "symlink")
346
358
  canonicalOfMatch.set(candidate, joinUnder(canonDirHere, entry.name));
@@ -363,7 +375,7 @@ export function createShellReadEstablisher(env, readRoots, opts) {
363
375
  while (stack.length > 0) {
364
376
  const { dir, spell } = stack.pop();
365
377
  if (++dirsUsed > MAX_GLOB_DIRS)
366
- return { unverifiable: `"${rootCanonical}" holds more than ${MAX_GLOB_DIRS} directories, too many to enumerate to verify what a recursive read would open` };
378
+ return bounded(`"${rootCanonical}" holds more than ${MAX_GLOB_DIRS} directories, too many to enumerate to verify what a recursive read would open`);
367
379
  const listed = await env.listDir(dir, signal);
368
380
  if (!listed.ok) {
369
381
  if (dir === rootCanonical && listed.error.code === "not_directory")
@@ -377,7 +389,7 @@ export function createShellReadEstablisher(env, readRoots, opts) {
377
389
  root = "directory";
378
390
  for (const entry of listed.value) {
379
391
  if (found.length >= MAX_GLOB_MATCHES)
380
- return { unverifiable: `"${rootCanonical}" holds more than ${MAX_GLOB_MATCHES} entries, too many to enumerate to verify what a recursive read would open` };
392
+ return bounded(`"${rootCanonical}" holds more than ${MAX_GLOB_MATCHES} entries, too many to enumerate to verify what a recursive read would open`);
381
393
  const path = joinUnder(spell, entry.name);
382
394
  const canonicalPath = joinUnder(dir, entry.name);
383
395
  if (entry.kind === "symlink") {
@@ -453,7 +465,7 @@ export function createShellReadEstablisher(env, readRoots, opts) {
453
465
  const dir = name.slice(0, name.search(/[^/]*$/));
454
466
  name = dir === "" ? link.value : anchorSpelling(family, dir, link.value);
455
467
  }
456
- return undefined;
468
+ return void bounded(`"${spelled}" is a link chain of more than ${WRITE_LINK_HOPS} links, too many to follow to verify where its backup is written`);
457
469
  };
458
470
  const followedBackupReads = async (j, boundary, signal) => {
459
471
  const { operand, suffix } = j.followed;
@@ -601,19 +613,5 @@ export function createShellReadEstablisher(env, readRoots, opts) {
601
613
  }
602
614
  return { plan, reads, nested, roots };
603
615
  };
604
- return {
605
- lexicalOnly,
606
- establish: async (plan, boundary, signal) => {
607
- if (lexicalOnly)
608
- return establishShellReadsLexically(plan, boundary);
609
- const roots = await canonicalRoots(signal);
610
- return await withHomeViews(env, opts, await establishPlan(plan, { ...boundary, roots }, roots, signal), signal);
611
- },
612
- reestablish: async (previous, boundary, signal) => {
613
- if (lexicalOnly)
614
- return { changed: [] };
615
- const again = await establishPlan(previous.plan, { ...boundary, roots: previous.roots }, previous.roots, signal);
616
- return { changed: movedReads(previous, again) };
617
- },
618
- };
616
+ return establishPlan;
619
617
  }
@@ -8,8 +8,9 @@
8
8
  * matched, or onto a home shadow's target, left the pool unchanged and the call was admitted unjudged.
9
9
  */
10
10
  import type { EstablishedPlan } from "./shell-read-establish.js";
11
+ import type { ObservedEnvironment } from "./shell-read-environment.js";
11
12
  import type { EstablishedRead } from "./shell-read-judge.js";
12
- import { type HomeViewEnv, type HomeViewsCarrier } from "./read-deny-home.js";
13
+ import { type HomeViewsCarrier } from "./read-deny-home.js";
13
14
  /** Every read of either establishment whose candidate's settled target set differs between the two. */
14
15
  export declare function movedReads(previous: EstablishedPlan, again: EstablishedPlan): EstablishedRead[];
15
16
  /**
@@ -18,4 +19,4 @@ export declare function movedReads(previous: EstablishedPlan, again: Established
18
19
  * spelled through. The views are `placed`'s — the home views of the placement the boundary's deny seats were compiled
19
20
  * at. A read that did not land (lexical, unestablishable) is left as it is.
20
21
  */
21
- export declare function withHomeViews(env: HomeViewEnv, placed: HomeViewsCarrier | undefined, established: EstablishedPlan, signal?: AbortSignal): Promise<EstablishedPlan>;
22
+ export declare function withHomeViews(door: Pick<ObservedEnvironment, "fs" | "adopt">, placed: HomeViewsCarrier | undefined, established: EstablishedPlan, signal?: AbortSignal): Promise<EstablishedPlan>;