@sema-agent/core 7.22.0 → 7.23.1

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 (60) hide show
  1. package/CHANGELOG.md +210 -1
  2. package/dist/core/auto-compaction.d.ts +2 -2
  3. package/dist/core/checkpoint-store.d.ts +1 -5
  4. package/dist/core/context-edit.js +1 -1
  5. package/dist/core/engine-notice.d.ts +13 -0
  6. package/dist/core/gate-bounded-await.d.ts +14 -4
  7. package/dist/core/gate-bounded-await.js +1 -1
  8. package/dist/core/gate-crash-trace.d.ts +7 -0
  9. package/dist/core/gate-crash-trace.js +3 -0
  10. package/dist/core/gate-fold.js +5 -4
  11. package/dist/core/gate-lanes.js +2 -1
  12. package/dist/core/governance-codes.d.ts +1 -1
  13. package/dist/core/governance-codes.js +2 -0
  14. package/dist/core/hooks.js +2 -1
  15. package/dist/core/park-selfcheck.js +1 -1
  16. package/dist/core/path-identity.d.ts +11 -4
  17. package/dist/core/path-identity.js +5 -2
  18. package/dist/core/read-root-candidate.js +11 -1
  19. package/dist/core/runner/assemble-result.d.ts +2 -7
  20. package/dist/core/runner/assemble-result.js +25 -8
  21. package/dist/core/runner/contracts.d.ts +15 -15
  22. package/dist/core/runner/gate-exit.d.ts +0 -5
  23. package/dist/core/runner/gate-exit.js +0 -3
  24. package/dist/core/runner/inherited-ask-grants.d.ts +2 -7
  25. package/dist/core/runner/inherited-ask-grants.js +1 -0
  26. package/dist/core/runner/prepare-ask-lane.js +1 -0
  27. package/dist/core/runner/prepare-context-lane.d.ts +43 -1
  28. package/dist/core/runner/prepare-context-lane.js +25 -4
  29. package/dist/core/runner/prepare-task.js +2 -2
  30. package/dist/core/runner/prepare-turn-wiring.js +2 -2
  31. package/dist/core/runner/run-harness-handlers.js +4 -2
  32. package/dist/core/runner/run-recovery-lanes.js +6 -3
  33. package/dist/core/runner/run-telemetry-and-budget-seats.js +11 -1
  34. package/dist/core/runner/write-protection-station.d.ts +7 -25
  35. package/dist/core/runner/write-protection-station.js +18 -0
  36. package/dist/core/task-registry.d.ts +0 -6
  37. package/dist/core/task-result.d.ts +28 -10
  38. package/dist/core/types.d.ts +1 -1
  39. package/dist/core/write-protect.d.ts +64 -12
  40. package/dist/core/write-protect.js +87 -34
  41. package/dist/engine/execution-env/node-execution-env.d.ts +19 -2
  42. package/dist/engine/execution-env/node-execution-env.js +5 -1
  43. package/dist/index.d.ts +1 -1
  44. package/dist/tools/fs/bash-lexer.d.ts +12 -5
  45. package/dist/tools/fs/bash-lexer.js +5 -2
  46. package/dist/tools/fs/bash-program-position.d.ts +18 -0
  47. package/dist/tools/fs/bash-program-position.js +13 -1
  48. package/dist/tools/fs/bash-readonly-classifier.d.ts +10 -3
  49. package/dist/tools/fs/bash-readonly-classifier.js +70 -41
  50. package/dist/tools/fs/fs-bash.d.ts +22 -0
  51. package/dist/tools/fs/fs-bash.js +19 -12
  52. package/dist/tools/fs/fs-read.js +2 -1
  53. package/dist/tools/fs/fs-shared.d.ts +33 -7
  54. package/dist/tools/fs/fs-shared.js +12 -1
  55. package/dist/tools/fs/safety.d.ts +37 -0
  56. package/dist/tools/fs/safety.js +31 -3
  57. package/package.json +2 -2
  58. package/test/export-surface.snapshot.json +3 -1
  59. package/wire-changes/core@7.23.0.json +316 -0
  60. package/wire-changes/core@7.23.1.json +205 -0
