@sema-agent/core 7.49.0 → 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 (45) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/dist/core/arg-summary.d.ts +4 -4
  3. package/dist/core/arg-summary.js +106 -42
  4. package/dist/core/credential-vocabulary.js +19 -2
  5. package/dist/core/engine-notice.d.ts +11 -4
  6. package/dist/core/gate-fold.js +2 -2
  7. package/dist/core/hooks.d.ts +17 -0
  8. package/dist/core/runner/prepare-gate-stations.js +2 -2
  9. package/dist/core/runner/prepare-lsp.d.ts +4 -4
  10. package/dist/core/runner/prepare-lsp.js +2 -2
  11. package/dist/core/runner/prepare-safety-scan.js +10 -6
  12. package/dist/core/runner/run-harness-handlers.js +2 -2
  13. package/dist/core/runner/tool-turn-repair-seat.d.ts +28 -4
  14. package/dist/core/runner/tool-turn-repair-seat.js +36 -13
  15. package/dist/core/runner-deps.d.ts +2 -2
  16. package/dist/core/shell-wrapper-projection.js +1 -1
  17. package/dist/core/task-spec.d.ts +2 -2
  18. package/dist/core/tool-catalog-entries.js +1 -1
  19. package/dist/core/tool-conformance.js +2 -1
  20. package/dist/core/tool-roster.d.ts +11 -6
  21. package/dist/core/tool-roster.js +8 -4
  22. package/dist/core/write-protect.d.ts +3 -1
  23. package/dist/core/write-protect.js +4 -5
  24. package/dist/tools/fs/bash-program-position.d.ts +2 -1
  25. package/dist/tools/fs/bash-program-position.js +15 -4
  26. package/dist/tools/fs/bash-readonly-classifier.d.ts +2 -2
  27. package/dist/tools/fs/bash-readonly-classifier.js +5 -3
  28. package/dist/tools/fs/program-read-model.d.ts +7 -2
  29. package/dist/tools/fs/program-read-model.js +21 -1
  30. package/dist/tools/fs/program-read-model.rows.js +11 -10
  31. package/dist/tools/fs/read-deny-home.d.ts +5 -1
  32. package/dist/tools/fs/read-deny-home.js +40 -19
  33. package/dist/tools/fs/shell-read-check.d.ts +1 -1
  34. package/dist/tools/fs/shell-read-check.js +43 -14
  35. package/dist/tools/fs/shell-read-environment.d.ts +74 -0
  36. package/dist/tools/fs/shell-read-environment.js +71 -0
  37. package/dist/tools/fs/shell-read-establish.d.ts +8 -4
  38. package/dist/tools/fs/shell-read-establish.js +34 -36
  39. package/dist/tools/fs/shell-read-identity.d.ts +3 -2
  40. package/dist/tools/fs/shell-read-identity.js +5 -3
  41. package/dist/tools/fs/shell-read-judge.d.ts +69 -15
  42. package/dist/tools/fs/shell-read-judge.js +30 -10
  43. package/package.json +1 -1
  44. package/wire-changes/core@7.49.1.json +57 -0
  45. package/wire-changes/core@7.49.2.json +138 -0
@@ -31,6 +31,7 @@ const OBJECT_PATH = { kind: "object-path" };
31
31
  const text = (language) => ({ kind: "program-text", language });
32
32
  const file = (language) => ({ kind: "program-file", language });
33
33
  const SWITCH = { arity: 0 };
34
+ const QUERY = { arity: 0, mode: "queries" };
34
35
  const val = (value, extra = {}) => ({ arity: 1, ...(value !== undefined ? { value } : {}), ...extra });
35
36
  const optional = (value) => ({ arity: "attached", ...(value !== undefined ? { value } : {}) });
36
37
  const switches = (letters, extra = {}) => Object.fromEntries([...letters].map((l) => [l, { arity: 0, ...extra }]));