@@ -18,7 +18,7 @@ export type { BeforeWriteRequest, BeforeWriteResult, BeforeWriteHook, TrackEditR
18
18
  export type { AgentDefinition, SkillManifest, SkillSpec } from "./agent-definition.js";
19
19
  export type { McpServerSpec, A2aServerSpec, McpElicitRequest, McpElicitResponse, OnElicit } from "./mcp-server-spec.js";
20
20
  export type { TaskLimits } from "./task-limits.js";
21
- export type { TaskStatus, RemoteEnvFailureNote, EffectiveMemoryScopes, TaskResult } from "./task-result.js";
21
+ export type { TaskStatus, RemoteEnvFailureNote, EffectiveMemoryScopes, TaskResult, HumanReviewGateRow } from "./task-result.js";
22
22
  export type { TerminalCause, PausedCause } from "./terminal-cause.js";
23
23
  export type { TaskEventIdentity, BrainStatusPhase, BrainRetryErrClass, BrainStatus, ToolActivity, HumanInputSource, HumanInputDelivery, DelegationTaskType, TaskEvent, HumanInputEvent, CompactOutcome } from "./task-event.js";
24
24
  export type { TaskStream } from "./task-stream.js";
@@ -30,13 +30,26 @@ export interface WriteProtectedHit {
30
30
  }
31
31
  export declare const WRITE_PROTECTED_DEFAULT_TABLE: readonly WriteProtectedRow[];
32
32
  /**
33
- * The ONE case fold of this module, applied to BOTH sides of every comparison (table names at
34
- * compile, path segments at match) — `toLowerCase` plus the two confusable letters CC's fold maps
35
- * (U+0131 dotless ı → i, U+017F long ſ → s). Deliberately NOT full Unicode confusable folding:
36
- * that would be a different, wider claim than the one made (the read deny set's ASCII-contract doc
37
- * states the same boundary for its own fold).
33
+ * The ONE SEGMENT fold of this module, applied to BOTH sides of every comparison (table names at
34
+ * compile, path segments at match), in the reference implementation's own order:
35
+ * · `toLowerCase` plus the two confusable letters its fold maps (U+0131 dotless ı → i, U+017F long ſ → s);
36
+ * · the INVISIBLE FORMAT characters it strips — the zero-width joiners/non-joiners, the bidirectional
37
+ * overrides and the byte-order mark (U+200C–U+200F, U+202A–U+202E, U+206A–U+206F, U+FEFF), verbatim.
38
+ * A segment carrying one of these LOOKS like the row it is not, on both sides of every comparison, so
39
+ * the strip is a deception axis rather than a platform one: it applies to every reading below, not to
40
+ * the win32 one alone. It can only ADD matches, which on a TIGHTEN costs one extra question — and the
41
+ * data-root passthrough, where an added match would be a guard BYPASSED, deliberately does not use this
42
+ * fold at all (its comparison is byte-exact; see {@link relativeUnderDataRoot}).
43
+ * · …and a strip that would EMPTY the segment keeps the segment instead (the reference fold's own `|| n`):
44
+ * an all-invisible component names a real file, and folding it to "" would make it collide with nothing
45
+ * or with everything depending on the caller.
46
+ * Deliberately NOT full Unicode confusable folding: that would be a different, wider claim than the one made
47
+ * (the read deny set's ASCII-contract doc states the same boundary for its own fold). The NTFS data-stream
48
+ * suffix the reference fold also strips is a win32 NAMESPACE fact, not a comparison one, and lives with the
49
+ * other component normalization of those views ({@link ComponentNormalization}) so a POSIX file legally
50
+ * named `a:b` keeps its own identity on the POSIX reading.
38
51
  */
39
- export declare function foldWriteProtectCase(s: string): string;
52
+ export declare function foldWriteProtectSegment(s: string): string;
40
53
  /**
41
54
  * Resolve the ACTIVE table under the deployment seat (validated, loud — #123's bad-value states all
42
55
  * throw and name the knob). `undefined` = the default table; `[]` = NO table (an explicit, legal
@@ -47,15 +60,32 @@ export declare function foldWriteProtectCase(s: string): string;
47
60
  * candidate configuration with the engine's own rules.
48
61
  */
49
62
  export declare function resolveWriteProtectedTable(entries?: readonly WriteProtectedEntry[]): readonly WriteProtectedRow[];
63
+ /**
64
+ * WHY the ENVIRONMENT half of one judgment could not contribute — the canonicalizer refused the spelling,
65
+ * a link the tree could not follow, a namespace spelling it declines, an environment call that threw.
66
+ * Reported through {@link WriteProtectionCompileOptions.onViewAbsent}; it is a DISCLOSURE, never a verdict
67
+ * (see the absence law on {@link WriteProtectionMatcher.judge}).
68
+ */
69
+ export interface WriteProtectionViewAbsence {
70
+ /** The environment's own sentence, verbatim — what it said when asked to resolve this target. */
71
+ readonly cause: string;
72
+ /** The failure was specifically a symlink whose real target is unknowable, as opposed to a spelling the
73
+ * tree refuses or a probe that errored. */
74
+ readonly unresolvedSymlink: boolean;
75
+ }
50
76
  /**
51
77
  * The judge's answer for ONE covered write ({@link WriteProtectionMatcher.judge}):
52
- * · `clear` — no row on either view: the write is not this layer's business;
78
+ * · `clear` — no row on any view that could answer: the write is not this layer's business;
53
79
  * · `hit` — a row matched; `via` says which view named it: `"spelling"` (the anchored spelling, no
54
80
  * filesystem consulted), `"chain"` (a spelling the leaf symlink chain passes through, judged as a
55
81
  * spelling) or `"target"` (the resolved real target, the spelling being innocent);
56
- * · `unresolvable` — the real target could not be resolved (an unfollowable symlink, an errored probe, a
57
- * namespace spelling the tree refuses); `cause` is the canonicalizer's own sentence and the consumer
58
- * REFUSES, an unjudged target never being vouched.
82
+ * · `unresolvable` — NO half of the judgment answered at all. The judge itself no longer mints this: an
83
+ * environment that cannot resolve the target has ANSWERED, and an answer of "I cannot resolve this"
84
+ * leaves the lexical half's judgment standing (the absence law below). What is left for this verdict is
85
+ * the judgment that never happened — the gate's bounded wait elapsing or the task ending before
86
+ * anything came back (`awaitWriteProtectionJudgment`, gate-bounded-await.ts) — where there is no
87
+ * lexical answer to stand on either, and the consumer REFUSES. `unresolvedSymlink` is that minter's
88
+ * `false`: a wait that produced nothing knows nothing about what it was waiting for.
59
89
  */
60
90
  export type WriteProtectionVerdict = {
61
91
  readonly kind: "clear";
@@ -115,8 +145,8 @@ export interface WriteProtectionMatcher {
115
145
  * @contract write_protection.judges_the_real_target — the always-on write-protection table and the
116
146
  * opt-in sensitive-path deny set differ in VOCABULARY (literal rows / segment globs) and SEVERITY
117
147
  * (ask / deny), never in REACH: both judge the file the tool will write — the spelling anchored at the
118
- * live cwd AND its resolved real target, through one canonicalizer — and a target neither can resolve is
119
- * refused, never vouched. An environment-less judge has no target view and answers by the spelling alone —
148
+ * live cwd AND its resolved real target, through one canonicalizer. Where they DO part: a target the write-protection
149
+ * judge cannot resolve leaves its resolved view ABSENT (the spelling still judges); the deny set still REFUSES whole. An environment-less judge has no target view and answers by the spelling alone —
120
150
  * the same absence law the cwd follows (absent ⇒ the spelling is judged exactly as given). REACH is what
121
151
  * this sentence promises, not a LOCK: the identity is resolved at judgment time and re-resolved by the
122
152
  * tool at execute time (the module header states that window and the seat that would close it).
@@ -136,6 +166,17 @@ export interface WriteProtectionCompileOptions {
136
166
  * path-family source when the call carries no cwd. Omitted ⇒ relative spellings resolve against the
137
167
  * cwd, then the environment's own working directory. */
138
168
  readonly rootPath?: string;
169
+ /**
170
+ * Called at most ONCE per judgment, at the moment the ENVIRONMENT half is found unable to contribute
171
+ * ({@link WriteProtectionViewAbsence}) — before the judgment returns, and on the hit arm as well as the
172
+ * clear one, because the fact is about the ENVIRONMENT rather than about the answer it did not change.
173
+ * Omitted ⇒ the absence is silent, which is the honest default for a judge compiled outside a leg.
174
+ * A throwing sink is the caller's bug and is not caught here; the leg that wires one wraps it.
175
+ *
176
+ * Deliberately carries no call identity: what it reports is a deployment-level fact (this environment
177
+ * cannot answer path questions), and its consumer announces once per leg rather than once per write.
178
+ */
179
+ readonly onViewAbsent?: (absence: WriteProtectionViewAbsence) => void;
139
180
  }
140
181
  /**
141
182
  * Compile the write-protection judge for a deployment configuration. Returns `undefined` when the
@@ -204,5 +245,16 @@ export interface WriteProtectionMount {
204
245
  * environment cannot RESOLVE is the judge's own `unresolvable` verdict, not this null.
205
246
  * Returns `undefined` when the resolved table is empty — nothing to judge, mount nothing.
206
247
  * `opts` are the judge's own seats, passed straight through ({@link compileWriteProtection}).
248
+ *
249
+ * @contract write_protection.membership_is_the_call — WHICH calls the always-on table judges is a
250
+ * PREDICATE OVER THE CALL and nothing else: the call's own face declares a path target whose access is not
251
+ * a read, so a custom writer joins this judge by DECLARING what it writes and a tool that declares nothing
252
+ * is not this layer's business under any name. There is deliberately NO name-list seat beside it, and the
253
+ * asymmetry with the opt-in deny policy — which does take one (`createSensitivePathPolicy({ tools })`, its
254
+ * own pre-declaration hatch) — is the intended shape rather than a gap: a name list on a default-ON floor
255
+ * is a way to narrow that floor silently, one tool name at a time, while the seat this layer gives a
256
+ * deployment for narrowing is the whole-table replacement, which says what it removes. A deployment that
257
+ * wants name-list membership composes the deny policy alongside; the two then answer under the shared
258
+ * `deny > ask > allow` fold, which is what makes the missing seat cost nothing.
207
259
  */
208
260
  export declare function createWriteProtectionCheck(entries?: readonly WriteProtectedEntry[], opts?: WriteProtectionCompileOptions): WriteProtectionMount | undefined;
@@ -57,8 +57,19 @@ export const WRITE_PROTECTED_DEFAULT_TABLE = freezeTable([
57
57
  { name: ".sema.json", kind: "basename" },
58
58
  { name: ".sema", kind: "segment" },
59
59
  ]);
60
- export function foldWriteProtectCase(s) {
61
- return s.toLowerCase().replace(/ı/g, "i").replace(/ſ/g, "s");
60
+ export function foldWriteProtectSegment(s) {
61
+ const cased = s.toLowerCase().replace(/ı/g, "i").replace(/ſ/g, "s");
62
+ return stripInvisibleFormat(cased) || cased;
63
+ }
64
+ function stripInvisibleFormat(s) {
65
+ return s.replace(/[\u200c-\u200f\u202a-\u202e\u206a-\u206f\ufeff]/g, "");
66
+ }
67
+ function stripWin32Component(raw, normalization) {
68
+ const base = normalization.invisibleFirst ? stripInvisibleFormat(raw) : raw;
69
+ return (normalization.stream ? base.replace(/:.*$/, "") : base).replace(/[. ]+$/, "");
70
+ }
71
+ function foldWriteProtectName(name) {
72
+ return name.split("/").map(foldWriteProtectSegment).join("/");
62
73
  }
63
74
  const KINDS = ["basename", "segment", "segment-run"];
64
75
  function describeEntryValue(v) {
@@ -107,7 +118,7 @@ export function resolveWriteProtectedTable(entries) {
107
118
  const seen = new Set();
108
119
  for (const entry of entries) {
109
120
  const row = normalizeWriteProtectedEntry(entry);
110
- const key = `${row.kind}:${foldWriteProtectCase(row.name)}`;
121
+ const key = `${row.kind}:${foldWriteProtectName(row.name)}`;
111
122
  if (seen.has(key))
112
123
  continue;
113
124
  seen.add(key);
@@ -115,8 +126,9 @@ export function resolveWriteProtectedTable(entries) {
115
126
  }
116
127
  return freezeTable(out);
117
128
  }
118
- function pathViewSegments(path, win32, seps = BOTH_SEPARATOR_FAMILIES, anchorFloor = 0) {
129
+ function pathViewSegments(path, normalization, seps = BOTH_SEPARATOR_FAMILIES, anchorFloor = 0) {
119
130
  const segs = [];
131
+ const win32 = normalization !== "literal";
120
132
  for (const raw of path.split(seps)) {
121
133
  if (raw.length === 0)
122
134
  continue;
@@ -131,11 +143,17 @@ function pathViewSegments(path, win32, seps = BOTH_SEPARATOR_FAMILIES, anchorFlo
131
143
  segs.push("..");
132
144
  continue;
133
145
  }
134
- const stripped = win32 ? raw.replace(/[. ]+$/, "") : raw;
146
+ const stripped = normalization === "literal" ? raw : stripWin32Component(raw, normalization);
135
147
  segs.push(stripped.length > 0 ? stripped : raw);
136
148
  }
137
149
  return segs;
138
150
  }
151
+ const WIN32_NORMALIZATIONS = [
152
+ { invisibleFirst: false, stream: false },
153
+ { invisibleFirst: false, stream: true },
154
+ { invisibleFirst: true, stream: false },
155
+ { invisibleFirst: true, stream: true },
156
+ ];
139
157
  const BOTH_SEPARATOR_FAMILIES = /[\\/]/;
140
158
  const POSIX_SEPARATOR_FAMILY = /\//;
141
159
  function uncAnchorSegments(path) {
@@ -149,7 +167,7 @@ function relativeUnderDataRoot(path, roots) {
149
167
  if (root.win ? !isWinFormPath(path) : isWinFormPath(path) || !path.startsWith("/"))
150
168
  continue;
151
169
  const seps = root.win ? BOTH_SEPARATOR_FAMILIES : POSIX_SEPARATOR_FAMILY;
152
- const segs = pathViewSegments(path, false, seps, uncAnchorSegments(path));
170
+ const segs = pathViewSegments(path, "literal", seps, uncAnchorSegments(path));
153
171
  if (segs.length < root.segments.length)
154
172
  continue;
155
173
  if (root.segments.every((s, i) => segs[i] === s))
@@ -165,13 +183,13 @@ export function compileWriteProtection(entries, opts) {
165
183
  const dataRootPaths = dataRootSpellings(opts?.dataRoot);
166
184
  const rootViews = dataRootPaths.map((r) => {
167
185
  const win = isWinFormPath(r);
168
- return { win, segments: pathViewSegments(r, false, win ? BOTH_SEPARATOR_FAMILIES : POSIX_SEPARATOR_FAMILY, uncAnchorSegments(r)) };
186
+ return { win, segments: pathViewSegments(r, "literal", win ? BOTH_SEPARATOR_FAMILIES : POSIX_SEPARATOR_FAMILY, uncAnchorSegments(r)) };
169
187
  });
170
188
  const basenames = new Map();
171
189
  const segments = new Map();
172
190
  const runs = [];
173
191
  for (const row of rows) {
174
- const folded = foldWriteProtectCase(row.name);
192
+ const folded = foldWriteProtectName(row.name);
175
193
  if (row.kind === "basename") {
176
194
  if (!basenames.has(folded))
177
195
  basenames.set(folded, row.name);
@@ -187,7 +205,7 @@ export function compileWriteProtection(entries, opts) {
187
205
  const judgeSegments = (raw) => {
188
206
  if (raw.length === 0)
189
207
  return null;
190
- const segs = raw.map(foldWriteProtectCase);
208
+ const segs = raw.map(foldWriteProtectSegment);
191
209
  const base = basenames.get(segs[segs.length - 1] ?? "");
192
210
  if (base !== undefined)
193
211
  return { name: base, kind: "basename" };
@@ -205,9 +223,20 @@ export function compileWriteProtection(entries, opts) {
205
223
  }
206
224
  return null;
207
225
  };
208
- const judgeViews = (spelling) => judgeSegments(pathViewSegments(spelling, false, POSIX_SEPARATOR_FAMILY, uncAnchorSegments(spelling))) ??
209
- judgeSegments(pathViewSegments(spelling, false)) ??
210
- judgeSegments(pathViewSegments(spelling, true));
226
+ const judgeViews = (spelling) => {
227
+ const posix = judgeSegments(pathViewSegments(spelling, "literal", POSIX_SEPARATOR_FAMILY, uncAnchorSegments(spelling)));
228
+ if (posix !== null)
229
+ return posix;
230
+ const bothSeparators = judgeSegments(pathViewSegments(spelling, "literal"));
231
+ if (bothSeparators !== null)
232
+ return bothSeparators;
233
+ for (const normalization of WIN32_NORMALIZATIONS) {
234
+ const hit = judgeSegments(pathViewSegments(spelling, normalization));
235
+ if (hit !== null)
236
+ return hit;
237
+ }
238
+ return null;
239
+ };
211
240
  const judgeSpelled = (spelled) => judgeViews(relativeUnderDataRoot(spelled, rootViews) ?? spelled);
212
241
  const judgeSpelling = (path, cwd) => {
213
242
  const base = typeof cwd === "string" && cwd.length > 0 ? cwd : undefined;
@@ -231,31 +260,55 @@ export function compileWriteProtection(entries, opts) {
231
260
  return { kind: "clear" };
232
261
  const family = pathFamilyOf({ root: rootPath, cwd: ctx.cwd ?? env.cwd });
233
262
  const base = ctx.base ?? ctx.cwd ?? rootPath;
234
- const canon = await canonicalizeTarget(env, path, ctx.signal, family, base);
235
- if (!canon.ok)
236
- return { kind: "unresolvable", cause: canon.message, unresolvedSymlink: canon.unresolvedSymlink === true };
237
- const byTarget = judgeViews(canon.key);
238
- if (byTarget !== null) {
239
- for (const rootKey of await canonicalDataRootKeys(env, dataRootPaths, ctx.signal)) {
240
- const relative = relativeUnderRoot(canon.key, rootKey);
241
- if (relative === null)
263
+ let noted = false;
264
+ const noteAbsence = (cause, unresolvedSymlink) => {
265
+ if (noted)
266
+ return;
267
+ noted = true;
268
+ opts?.onViewAbsent?.({ cause, unresolvedSymlink });
269
+ };
270
+ let canonKey;
271
+ try {
272
+ const canon = await canonicalizeTarget(env, path, ctx.signal, family, base);
273
+ if (canon.ok)
274
+ canonKey = canon.key;
275
+ else
276
+ noteAbsence(canon.message, canon.unresolvedSymlink === true);
277
+ }
278
+ catch (err) {
279
+ noteAbsence(err instanceof Error ? err.message : String(err), false);
280
+ }
281
+ try {
282
+ if (canonKey !== undefined) {
283
+ const byTarget = judgeViews(canonKey);
284
+ if (byTarget !== null) {
285
+ for (const rootKey of await canonicalDataRootKeys(env, dataRootPaths, ctx.signal)) {
286
+ const relative = relativeUnderRoot(canonKey, rootKey);
287
+ if (relative === null)
288
+ continue;
289
+ if (relative === "")
290
+ return { kind: "clear" };
291
+ const remainder = judgeViews(relative);
292
+ return remainder === null ? { kind: "clear" } : { kind: "hit", hit: remainder, via: "target" };
293
+ }
294
+ return { kind: "hit", hit: byTarget, via: "target" };
295
+ }
296
+ }
297
+ for (const hop of await symlinkChainHops(env, path, ctx.signal, family, base, (cause) => noteAbsence(cause, false))) {
298
+ const byHop = judgeSpelled(hop);
299
+ if (byHop === null)
242
300
  continue;
243
- if (relative === "")
244
- return { kind: "clear" };
245
- const remainder = judgeViews(relative);
246
- return remainder === null ? { kind: "clear" } : { kind: "hit", hit: remainder, via: "target" };
301
+ if (canonKey !== undefined) {
302
+ for (const rootKey of await canonicalDataRootKeys(env, dataRootPaths, ctx.signal)) {
303
+ if (relativeUnderRoot(canonKey, rootKey) !== null)
304
+ return { kind: "clear" };
305
+ }
306
+ }
307
+ return { kind: "hit", hit: byHop, via: "chain" };
247
308
  }
248
- return { kind: "hit", hit: byTarget, via: "target" };
249
309
  }
250
- for (const hop of await symlinkChainHops(env, path, ctx.signal, family, base)) {
251
- const byHop = judgeSpelled(hop);
252
- if (byHop === null)
253
- continue;
254
- for (const rootKey of await canonicalDataRootKeys(env, dataRootPaths, ctx.signal)) {
255
- if (relativeUnderRoot(canon.key, rootKey) !== null)
256
- return { kind: "clear" };
257
- }
258
- return { kind: "hit", hit: byHop, via: "chain" };
310
+ catch (err) {
311
+ noteAbsence(err instanceof Error ? err.message : String(err), false);
259
312
  }
260
313
  return { kind: "clear" };
261
314
  },
@@ -7,6 +7,21 @@ import type { SchedulerCapability, ScheduledIntent, SchedulerContext, ScheduledT
7
7
  import { SchedulerError } from "../../core/scheduler.js";
8
8
  /** Convert user-facing timeout seconds into a positive, timer-safe millisecond delay. */
9
9
  export declare function resolveExecTimeoutMs(timeoutSeconds: unknown): number | undefined;
10
+ /**
11
+ * Anchor a RELATIVE symlink target at its link's own directory, UNFOLDED and with exactly one separator
12
+ * between the two. A directory that IS a root already ends in its own separator (`/`, `C:\`, `C:/`,
13
+ * `\\host\share\`, all of which `dirname` answers that way), and concatenating a second one mints a
14
+ * DOUBLE-separator spelling for a path that has one spelling — `/tmp -> private/tmp` came back as
15
+ * `//private/tmp`, which the judges that walk this chain then match against nothing, because a guarded row
16
+ * and a containment root are both written with single separators. Joining through `path.resolve`/`path.join`
17
+ * would fix the separator and fold `..` with it, which is the one thing this walk may not do.
18
+ *
19
+ * `separator` is the one the caller's `dirname` writes, passed rather than read from the module so the rule
20
+ * is a pure function of its inputs. A backslash counts as a terminator only where it IS a separator: on a
21
+ * POSIX tree a directory whose name ends in one (`/a/b\`) is an ordinary name, and reading it as terminated
22
+ * would rename `/a/b\/t` to `/a/b\t` — the same class of rewrite as folding `..` lexically.
23
+ */
24
+ export declare function anchorAtLinkDir(linkDir: string, target: string, separator: string): string;
10
25
  /** review finding (win32): `where bash.exe` can resolve to `System32\bash.exe` — the
11
26
  * WSL LAUNCHER, not a native shell. Running it drops into a Linux subsystem where Windows cwd/paths
12
27
  * do not exist; silently adopting it is exactly the "wrong shell, false green" failure D1 forbids.
@@ -171,8 +186,10 @@ export declare class NodeExecutionEnv implements ExecutionEnv, BackgroundShellCa
171
186
  * target, until a hop is not a link (`EINVAL`), is absent (`ENOENT` / `ENOTDIR`), repeats, or the bound is
172
187
  * reached. A relative target is ANCHORED at its link's directory and never folded — `..` is the filesystem's
173
188
  * to resolve during the next `readlink`'s own lookup (a lexical fold reads `bridge/../next` as `next` and misses
174
- * every link behind `bridge`). One syscall per hop, no `lstat`. On a win32 host a share-shaped hop is kept and
175
- * ends the walk unprobed. Any other errno is an answer nobody can use (`err`), never a shortened chain.
189
+ * every link behind `bridge`). Anchoring puts EXACTLY ONE separator between the directory and the target
190
+ * ({@link anchorAtLinkDir}); a root directory already ends in its own. One syscall per hop, no `lstat`. On a
191
+ * win32 host a share-shaped hop is kept and ends the walk unprobed. Any other errno is an answer nobody can
192
+ * use (`err`), never a shortened chain.
176
193
  */
177
194
  canonicalChain(path: string): Promise<Result<SymlinkChain, FileError>>;
178
195
  exists(path: string): Promise<Result<boolean, FileError>>;
@@ -42,6 +42,10 @@ function fileKindFromStats(stats) {
42
42
  return undefined;
43
43
  }
44
44
  const HAS_POSIX_MODE_FACE = process.platform !== "win32";
45
+ export function anchorAtLinkDir(linkDir, target, separator) {
46
+ const endsInSeparator = linkDir.endsWith(separator) || (separator === "\\" && linkDir.endsWith("/"));
47
+ return endsInSeparator ? `${linkDir}${target}` : `${linkDir}${separator}${target}`;
48
+ }
45
49
  const SYMLINK_CHAIN_HOP_BOUND = 64;
46
50
  function fileInfoFromStats(path, stats) {
47
51
  const kind = fileKindFromStats(stats);
@@ -1043,7 +1047,7 @@ export class NodeExecutionEnv {
1043
1047
  return ok({ hops });
1044
1048
  return err(toFileError(error, cur));
1045
1049
  }
1046
- const next = isAbsolute(target) ? target : `${dirname(cur)}${sep}${target}`;
1050
+ const next = isAbsolute(target) ? target : anchorAtLinkDir(dirname(cur), target, sep);
1047
1051
  if (seen.has(next))
1048
1052
  break;
1049
1053
  hops.push(next);
package/dist/index.d.ts CHANGED
@@ -105,7 +105,7 @@ export { classifyCompoundReadonlyDetailed, formatOutOfRootReadApprovalOption, ty
105
105
  export { resolveBashTimeoutCaps } from "./tools/fs/index.js";
106
106
  export { READ_FACE_DEFAULT_DENY_ENTRIES, READ_FACE_BUILTIN_DENY_TABLE, READ_DENY_BUILTIN_TIERS, READ_DENY_DEFAULT_TIERS, resolveReadDenyBuiltins, compileReadDeny, type ReadDenyEntry, type ReadDenyMatcher, type NormalizedReadDenyEntry, type ReadDenyBuiltinTier, type ReadDenyBuiltinRow, type ReadDenyBuiltinConfig, } from "./tools/fs/index.js";
107
107
  export { deploymentFaceClampNotice, resolveContainmentFace, type ContainmentFace, type ContainmentFaceInputs, type ContainmentFaceKind, type ContainmentFaceLayer, } from "./tools/fs/index.js";
108
- export { WRITE_PROTECTED_DEFAULT_TABLE, resolveWriteProtectedTable, compileWriteProtection, type WriteProtectedEntry, type WriteProtectedRow, type WriteProtectedKind, type WriteProtectedHit, type WriteProtectionMatcher, type WriteProtectionVerdict, type WriteProtectionJudgeContext, type WriteProtectionCompileOptions, type WriteProtectionCheck, WRITE_PROTECTION_TARGET_VIEWS, type WriteProtectionTargetView, type WriteProtectionMount, } from "./core/write-protect.js";
108
+ export { WRITE_PROTECTED_DEFAULT_TABLE, resolveWriteProtectedTable, compileWriteProtection, type WriteProtectedEntry, type WriteProtectedRow, type WriteProtectedKind, type WriteProtectedHit, type WriteProtectionMatcher, type WriteProtectionVerdict, type WriteProtectionViewAbsence, type WriteProtectionJudgeContext, type WriteProtectionCompileOptions, type WriteProtectionCheck, WRITE_PROTECTION_TARGET_VIEWS, type WriteProtectionTargetView, type WriteProtectionMount, } from "./core/write-protect.js";
109
109
  export { InMemoryToolResultStore, OFFLOAD_TOOL_NAME, DEFAULT_TOOL_RESULT_THRESHOLD_CHARS, assertSafeToolResultRef, buildToolResultRef, toolResultContentSegment, MAX_MINTED_TOOL_RESULT_REF_CHARS, type ToolResultProvenance, assertToolResultProvenanceMatch, normalizeToolResultProvenance, toolResultProvenanceOf, ToolResultRefConflictError, TOOL_RESULT_REF_CONFLICT_CODE, type ToolResultStore, type ToolResultSlice, type ToolResultDeletionReport, } from "./core/tool-result-store.js";
110
110
  export { InMemoryCheckpointStore, CheckpointError, mintCheckpointId, mintCheckpointToken, checkpointVersionOf, CURRENT_CHECKPOINT_VERSION, MAX_SUPPORTED_CHECKPOINT_VERSION, ORG_ADMISSION_CHECKPOINT_VERSION, F012_CHECKPOINT_VERSION, REAL_APPROVAL_CHECKPOINT_VERSION, RESOURCE_CHECKPOINT_VERSION, TOKEN_CHECKPOINT_VERSION, debitLedger, remainingBudgetMicroUsd, remainingTokens, winnerFromOutcome, validatePendingSteer, readPendingSteerQueue, appendPendingSteer, MAX_PENDING_STEER_CHARS, MAX_PENDING_STEER_ENTRIES, PENDING_STEER_QUEUE_BYTE_BUDGET_BYTES, PENDING_STEER_FROZEN_FIELDS, ACTOR_ASSERTION_FROZEN_FIELDS, MAX_ACTOR_FIELD_CHARS, MAX_STEER_INPUT_ID_CHARS, type ActorAssertion, type PendingSteerEntry, type PendingSteerInput, riskSeverity, buildRiskDescriptor, summarizeCheckpoint, type RiskDescriptor, type ProbeCause, type ProbeCauseOperands, type CheckpointStore, type CheckpointSummary, type Checkpoint, type CheckpointToken, type CheckpointGate, type CheckpointState, type SerializedCheckpointState, type CheckpointFaultMode, type PendingAction, type ResumeOutcome, type ResolvedOutcome, type ReopenReason, type ResolveExpectation, type TerminalClaimIntent, type TerminalClaimOutcome, type SafetyAxis, type RealApprovalGateBit, type ResourceLedger, type ResourceLimitReason, type PlatformLimitReason, type CheckpointRow, realApprovalOrgFact, resolveCheckpointStore, checkpointStoreExecutionUndeclared, } from "./core/checkpoint-store.js";
111
111
  export { EXECUTION_OUTCOME_RECORD_WORDS, EXECUTION_RECORD_LEAVES_ROW_UNRECORDED, type ExecutionOutcomeRecordWord, type ExecutionRecordRowFacts, type ExecutionVerdict, ExecutionOutcomeConflictError, checkpointExecutionRecorded, executionRecordDisposition, sameExecutionRecord, executionVerdict, } from "./core/checkpoint-execution-record.js";
@@ -92,20 +92,27 @@ export interface SegmentTokens {
92
92
  /** What a substitution leaves in a word's FOLDED spelling: a marker no argv word can spell, so a consumer that
93
93
  * judges words sees "this word carries text the shell computes" rather than the computing text itself. */
94
94
  export declare const SUBSTITUTION_PLACEHOLDER = " subst";
95
+ export type QuoteOpen = '"' | "'" | undefined;
95
96
  /**
96
- * Which characters of `s` sit inside a QUOTED region, by the same pairing rule the lexer walks with. The pairing
97
- * quote characters themselves are reported as quoted — they are not operators either way.
97
+ * Which QUOTE holds each character of `s`, by the same pairing rule the lexer walks with, and the boolean view
98
+ * of it. The pairing quote characters themselves belong to the region they bound — they are not operators
99
+ * either way — so `openedBy` names that region's quote character at their own index too.
100
+ *
101
+ * `quoted` is a VIEW of `openedBy`, minted in the same step: a consumer asking only "is this inside quotes"
102
+ * reads it, one asking WHICH quote (single quotes suppress every expansion, double quotes suppress neither `$`
103
+ * nor the backtick) reads `openedBy`, and the two cannot drift.
98
104
  *
99
105
  * `balanced` is false when a region is still open at the end of the string. Callers must then NOT use the mask:
100
106
  * an unterminated quote is a string bash itself cannot parse, and every consumer falls back to the quote-BLIND
101
107
  * reading, which is the stricter one.
102
108
  *
103
109
  * EXACTNESS. This models bash only for the residual character space the strict faces operate in, where backslash
104
- * escapes, `$`, backticks, and subshells are rejected outright — there `'` and `"` pairing is the whole of
105
- * quoting, so a region computed here is exactly a region bash quotes. It is NOT a general shell quoting parser
106
- * and must not be used before those characters are excluded.
110
+ * escapes and subshells are rejected outright — there `'` and `"` pairing is the whole of quoting, so a region
111
+ * computed here is exactly a region bash quotes. A caller deciding about `$` or a backtick on this mask owes
112
+ * the argument in `bash-readonly-classifier.ts`. It is NOT a general shell quoting parser.
107
113
  */
108
114
  export declare function quoteMask(s: string): {
115
+ openedBy: readonly QuoteOpen[];
109
116
  quoted: readonly boolean[];
110
117
  balanced: boolean;
111
118
  };
@@ -5,14 +5,17 @@ function quoteStep(open, ch) {
5
5
  return open === ch ? undefined : open;
6
6
  }
7
7
  export function quoteMask(s) {
8
+ const openedBy = new Array(s.length).fill(undefined);
8
9
  const quoted = new Array(s.length).fill(false);
9
10
  let open;
10
11
  for (let i = 0; i < s.length; i++) {
11
12
  const before = open;
12
13
  open = quoteStep(open, s[i]);
13
- quoted[i] = before !== undefined || open !== undefined;
14
+ const holds = before ?? open;
15
+ openedBy[i] = holds;
16
+ quoted[i] = holds !== undefined;
14
17
  }
15
- return { quoted, balanced: open === undefined };
18
+ return { openedBy, quoted, balanced: open === undefined };
16
19
  }
17
20
  export function literalTildeSpelling(word) {
18
21
  return word.startsWith("~") ? `./${word}` : word;
@@ -114,6 +114,9 @@ export type ProgramPositionRefusal = {
114
114
  readonly kind: "control_syntax_at_program_position";
115
115
  readonly owner: string;
116
116
  readonly word: string;
117
+ /** Not a DECLINE ({@link programPositionRunsShell}): the row WAS read and a SHELL stands at the program
118
+ * position. Absent ⇒ genuine control syntax, which IS one. */
119
+ readonly runsShell?: true;
117
120
  };
118
121
  export interface ProgramPosition {
119
122
  /** Indices of the bare control words stripped in front (`!`, `if`, `time`, …), in order; their declared options
@@ -159,5 +162,20 @@ export declare function readProgramPosition(toks: SegmentTokens): ProgramPositio
159
162
  * stdin AND the program it runs is not one the caller accepts as a stream target. ONE reading, shared by every
160
163
  * consumer; WHICH targets are accepted is the caller's declaration, not this reader's. */
161
164
  export declare function argvReentryUnjudged(pos: ProgramPosition, acceptedTargets: ReadonlySet<string>): boolean;
165
+ /**
166
+ * The two ways this reader DECLINES rather than judges — a CLOSED set, because which of them a seat reads as
167
+ * evidence is a table each seat declares against (`fs-bash.ts`, where the argument is), not a boolean buried
168
+ * in a caller. `unreadable_program_position`: the walk cannot name the program bash will run (`env -- ls`).
169
+ * `stream_fed_operands`: the operands arrive on a STREAM it never saw (`… | xargs cat`). Neither names a WORD.
170
+ */
171
+ export declare const SHELL_SCAN_DECLINES: readonly ["unreadable_program_position", "stream_fed_operands"];
172
+ export type ShellScanDecline = (typeof SHELL_SCAN_DECLINES)[number];
173
+ /** Does a SHELL stand where this segment's program would be — as the program itself (`sh -c`), or as a launcher
174
+ * ROW handing its operands to one (`watch "…"`)? ONE predicate for the re-entry class, so the two spellings
175
+ * cannot be answered differently; which names are shells is the caller's declaration. Not a decline. */
176
+ export declare function programPositionRunsShell(pos: ProgramPosition, shellProgram: RegExp): boolean;
177
+ /** Which decline, if any, this program position raises — ONE reading of the two facts above, so no consumer
178
+ * re-derives either. A refusal that says a SHELL runs is NOT one: it names what runs. */
179
+ export declare function shellScanDeclineOf(pos: ProgramPosition, acceptedStreamTargets: ReadonlySet<string>): ShellScanDecline | undefined;
162
180
  /** ONE sentence per refusal kind, minted here and nowhere else. */
163
181
  export declare function programPositionRefusalSentence(refusal: ProgramPositionRefusal): string;
@@ -107,7 +107,7 @@ export function readProgramPosition(toks) {
107
107
  }
108
108
  const launchers = [{ index: i, name: basename }];
109
109
  const owner = head;
110
- const shellRefusal = () => ({ kind: "control_syntax_at_program_position", owner, word: "sh" });
110
+ const shellRefusal = () => ({ kind: "control_syntax_at_program_position", owner, word: "sh", runsShell: true });
111
111
  let owedOperands = LAUNCHER_TABLE.get(basename)?.requiredOperands ?? 0;
112
112
  let lastRow = LAUNCHER_TABLE.get(basename);
113
113
  const chain0 = (program, refusal) => ({ controlWords, written, assignments, launchers, argvFromStdin: false, runsDefaultProgram: false, program, refusal });
@@ -158,6 +158,18 @@ export function argvReentryUnjudged(pos, acceptedTargets) {
158
158
  const runs = pos.program?.basename ?? (pos.runsDefaultProgram ? ARGV_REENTRY_DEFAULT_PROGRAM : undefined);
159
159
  return runs === undefined || !acceptedTargets.has(runs);
160
160
  }
161
+ export const SHELL_SCAN_DECLINES = ["unreadable_program_position", "stream_fed_operands"];
162
+ export function programPositionRunsShell(pos, shellProgram) {
163
+ if (pos.refusal !== undefined)
164
+ return pos.refusal.kind === "control_syntax_at_program_position" && pos.refusal.runsShell === true;
165
+ return pos.program !== undefined && shellProgram.test(pos.program.basename);
166
+ }
167
+ export function shellScanDeclineOf(pos, acceptedStreamTargets) {
168
+ if (pos.refusal !== undefined) {
169
+ return pos.refusal.kind === "control_syntax_at_program_position" && pos.refusal.runsShell === true ? undefined : "unreadable_program_position";
170
+ }
171
+ return argvReentryUnjudged(pos, acceptedStreamTargets) ? "stream_fed_operands" : undefined;
172
+ }
161
173
  export function programPositionRefusalSentence(refusal) {
162
174
  switch (refusal.kind) {
163
175
  case "option_at_program_position":
@@ -1,4 +1,5 @@
1
1
  import { type ShellSegmentConnector } from "./bash-lexer.js";
2
+ import { type ShellScanDecline } from "./bash-program-position.js";
2
3
  /**
3
4
  * Bare command names `bash_readonly` permits out of the box, ALSO the default reversible set for the
4
5
  * The {@link import("./fs-bash.js").bashReversibilityProbe} classifier. **Coarse first filter, NOT a security
@@ -31,7 +32,6 @@ import { type ShellSegmentConnector } from "./bash-lexer.js";
31
32
  * value-taking options defeat the stdin-floor operand count without a per-verb option-value model
32
33
  * — those are a follow-on slice, not a silent drop.
33
34
  */
34
- export { NOT_AUTO_ALLOWED } from "./bash-program-position.js";
35
35
  export type { ShellSegmentConnector } from "./bash-lexer.js";
36
36
  export declare const BASH_READONLY_DEFAULT_ALLOW: readonly string[];
37
37
  /**
@@ -174,7 +174,7 @@ export interface ShellCompoundSegments {
174
174
  * drift in a segmentation is drift in what a deny rule is even looking at.
175
175
  *
176
176
  * Three steps, in this order and load-bearing:
177
- * 1. WHOLE-STRING hard reject of {@link SHELL_SEGMENT_HARD_REJECT} — redirection, substitution,
177
+ * 1. WHOLE-STRING hard reject ({@link hardRejectReason}) — redirection, substitution,
178
178
  * subshells, escapes, line breaks. This is what makes step 3's quote mask EXACT: with those
179
179
  * characters gone, `'`/`"` pairing is the entirety of quoting, so the mask's regions are exactly
180
180
  * bash's quoted regions and the unquoted connectors are exactly bash's command boundaries. Callers
@@ -524,7 +524,10 @@ export declare function resolveOperandSpellings(base: string | undefined, operan
524
524
  * here-document, a here-string or a literal in-root stdin file; a directory change (which then makes every relative
525
525
  * stdin literal in the command unjudged). A substitution body (`$(…)`, backticks, `<(…)`) is judged on its own: green
526
526
  * under the compound walk, or exempt under this whitelist; a here-document body is text, not commands. */
527
- export declare function refusedCommandMayRead(command: string, allow: ReadonlySet<string>, boundary?: BashReadonlyRootBoundary): boolean;
527
+ export declare function refusedCommandMayRead(command: string, allow: ReadonlySet<string>, boundary: BashReadonlyRootBoundary | undefined,
528
+ /** The {@link ShellScanDecline} members THIS SEAT reads as evidence (tables and argument: `fs-bash.ts`); one
529
+ * the table omits ends the scan with "exempt". */
530
+ declinesAreEvidence: ReadonlySet<ShellScanDecline>): boolean;
528
531
  /**
529
532
  * The home directory's VARIABLE spellings, SUBSTITUTED with the declared value before a read face segments the
530
533
  * command: `$HOME` / `${HOME}` at the START of a word (an empty quote pair before it included), unquoted or
@@ -602,6 +605,10 @@ export interface OutOfRootReadGateVerdict {
602
605
  * operand is placed against a base bash has already left, so no path can be named honestly. Read it
603
606
  * as display/triage evidence; emptiness is not "not gated". */
604
607
  readonly paths: readonly string[];
608
+ /** Did the deployment's DENY judge speak about an operand this command names? The other half of the same
609
+ * question, over the very same readers, in a second pass WITH the deny judge — which is what lets the
610
+ * containment answer above stay deny-free and keep its `paths` contract. Absent ⇔ it said nothing. */
611
+ readonly denied?: true;
605
612
  }
606
613
  /** Test seam for the lockstep pin that the launcher table (the ONE launcher truth, in the program-position
607
614
  * reader's module) stays a SUBSET of the rule lane's curated `BARE_INTERPRETER_NAMES`. Exported for that one