@@ -380,10 +381,10 @@ const launchers = [
380
381
  values: { "-u": LABEL, "--unset": LABEL, "-C": WORKDIR, "--chdir": WORKDIR, "-a": LABEL, "--argv0": LABEL, "-P": PROGRAM_SEARCH },
381
382
  optional: { "--default-signal": LABEL, "--ignore-signal": LABEL },
382
383
  shellText: ["-S", "--split-string"],
383
- }), { lacks: ["-P"] }, { lacks: ["-a", "--ignore-environment", "--debug", "--list-signal-handling", "--help", "--version", "--unset", "--chdir", "--argv0", "--default-signal", "--ignore-signal", "--split-string"] }), launcher: { assignmentOperands: true, bareDash: true }, witness: BINARY },
384
- launcher("command", {}, grammar({ p: SWITCH })),
385
- launcher("builtin"),
386
- launcher("exec", {}, launcherGrammar({ switches: ["-c", "-l"], values: { "-a": LABEL } })),
384
+ }, { stepsDashDash: true }), { lacks: ["-P"] }, { lacks: ["-a", "--ignore-environment", "--debug", "--list-signal-handling", "--help", "--version", "--unset", "--chdir", "--argv0", "--default-signal", "--ignore-signal", "--split-string"] }), launcher: { assignmentOperands: true, bareDash: true }, witness: BINARY },
385
+ launcher("command", {}, grammar({ p: SWITCH, v: QUERY, V: QUERY }, {}, { stepsDashDash: true })),
386
+ launcher("builtin", {}, grammar({}, {}, { stepsDashDash: true })),
387
+ launcher("exec", {}, launcherGrammar({ switches: ["-c", "-l"], values: { "-a": LABEL } }, { stepsDashDash: true })),
387
388
  launcher("noglob", {}, undefined, { witness: { declared: "a zsh precommand modifier" } }),
388
389
  launcher("nocorrect", {}, undefined, { witness: { declared: "a zsh precommand modifier" } }),
389
390
  { key: "xargs", grammars: implementations(launcherGrammar({
@@ -406,8 +407,8 @@ const launchers = [
406
407
  { key: "runuser", grammars: [launcherGrammar({ ...SU_OPTIONS, values: { ...SU_OPTIONS.values, "-u": LABEL, "--user": LABEL } }, { afterFirstOperand: "options", stepsDashDash: true })], launcher: { shellText: "user", programWith: ["-u", "--user"], asUser: { home: "target", loginWith: ["-l", "--login"] } }, witness: PRIVILEGED },
407
408
  launcher("chroot", { requiredOperands: 1, whenNoProgram: "shell" }, undefined, { witness: PRIVILEGED }),
408
409
  launcher("setpriv", {}, undefined, { witness: PRIVILEGED }),
409
- launcher("nohup", {}, grammar({}, BANNERS)),
410
- { key: "nice", grammars: implementations(grammar({ n: val(COUNT) }, { adjustment: val(COUNT), ...BANNERS }), {}, { lacks: ["--adjustment", "--help", "--version"] }), launcher: { negativeIntegerIsOwnArgument: true }, witness: BINARY },
410
+ launcher("nohup", {}, grammar({}, BANNERS, { stepsDashDash: true })),
411
+ { key: "nice", grammars: implementations(grammar({ n: val(COUNT) }, { adjustment: val(COUNT), ...BANNERS }, { stepsDashDash: true }), {}, { lacks: ["--adjustment", "--help", "--version"] }), launcher: { negativeIntegerIsOwnArgument: true }, witness: BINARY },
411
412
  launcher("ionice", {}, launcherGrammar({
412
413
  switches: ["-t", "--ignore", "-h", "--help", "-V", "--version"],
413
414
  banners: ["-h", "-V"],
@@ -418,8 +419,8 @@ const launchers = [
418
419
  values: { "-p": COUNT, "--pid": COUNT, "-T": COUNT, "-P": COUNT, "-D": COUNT, "--sched-runtime": COUNT, "--sched-period": COUNT, "--sched-deadline": COUNT },
419
420
  }), { witness: LINUX_NAMESPACE }),
420
421
  launcher("taskset", { requiredOperands: 1, requiredOperandSpelling: /^(?:0x)?[\da-f]+$|^[\d,:-]+$/i }, launcherGrammar({ switches: ["-a", "-p", "-c", "--all-tasks", "--pid", "--cpu-list", "--help", "--version"] }), { witness: LINUX_NAMESPACE }),
421
- { key: "stdbuf", grammars: implementations(grammar({ i: val(LABEL), o: val(LABEL), e: val(LABEL) }, { input: val(LABEL), output: val(LABEL), error: val(LABEL), ...BANNERS }), {}, { lacks: ["--input", "--output", "--error", "--help", "--version"] }), launcher: {}, witness: BINARY },
422
- launcher("timeout", { requiredOperands: 1, requiredOperandSpelling: DURATION_SPELLING }, grammar({ v: SWITCH, k: val(COUNT), s: val(LABEL) }, { foreground: SWITCH, "preserve-status": SWITCH, verbose: SWITCH, "kill-after": val(COUNT), signal: val(LABEL), ...BANNERS })),
422
+ { key: "stdbuf", grammars: implementations(grammar({ i: val(LABEL), o: val(LABEL), e: val(LABEL) }, { input: val(LABEL), output: val(LABEL), error: val(LABEL), ...BANNERS }, { stepsDashDash: true }), {}, { lacks: ["--input", "--output", "--error", "--help", "--version"] }), launcher: {}, witness: BINARY },
423
+ launcher("timeout", { requiredOperands: 1, requiredOperandSpelling: DURATION_SPELLING }, grammar({ v: SWITCH, k: val(COUNT), s: val(LABEL) }, { foreground: SWITCH, "preserve-status": SWITCH, verbose: SWITCH, "kill-after": val(COUNT), signal: val(LABEL), ...BANNERS }, { stepsDashDash: true })),
423
424
  { key: "time", grammars: implementations(grammar({ p: SWITCH, a: SWITCH, v: SWITCH, q: SWITCH, f: val(LABEL), o: val(WRITE) }, { portability: SWITCH, append: SWITCH, verbose: SWITCH, quiet: SWITCH, help: SWITCH, version: SWITCH, format: val(LABEL), output: val(WRITE) }, { stepsDashDash: true }), {}, { lacks: ["-v", "-q", "-f", "--portability", "--append", "--verbose", "--quiet", "--help", "--version", "--format", "--output"] }), launcher: {}, witness: BINARY },
424
425
  { key: "watch", grammars: [
425
426
  launcherGrammar({
@@ -430,12 +431,12 @@ const launchers = [
430
431
  }, { afterFirstOperand: "operands", stepsDashDash: true }),
431
432
  launcherGrammar({ switches: ["-d", "-t"], values: { "-n": COUNT } }, { afterFirstOperand: "operands", stepsDashDash: true }),
432
433
  ], launcher: { shellText: "joined", programWith: ["-x", "--exec"] }, witness: SHELL_TEXT_LAUNCHER },
433
- launcher("setsid", {}, launcherGrammar({ switches: ["-c", "-f", "-w", "--ctty", "--fork", "--wait", "--help", "--version"] })),
434
+ launcher("setsid", {}, launcherGrammar({ switches: ["-c", "-f", "-w", "--ctty", "--fork", "--wait", "--help", "--version"] }, { stepsDashDash: true })),
434
435
  launcher("flock", { requiredOperands: 1 }, launcherGrammar({
435
436
  switches: ["-s", "-x", "-u", "-n", "-o", "-F", "--shared", "--exclusive", "--unlock", "--nonblock", "--nb", "--close", "--no-fork", "--verbose", "--help", "--version"],
436
437
  values: { "-w": COUNT, "-E": COUNT, "--timeout": COUNT, "--wait": COUNT, "--conflict-exit-code": COUNT },
437
438
  shellText: ["-c", "--command"],
438
- }), { operands: { rest: CREATED } }),
439
+ }, { stepsDashDash: true }), { operands: { rest: CREATED } }),
439
440
  launcher("unshare", { whenNoProgram: "shell" }, launcherGrammar({
440
441
  switches: ["-m", "-u", "-i", "-n", "-p", "-U", "-C", "-T", "-f", "-r", "-c", "--mount", "--uts", "--ipc", "--net", "--pid", "--user", "--cgroup", "--time", "--fork", "--map-root-user", "--map-current-user", "--kill-child", "--mount-proc", "--keep-caps", "--help", "--version"],
441
442
  values: { "-R": ROOTDIR, "-w": WORKDIR, "-S": LABEL, "-G": LABEL, "--setuid": LABEL, "--setgid": LABEL, "--root": ROOTDIR, "--wd": WORKDIR, "--propagation": LABEL, "--setgroups": LABEL, "--monotonic": COUNT, "--boottime": COUNT, "--map-user": LABEL, "--map-group": LABEL },
@@ -93,7 +93,7 @@ export type HomeViewEnv = Pick<ExecutionEnv, "homeDir" | "canonicalPath" | "file
93
93
  export interface HomeViewSource {
94
94
  /** The home views of one judgment (a tool call, a command's establishment, one search): a view per directory is read
95
95
  * once and shared by every path below it. */
96
- views(signal?: AbortSignal): HomeViews;
96
+ views(signal?: AbortSignal, env?: HomeViewEnv): HomeViews;
97
97
  }
98
98
  /** A judgment's views. `of` answers the view only where it differs from the path as spelled; `undefined` otherwise, and
99
99
  * wherever it cannot be read: no `fileIdentity` seat, no home, a level of the path whose identity fails (the path is
@@ -103,6 +103,10 @@ export interface HomeViews {
103
103
  of(path: string, opts?: {
104
104
  readonly leaf?: "file";
105
105
  }): Promise<string | undefined>;
106
+ /** The paths whose identity lookup gave no answer in this judgment (a throw, a timeout, an error that is no conclusive "not
107
+ * there"): for them `of` answered `undefined` for want of a reading, not because there is no view — a consumer that
108
+ * must not vouch past an unread view (the shell station) reads this after its lookups. Live: grows as `of` is asked. */
109
+ readonly unread: readonly string[];
106
110
  }
107
111
  /** A judge that carries the home views of the placement its rows were compiled at. */
108
112
  export interface HomeViewsCarrier {
@@ -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";
@@ -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)
@@ -241,6 +245,20 @@ function outsidePathsOf(judged) {
241
245
  }
242
246
  return out;
243
247
  }
248
+ function outsideFindingsAreRedirections(judged) {
249
+ const walk = (plan) => plan.segments.every((s) => {
250
+ const redirected = new Set(s.segment.redirects.flatMap((r) => (r.direction === "out" && r.target !== undefined ? [r.target.raw] : [])));
251
+ const slotWrites = new Set(s.segment.reads.filter((c) => c.provenance === "write_redirect_target").map((c) => c.raw));
252
+ for (const f of s.findings) {
253
+ if (f.kind !== "outside_roots")
254
+ continue;
255
+ if (f.read.written === undefined || f.read.candidate.provenance !== "write_redirect_target" || !redirected.has(f.read.candidate.raw) || slotWrites.has(f.read.candidate.raw))
256
+ return false;
257
+ }
258
+ return s.nested.every(walk);
259
+ });
260
+ return walk(judged);
261
+ }
244
262
  function refusalOf(tool, fold, boundary, paths) {
245
263
  const w = fold.witness;
246
264
  if (fold.finding === "deny_hit" && w?.deny !== undefined) {
@@ -261,7 +279,8 @@ const DENY_REFUSAL_NOTE = "The read boundary judges the paths this command names
261
279
  "a file the command reaches without naming it is judged only where a segment deny row (built in, or the deployment's " +
262
280
  "own pattern) reaches into the tree a recursive read walks.";
263
281
  function verdictOf(tool, seat, fold, judged, boundary) {
264
- const disposition = decideShellRead(fold.finding, seat);
282
+ const redirectsOnly = fold.finding === "outside_roots" && fold.unestablished !== true && outsideFindingsAreRedirections(judged);
283
+ const disposition = decideShellRead(fold.finding, seat, { redirectsOnly, narrowedReadFace: fold.unplacedRead === true && boundary.face !== "open" });
265
284
  if (disposition === "vouch")
266
285
  return { disposition, finding: fold.finding };
267
286
  const paths = outsidePathsOf(judged);
@@ -272,14 +291,14 @@ function verdictOf(tool, seat, fold, judged, boundary) {
272
291
  return { disposition, finding: fold.finding, ...writes, sentence: r.sentence, ...(r.details !== undefined ? { details: r.details } : {}) };
273
292
  }
274
293
  const grant = fold.finding === "outside_roots" ? readRootGrantCandidateOf(judged, boundary) : undefined;
275
- const containment = fold.finding === "outside_roots" || fold.finding === "unestablishable";
294
+ const containment = fold.finding === "outside_roots" || fold.finding === "unestablishable" || fold.finding === "unanswered";
276
295
  return {
277
296
  disposition,
278
297
  finding: fold.finding,
279
298
  ...writes,
280
299
  ...unestablished,
281
300
  sentence: findingAccount(fold, boundary),
282
- ...(disposition === "mandated" && containment ? { details: findingDetails(fold.finding, paths) } : {}),
301
+ ...((disposition === "mandated" && containment) || (disposition === "ordinary" && fold.finding === "unestablishable") ? { details: findingDetails(fold.finding, paths) } : {}),
283
302
  ...(grant !== undefined ? { readRootCandidate: { dir: grant.dir, ...(grant.covers !== undefined ? { covers: grant.covers } : {}), clearsThisAsk: true } } : {}),
284
303
  };
285
304
  }
@@ -310,6 +329,7 @@ async function applyAlsoInside(judged, boundary) {
310
329
  plan: j.plan,
311
330
  guards: j.guards,
312
331
  writeGuards: j.writeGuards,
332
+ ...(j.incomplete !== undefined ? { incomplete: j.incomplete } : {}),
313
333
  segments: j.segments.map((s) => ({
314
334
  segment: s.segment,
315
335
  findings: s.findings.map((f) => (outsideRead(f) && rescued.has(f.read.target) ? { ...f, kind: "none", containment: "inside" } : f)),
@@ -320,13 +340,23 @@ async function applyAlsoInside(judged, boundary) {
320
340
  }
321
341
  async function runShellReadStation(tool, command, declared, seat, readers, establisher, env, signal) {
322
342
  const plan = planShellReads(command, declarationsOf(declared, readers, env.cdPath));
323
- const established = await establisher.establish(plan, declared, signal);
343
+ const door = observeEnvironment(env);
344
+ const established = await establisher.establish(plan, declared, signal, door);
324
345
  const boundary = { ...declared, roots: established.roots };
325
- const judged = await applyAlsoInside(judgePlan(plan, established, boundary), boundary);
326
- 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);
327
358
  const unplaced = unplacedOptionsOf(judged);
328
- const placed = writesPlacedOf(judged, boundary);
329
- const guarded = judgedVerdict.disposition === "refuse" ? { unplaced: false, writeProtected: false } : await writeGuardsOf(judged, boundary, env, signal);
359
+ const placed = judgedVerdict.finding !== "unanswered" && writesPlacedOf(judged, boundary);
330
360
  const unresolved = judgedVerdict.disposition !== "refuse" && runOrderOf(judged).runs.some((s) => s.findings.some((f) => f.read.written !== undefined && f.read.unresolved === true));
331
361
  const facts = { ...judgedVerdict, ...(guarded.writeProtected ? { writeProtected: true } : {}), ...(unresolved ? { targetUnresolved: true } : {}) };
332
362
  const verdict = placed && !guarded.unplaced ? { ...facts, writesPlaced: true } : unplaced !== undefined ? { ...facts, unplacedOptions: unplaced } : facts;
@@ -400,13 +430,12 @@ export function createShellReadStation(opts) {
400
430
  const boundary = opts.boundaryOf(member, undefined);
401
431
  if (boundary === undefined || !(anyFaceGuards(boundary) || boundary.writeTargetJudge !== undefined))
402
432
  return { disposition: "vouch", finding: "none" };
403
- const fold = { finding: "unestablishable" };
404
- const disposition = decideShellRead("unestablishable", seat);
433
+ const disposition = decideShellRead("unanswered", seat);
405
434
  const account = `${cause}, so where this command reads could not be established ${NOT_AUTO_ALLOWED}`;
406
435
  const details = unestablishedDetails();
407
436
  if (disposition === "refuse")
408
- return { disposition, finding: "unestablishable", sentence: `Error (${toolName}): ${account}. ${BASH_READONLY_CONFINEMENT_NOTE}`, details };
409
- 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 };
410
439
  },
411
440
  };
412
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;