@sema-agent/core 7.17.0 → 7.17.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 (37) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/dist/core/memory-engine/engine.js +2 -1
  3. package/dist/core/memory-engine/layout.d.ts +18 -6
  4. package/dist/core/memory-engine/layout.js +40 -21
  5. package/dist/core/physical-path.d.ts +37 -0
  6. package/dist/core/physical-path.js +30 -0
  7. package/dist/core/runner/contracts.d.ts +1 -1
  8. package/dist/core/runner/prepare-artifact.d.ts +4 -5
  9. package/dist/core/runner/prepare-artifact.js +2 -16
  10. package/dist/core/runner/prepare-policy-chain.js +2 -2
  11. package/dist/core/runner/prepare-question-face.js +2 -1
  12. package/dist/core/runner/prepare-task.js +2 -2
  13. package/dist/core/runner/run-harness-handlers.js +4 -1
  14. package/dist/core/sensitive-path-policy.js +7 -8
  15. package/dist/core/skills-directory.js +4 -3
  16. package/dist/core/spec-contract.js +5 -4
  17. package/dist/core/task-registry-shared.d.ts +5 -1
  18. package/dist/core/task-registry-shared.js +1 -0
  19. package/dist/core/tool-catalog-entries.js +1 -1
  20. package/dist/core/tool-policy.d.ts +15 -0
  21. package/dist/core/tool-policy.js +3 -0
  22. package/dist/engine/execution-env/node-execution-env.js +4 -3
  23. package/dist/index.d.ts +1 -0
  24. package/dist/index.js +1 -0
  25. package/dist/orchestration/workflow-script-store.js +9 -25
  26. package/dist/orchestration/workflow.js +6 -3
  27. package/dist/stores/cc/task-list-store.js +2 -10
  28. package/dist/stores/file/fs-atomic.d.ts +15 -18
  29. package/dist/stores/file/fs-atomic.js +4 -14
  30. package/dist/stores/file/mailbox-store.d.ts +7 -11
  31. package/dist/stores/file/mailbox-store.js +4 -11
  32. package/dist/tools/artifact/local-stub.js +4 -3
  33. package/dist/tools/fs/bash-readonly-classifier.d.ts +17 -1
  34. package/dist/tools/fs/bash-readonly-classifier.js +125 -12
  35. package/dist/tools/fs/fs-bash.js +42 -17
  36. package/package.json +1 -1
  37. package/test/export-surface.snapshot.json +5 -1
@@ -1,13 +1,14 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { closeSync, constants, createReadStream, mkdtempSync, openSync, readSync, statSync, truncateSync, unlinkSync } from "node:fs";
4
- import { access, appendFile, lstat, mkdir, mkdtemp, open, readdir, readFile, readlink, realpath, rename, rm, unlink, writeFile, } from "node:fs/promises";
4
+ import { access, appendFile, lstat, mkdir, mkdtemp, open, readdir, readFile, readlink, rename, rm, unlink, writeFile, } from "node:fs/promises";
5
5
  import { tmpdir } from "node:os";
6
6
  import { isAbsolute, join, resolve } from "node:path";
7
7
  import { createInterface } from "node:readline";
8
8
  import { ExecutionError, err, FileError, ok, toError, } from "../harness/types.js";
9
9
  import { killProcessTree, shutdownDebug } from "./kill-tree.js";
10
10
  import { scrubSecretEnv } from "../../core/secret-env.js";
11
+ import { physicalPathOfAsync } from "../../core/physical-path.js";
11
12
  import { RollingTailBuffer, markTruncated, newStreamCursorState, sliceStreamIncrement, } from "../../core/exec-output-tail.js";
12
13
  import { BackgroundShellError } from "../../core/background-shell.js";
13
14
  import { SchedulerError } from "../../core/scheduler.js";
@@ -802,7 +803,7 @@ export class NodeExecutionEnv {
802
803
  let hop;
803
804
  try {
804
805
  const link = await readlink(resolved);
805
- const parentReal = await realpath(resolve(resolved, "..")).catch(() => resolve(resolved, ".."));
806
+ const parentReal = await physicalPathOfAsync(resolve(resolved, "..")).catch(() => resolve(resolved, ".."));
806
807
  hop = isAbsolute(link) ? link : resolve(parentReal, link);
807
808
  }
808
809
  catch {
@@ -1004,7 +1005,7 @@ export class NodeExecutionEnv {
1004
1005
  async canonicalPath(path) {
1005
1006
  const resolved = resolvePath(this.cwd, path);
1006
1007
  try {
1007
- return ok(await realpath(resolved));
1008
+ return ok(await physicalPathOfAsync(resolved));
1008
1009
  }
1009
1010
  catch (error) {
1010
1011
  return err(toFileError(error, resolved));
package/dist/index.d.ts CHANGED
@@ -83,6 +83,7 @@ export { getShellConfig, isWslBashLauncher } from "./engine/execution-env/node-e
83
83
  export { isSecretEnvKey, scrubSecretEnv } from "./core/secret-env.js";
84
84
  export type { SecretEnvFinding, SecretEnvFindingKind } from "./core/secret-env.js";
85
85
  export { MAX_EXEC_OUTPUT_BYTES, RollingTailBuffer, markTruncated } from "./core/exec-output-tail.js";
86
+ export { physicalPathOf, physicalPathOfExisting } from "./core/physical-path.js";
86
87
  export type { ExecutionEnv, FileInfo, Result, FileErrorCode, ExecutionErrorCode, WriteExpectation, WriteReceipt } from "./internal/harness.js";
87
88
  export type { ExecResult } from "./internal/harness.js";
88
89
  export { RemoteExecutionError, hasDestroy, isRemoteExecutionEnv, isSuspendable, isIsolated, missingRestoreSurface, isRetryableRemoteErrorCode, RETRYABLE_REMOTE_ERROR_CODES, } from "./core/remote-env.js";
package/dist/index.js CHANGED
@@ -68,6 +68,7 @@ export { killProcessTree, signalProcessTree } from "./engine/execution-env/kill-
68
68
  export { getShellConfig, isWslBashLauncher } from "./engine/execution-env/node-execution-env.js";
69
69
  export { isSecretEnvKey, scrubSecretEnv } from "./core/secret-env.js";
70
70
  export { MAX_EXEC_OUTPUT_BYTES, RollingTailBuffer, markTruncated } from "./core/exec-output-tail.js";
71
+ export { physicalPathOf, physicalPathOfExisting } from "./core/physical-path.js";
71
72
  export { RemoteExecutionError, hasDestroy, isRemoteExecutionEnv, isSuspendable, isIsolated, missingRestoreSurface, isRetryableRemoteErrorCode, RETRYABLE_REMOTE_ERROR_CODES, } from "./core/remote-env.js";
72
73
  export { withRetry } from "./core/with-retry.js";
73
74
  export { addWorktree, pruneWorktrees, WORKTREE_PARENT } from "./core/git-worktree-env.js";
@@ -1,5 +1,6 @@
1
- import { mkdirSync, readFileSync, realpathSync, writeFileSync, existsSync } from "node:fs";
2
- import { basename, dirname, join, resolve, sep } from "node:path";
1
+ import { mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
2
+ import { join, resolve, sep } from "node:path";
3
+ import { physicalPathOfExisting } from "../core/physical-path.js";
3
4
  import { sanitizeScope } from "../stores/file/fs-atomic.js";
4
5
  function isPlainObject(v) {
5
6
  return typeof v === "object" && v !== null && !Array.isArray(v);
@@ -33,23 +34,6 @@ function safeFileStem(id) {
33
34
  function containedBy(root, p) {
34
35
  return p === root || p.startsWith(root + sep);
35
36
  }
36
- function canonicalizeDeepestExisting(p) {
37
- let cur = p;
38
- const missingTail = [];
39
- for (;;) {
40
- try {
41
- const real = realpathSync(cur);
42
- return missingTail.length === 0 ? real : join(real, ...[...missingTail].reverse());
43
- }
44
- catch {
45
- const parent = dirname(cur);
46
- if (parent === cur)
47
- return undefined;
48
- missingTail.push(basename(cur));
49
- cur = parent;
50
- }
51
- }
52
- }
53
37
  export function createFileWorkflowScriptStore(dir) {
54
38
  const root = resolve(dir);
55
39
  const scopeDir = (scope) => join(root, sanitizeScope(scope));
@@ -71,17 +55,17 @@ export function createFileWorkflowScriptStore(dir) {
71
55
  if (!containedBy(base, full)) {
72
56
  throw new Error(`workflow script store: scriptPath is outside this scope's script partition`);
73
57
  }
74
- const physRoot = canonicalizeDeepestExisting(root);
75
- const physBase = canonicalizeDeepestExisting(base);
76
- const physFull = canonicalizeDeepestExisting(full);
58
+ const physRoot = physicalPathOfExisting(root);
59
+ const physBase = physicalPathOfExisting(base);
60
+ const physFull = physicalPathOfExisting(full);
77
61
  const refusePhysical = () => {
78
62
  throw new Error(`workflow script store: scriptPath is outside this scope's script partition after symlink resolution`);
79
63
  };
80
- if (physRoot !== undefined && physBase !== undefined && !containedBy(physRoot, physBase))
64
+ if (!containedBy(physRoot, physBase))
81
65
  refusePhysical();
82
- if (physBase !== undefined && physFull !== undefined && !containedBy(physBase, physFull))
66
+ if (!containedBy(physBase, physFull))
83
67
  refusePhysical();
84
- return readFileSync(physFull ?? full, "utf-8");
68
+ return readFileSync(physFull, "utf-8");
85
69
  },
86
70
  resolveName(name) {
87
71
  if (/^wf_/i.test(name))
@@ -460,6 +460,9 @@ export const workflowResumeClaimFallback = {
460
460
  table.delete(key);
461
461
  },
462
462
  };
463
+ function parkLaneArmed(opts) {
464
+ return opts.store !== undefined && opts.checkpointStore !== undefined;
465
+ }
463
466
  export function startWorkflow(runner, fn, opts = {}, internals) {
464
467
  const alsDepth = currentWorkflowDepth();
465
468
  const internalDepth = internals?.workflowDepth;
@@ -472,10 +475,10 @@ export function startWorkflow(runner, fn, opts = {}, internals) {
472
475
  if (opts.resumeFromRunId !== undefined && opts.journalStore === undefined) {
473
476
  throw new Error("runWorkflow: resumeFromRunId requires a journalStore to load the prior run's journal");
474
477
  }
475
- if (opts.checkpointStore !== undefined && opts.store === undefined) {
478
+ if (opts.checkpointStore !== undefined && !parkLaneArmed(opts)) {
476
479
  throw new WorkflowParkRefusal("workflow.park_requires_run_store", "runWorkflow: checkpointStore (the store this run's children park in) requires a WorkflowRunStore — a workflow child's park is recorded on its wa* row and proven against the checkpoint store at the next resume, and without a run store there is no durable row for a host to route the parked approval by", {});
477
480
  }
478
- if (opts.defaultDurableApproval !== undefined && (opts.store === undefined || opts.checkpointStore === undefined)) {
481
+ if (opts.defaultDurableApproval !== undefined && !parkLaneArmed(opts)) {
479
482
  throw new WorkflowParkRefusal("workflow.park_requires_run_store", "runWorkflow: defaultDurableApproval requires a WorkflowRunStore and a checkpointStore — a workflow child's park is recorded on its wa* row and read back from the checkpoint store at the next resume; without both there is no durable row for a host to route the parked approval by, or no way for a resume to prove the park", {});
480
483
  }
481
484
  if (opts.parkedResume !== undefined && opts.resumeFromRunId === undefined) {
@@ -917,7 +920,7 @@ export function startWorkflow(runner, fn, opts = {}, internals) {
917
920
  const seat = s.checkpointStore;
918
921
  if (seat === undefined || seat === "disabled")
919
922
  return;
920
- if (parkSeat === undefined) {
923
+ if (!parkLaneArmed(opts)) {
921
924
  throw new WorkflowParkRefusal("workflow.park_requires_run_store", "workflow agent: the child's checkpointStore would let it park where this workflow cannot read the park back — a workflow whose children can park needs a WorkflowRunStore and that checkpoint store as its read seat (RunWorkflowOptions.store + checkpointStore); wire both, or leave the child's seat unset.", {});
922
925
  }
923
926
  if (seat !== parkSeat) {
@@ -2,21 +2,13 @@ import { readFileSync, readdirSync, mkdirSync, unlinkSync } from "node:fs";
2
2
  import { join, resolve } from "node:path";
3
3
  import { assertJsonMetadata, normalizeTaskShape } from "../../tools/task-list.js";
4
4
  import { atomicWriteFile } from "../file/fs-atomic.js";
5
- import { realpathSync } from "node:fs";
6
- function realpathSyncSafe(p) {
7
- try {
8
- return realpathSync(p);
9
- }
10
- catch {
11
- return p;
12
- }
13
- }
5
+ import { physicalPathOf } from "../../core/physical-path.js";
14
6
  import { withCcLock } from "./lockfile.js";
15
7
  export function createCcFileTaskListStore(listDir) {
16
8
  const dir = resolve(listDir);
17
9
  mkdirSync(dir, { recursive: true });
18
10
  const hwmPath = join(dir, ".highwatermark");
19
- const lockTarget = realpathSyncSafe(dir);
11
+ const lockTarget = physicalPathOf(dir);
20
12
  const CC_TASK_ID_RE = /^[A-Za-z0-9_-]+$/;
21
13
  const validId = (id) => CC_TASK_ID_RE.test(id);
22
14
  const taskPath = (id) => {
@@ -11,10 +11,8 @@ export declare function sanitizePathComponent(raw: string): string;
11
11
  * on-disk dir name, never the stored data. Never `path.join` a raw scope.
12
12
  */
13
13
  export declare function sanitizeScope(scope: string): string;
14
- /**
15
- * Resolve the data root (CC `getClaudeConfigHomeDir` analog): `$AGENT_DATA_DIR ?? ~/.ai-agent`,
16
- * NFC-normalized, and `realpath`-canonicalized once the dir exists. Creates the dir (0o700) if absent.
17
- */
14
+ /** Resolve the data root (CC `getClaudeConfigHomeDir` analog): `$AGENT_DATA_DIR ?? ~/.ai-agent`,
15
+ * NFC-normalized, canonicalized once the dir exists. Creates the dir (0o700) if absent. */
18
16
  export declare function resolveDataRoot(explicit?: string): string;
19
17
  /** Ensure a directory exists with 0o700 perms (idempotent). */
20
18
  /**
@@ -59,25 +57,24 @@ export declare function readJsonlRecords<T>(path: string, onCorrupt?: (info: {
59
57
  * append (the checkpoint commit point ALWAYS fsyncs; session/memory cadence is the caller's `fsyncEvery`).
60
58
  */
61
59
  /**
62
- * RB-144 (2026-07-25, 按面收口): THE canonical key for "one authority per physical location".
63
- *
64
- * Every file backend keeps a module-level table so that N instances over one directory collapse into one
65
- * CAS authority. Getting the KEY wrong reopens the exact defect the table exists to close — and this repo
66
- * has now paid for that four separate times: RB-62 taught the mailbox to realpath, RB-101 taught the
67
- * session store, RB-119 added case folding there, and a defect hunt then found the agent store, the run store
68
- * and the task-list still keying on a lexical `resolve()` while two of them cite the mailbox as the
69
- * precedent they copied. Point fixes kept missing siblings, so the rule now lives in ONE place that all of
70
- * them call.
60
+ * RB-144: THE canonical key for "one authority per physical location". Every file backend keeps a
61
+ * module-level table so that N instances over one directory collapse into one CAS authority; getting the
62
+ * KEY wrong reopens the defect the table exists to close, which this repo paid for four times (mailbox,
63
+ * session store, then the agent/run/task-list stores still keying on a lexical `resolve()`). Point fixes
64
+ * kept missing siblings, so the rule lives in ONE place they all call.
71
65
  *
72
66
  * Two normalizations, each for a demonstrated failure:
73
- * - REALPATH — a symlinked data dir (`/var` → `/private/var` on macOS, a container bind-mount, a linked
67
+ * - PHYSICAL PATH — a symlinked data dir (`/var` → `/private/var` on macOS, a bind-mount, a linked
74
68
  * `~/.ai-agent`) otherwise yields two authorities for one directory, and both writers win the CAS.
75
- * - CASE FOLD — on a case-insensitive filesystem (macOS/Windows default) `Foo/` and `foo/` are the same
76
- * directory; `realpath` does NOT fold case there, so realpath alone is not enough (measured).
69
+ * - CASE FOLD — on a case-insensitive filesystem `Foo/` and `foo/` are the same directory. The mint
70
+ * answers with the on-disk spelling, which folds the two only where the entry EXISTS; the fold here
71
+ * also covers a not-yet-created tail, and folding a PATH would name a different directory on a
72
+ * case-SENSITIVE volume (see the cc task-list store's lock target, which must not fold) — so it stays
73
+ * at this call site, which keys, rather than inside the mint, which answers about paths.
77
74
  *
78
75
  * The key is used ONLY for table lookup — never for I/O, so the on-disk name stays verbatim. A path that
79
- * does not exist yet resolves through its parent directory; if even that fails, the lexical form is used
80
- * (a store must not fail to construct because canonicalization is unavailable).
76
+ * does not exist yet resolves through its deepest existing ancestor; a path with no resolvable ancestor at
77
+ * all keys on its lexical form (a store must not fail to construct because canonicalization is unavailable).
81
78
  */
82
79
  export declare function canonicalStoreKey(p: string): string;
83
80
  export declare class AppendLog {
@@ -1,9 +1,10 @@
1
- import { closeSync, constants as FS, existsSync, fstatSync, linkSync, mkdirSync, openSync, readFileSync, readSync, realpathSync, renameSync, truncateSync, unlinkSync, writeFileSync, writeSync, fsyncSync, } from "node:fs";
1
+ import { closeSync, constants as FS, existsSync, fstatSync, linkSync, mkdirSync, openSync, readFileSync, readSync, renameSync, truncateSync, unlinkSync, writeFileSync, writeSync, fsyncSync, } from "node:fs";
2
2
  import { homedir, hostname, uptime } from "node:os";
3
3
  import { hrtime } from "node:process";
4
4
  import { basename, dirname, join, resolve as resolvePath } from "node:path";
5
5
  import { createHash, randomBytes } from "node:crypto";
6
6
  import { execFileSync } from "node:child_process";
7
+ import { physicalPathOf, physicalPathOfExisting } from "../../core/physical-path.js";
7
8
  const SAFE_COMPONENT = /^[A-Za-z0-9_.-]+$/;
8
9
  export function sanitizePathComponent(raw) {
9
10
  if (raw === "" || raw === "." || raw === ".." || !SAFE_COMPONENT.test(raw)) {
@@ -18,7 +19,7 @@ export function sanitizeScope(scope) {
18
19
  export function resolveDataRoot(explicit) {
19
20
  const raw = (explicit ?? process.env.AGENT_DATA_DIR ?? join(homedir(), ".ai-agent")).normalize("NFC");
20
21
  mkdirSync(raw, { recursive: true, mode: 0o700 });
21
- return realpathSync(raw);
22
+ return physicalPathOf(raw);
22
23
  }
23
24
  export function writeThenLink(target, content) {
24
25
  ensureDir(dirnameOf(target));
@@ -134,18 +135,7 @@ export function readJsonlRecords(path, onCorrupt) {
134
135
  return out;
135
136
  }
136
137
  export function canonicalStoreKey(p) {
137
- const abs = resolvePath(p);
138
- try {
139
- return realpathSync(abs).toLowerCase();
140
- }
141
- catch {
142
- try {
143
- return join(realpathSync(dirname(abs)), basename(abs)).toLowerCase();
144
- }
145
- catch {
146
- return abs.toLowerCase();
147
- }
148
- }
138
+ return physicalPathOfExisting(p).toLowerCase();
149
139
  }
150
140
  export class AppendLog {
151
141
  fd;
@@ -81,17 +81,13 @@ export declare class FileMailboxStore implements MailboxStore {
81
81
  /**
82
82
  * The canonical mutex / shared-state key — instance-independent (X-5).
83
83
  *
84
- * RB-162 (2026-07-26): this went through `realpath` (RB-62) but NOT the case fold (RB-67/RB-119), even
85
- * though the module header above claims "the shared-state KEY goes through `canonicalStoreKey`, which
86
- * also folds case". It did not that name appeared exactly once in this file, inside that sentence.
87
- * The handle component was folded by `boxPath`, but the ROOT was not: two spellings of the same
88
- * directory on a case-insensitive filesystem produced two shared-state slots over one physical file, and
89
- * each allocated its own `seq` the very contract violation the realpath comment above describes,
90
- * reached by the other half of the same canonicalization.
91
- *
92
- * Worse for the record: the enumerative guard written for this exact family tested
93
- * `src.includes("canonicalStoreKey")`, which that one comment satisfied. The guard was green because the
94
- * file talked about the rule.
84
+ * RB-162: this went through the physical path but NOT the case fold, even though the module header
85
+ * claimed otherwise. The handle component was folded by `boxPath` while the ROOT was not, so two
86
+ * spellings of one directory on a case-insensitive filesystem produced two shared-state slots over one
87
+ * physical file, each allocating its own `seq` the same contract violation the RB-62 comment above
88
+ * describes, reached by the other half of the same canonicalization. The enumerative guard written for
89
+ * this family tested `src.includes("canonicalStoreKey")`, which the CLAIM satisfied: a guard that reads
90
+ * prose is green while the code diverges.
95
91
  */
96
92
  private lockKey;
97
93
  /**
@@ -1,18 +1,11 @@
1
1
  import { join, resolve, sep } from "node:path";
2
2
  import { randomBytes } from "node:crypto";
3
3
  import { assertRetentionPolicy } from "../../core/retention-policy.js";
4
- import { existsSync, linkSync, readdirSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
4
+ import { existsSync, linkSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
5
5
  import { detachMailboxMessage, newestSentAt, readMailboxPeerMeta, } from "../../core/mailbox-store.js";
6
6
  import { AppendLog, atomicWriteFile, canonicalStoreKey, ensureDir, readJsonlRecords, sanitizeScope, sanitizePathComponent } from "./fs-atomic.js";
7
+ import { physicalPathOf } from "../../core/physical-path.js";
7
8
  import { assertAdoptionBootGate } from "./adoption/marker.js";
8
- function realpathSyncSafe(p) {
9
- try {
10
- return realpathSync(p);
11
- }
12
- catch {
13
- return p;
14
- }
15
- }
16
9
  function diskMarkOf(path) {
17
10
  try {
18
11
  const st = statSync(path);
@@ -361,8 +354,8 @@ export class FileMailboxStore {
361
354
  const lexicalTmp = resolve(join(root, "tmp"));
362
355
  ensureDir(lexicalDir);
363
356
  ensureDir(lexicalTmp);
364
- this.dir = realpathSyncSafe(lexicalDir);
365
- this.tmpDir = realpathSyncSafe(lexicalTmp);
357
+ this.dir = physicalPathOf(lexicalDir);
358
+ this.tmpDir = physicalPathOf(lexicalTmp);
366
359
  }
367
360
  boxPath(scope, handle) {
368
361
  return join(this.dir, sanitizeScope(scope), `${sanitizePathComponent(handle).toLowerCase()}.jsonl`);
@@ -1,7 +1,8 @@
1
1
  import { randomBytes } from "node:crypto";
2
- import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
+ import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { dirname, join, resolve, sep } from "node:path";
4
4
  import {} from "../../core/artifact-host.js";
5
+ import { physicalPathOf } from "../../core/physical-path.js";
5
6
  import { acquireStoreDirLock } from "../../stores/file/fs-atomic.js";
6
7
  import { normalizePublishedPath } from "./artifact-tool.js";
7
8
  export const EVAL_STUB_URL_PREFIX = "eval-stub://artifact/";
@@ -112,9 +113,9 @@ export class LocalArtifactStub {
112
113
  }
113
114
  #snapshotOf(row) {
114
115
  const base = this.#versionDir(row.slug, row.version);
115
- const realBase = realpathSync(base);
116
+ const realBase = physicalPathOf(base);
116
117
  const files = row.files.map((f) => {
117
- const target = realpathSync(resolve(base, ...f.path.split("/")));
118
+ const target = physicalPathOf(resolve(base, ...f.path.split("/")));
118
119
  if (!target.startsWith(realBase + sep))
119
120
  throw new Error(`artifact stub ledger is corrupt (a file resolves outside its version directory): ${this.#ledgerPath()}`);
120
121
  return { path: f.path, content: new Uint8Array(readFileSync(target)), ...(f.mediaType !== undefined ? { mediaType: f.mediaType } : {}) };
@@ -321,9 +321,18 @@ export interface CompoundReadonlyVerdict {
321
321
  * (`cd -`, bare `cd`, a pattern). The STRUCTURAL form of the unresolvable sentence: `reason` carries the
322
322
  * first one's sentence, this member every one of them, and both survive whatever other sentence (a shape
323
323
  * refusal) takes precedence. CONSUMER CONTRACT: non-empty ⇒ the boundary could not read where the command
324
- * reads — a fail-closed ask (plain: nothing was declared), never a vouch.
324
+ * reads — a fail-closed ask, never a vouch (plain on the classify seat, whose classifier asks beside it; MANDATED on the
325
+ * boundary-only seat, where nothing else would ask).
325
326
  */
326
327
  unresolvedOperands?: readonly string[];
328
+ /**
329
+ * The grammar gate refused the WHOLE command before the walk ran (an escape, a substitution, a subshell, a
330
+ * redirection, a line break — see {@link rejectedSansRedirection} and the compound gate): no operand was judged,
331
+ * and what the shell would run is not knowable from the text (a reader can be spelled `ca\t`, fed by `<`, or hidden
332
+ * in `$(…)`). CONSUMER CONTRACT: present ⇒ the boundary judged nothing — MANDATED on both probe seats (a name-reading
333
+ * arm or a stored allow rule must not retire a read nobody judged). Structure, never the sentence.
334
+ */
335
+ refusedWhole?: true;
327
336
  /**
328
337
  * Operands of a RECURSIVE/EXPANDING read form (`grep -r`, `ls -R`, `du`, … — see
329
338
  * {@link RECURSIVE_READ_FORMS}) judged with a {@link BashReadonlyRootBoundary.denyMatch} seat wired.
@@ -380,6 +389,13 @@ export declare function formatOutOfRootReadApprovalOption(directory: string): st
380
389
  * has no filesystem and stays lexical, and a REMOTE env keeps the lexical behaviour (its
381
390
  * `canonicalPath` is an RPC per candidate). */
382
391
  export declare function resolveOperandLexically(base: string | undefined, operand: string, homeDir: string | undefined): string | undefined;
392
+ /** Whether a command the grammar gate REFUSED WHOLE carries evidence that it may READ: a listed reader or a shell
393
+ * re-entry program anywhere in it, a substitution where a program name stands (`$C /etc/passwd`), or a stdin
394
+ * redirection from a non-literal target (`cat < $F`). The out-of-root and base-mover readings over its literal path
395
+ * tokens are the containment gate's, asked separately. A refused command with none of these — a write through a
396
+ * redirection (`echo hi > out.txt`), a function definition — names no read for the boundary to mandate: the syntax
397
+ * refusal stays the classify seat's own plain ask. */
398
+ export declare function refusedCommandMayRead(command: string, allow: ReadonlySet<string>): boolean;
383
399
  /**
384
400
  * The home directory's VARIABLE spellings, SUBSTITUTED with the declared value before a read face segments the
385
401
  * command: `$HOME` / `${HOME}` at the START of a word (an empty quote pair before it included), unquoted or
@@ -1,4 +1,4 @@
1
- import { isAbsoluteForFamily, isAbsolutePathForm, isBlockedDevicePath, isShellRootedSpellingUnmapped, joinForFamily, nativeUncSpellingOf, normalizeAbsPathLexically, pathFamilyOf, withinAnyRoot } from "./safety.js";
1
+ import { isAbsoluteForFamily, isAbsolutePathForm, isBlockedDevicePath, isShellRootedSpellingUnmapped, joinForFamily, nativeUncSpellingOf, normalizeAbsPathLexically, win32NamespaceScreen, pathFamilyOf, withinAnyRoot } from "./safety.js";
2
2
  export const NOT_AUTO_ALLOWED = "— not auto-allowed";
3
3
  export const BASH_READONLY_DEFAULT_ALLOW = [
4
4
  "ls", "cat", "head", "tail", "wc", "pwd", "echo", "whoami", "uname",
@@ -224,6 +224,8 @@ export function resolveOperandLexically(base, operand, homeDir) {
224
224
  return undefined;
225
225
  }
226
226
  const family = base === undefined ? undefined : pathFamilyOf({ root: base });
227
+ if (!win32NamespaceScreen(raw, family).ok)
228
+ return undefined;
227
229
  if (isAbsoluteForFamily(family, raw))
228
230
  return normalizeAbsPathLexically(nativeUncSpellingOf(family, raw));
229
231
  if (base === undefined || family === undefined || !isAbsoluteForFamily(family, base))
@@ -232,6 +234,78 @@ export function resolveOperandLexically(base, operand, homeDir) {
232
234
  return undefined;
233
235
  return normalizeAbsPathLexically(joinForFamily(family, base, raw));
234
236
  }
237
+ function reheadSegment(toks, head, from = 0) {
238
+ return { folded: [head, ...toks.folded.slice(from + 1)], raw: [head, ...toks.raw.slice(from + 1)] };
239
+ }
240
+ const SHELL_CONTROL_PREFIXES = new Set(["!", "if", "then", "elif", "else", "while", "until", "do", "time"]);
241
+ const SHELL_BLOCK_WORDS = new Set(["for", "select", "case", "esac", "fi", "done", "function", "coproc", "[[", "]]", "in"]);
242
+ function isBareControlWord(toks, i, words) {
243
+ const word = toks.folded[i];
244
+ return word !== undefined && toks.raw[i] === word && words.has(word);
245
+ }
246
+ function tokensMoveBase(toks, allow) {
247
+ let from = 0;
248
+ while (from < toks.folded.length && isBareControlWord(toks, from, SHELL_CONTROL_PREFIXES))
249
+ from++;
250
+ const head = toks.folded[from];
251
+ if (head === undefined)
252
+ return false;
253
+ if (toks.raw[from] !== head)
254
+ return true;
255
+ if (head === "cd")
256
+ return true;
257
+ if (!COMMAND_LAUNCHERS.has(head))
258
+ return false;
259
+ const unwrapped = unwrapLauncher(toks.folded.slice(from), allow);
260
+ return unwrapped.kind === "refused" || (unwrapped.kind === "reader" && unwrapped.head === "cd");
261
+ }
262
+ const SHELL_REENTRY_PROGRAM_RE = /^(?:(?:ba|z|da|k|fi)?sh|eval|source|\.)$/;
263
+ export function refusedCommandMayRead(command, allow) {
264
+ for (const m of command.matchAll(/(?<![<>&\d])<(?![<&])\s*(\S*)/g)) {
265
+ const target = (m[1] ?? "").replace(/^["']+|["']+$/g, "");
266
+ if (target === "" || /^[$`(~]/.test(target))
267
+ return true;
268
+ }
269
+ const fold = (w) => w.replace(/\\(.)/g, "$1").replace(/["']/g, "");
270
+ const programOf = (w) => {
271
+ const inner = fold(w).replace(/^[$`(]+|[)`]+$/g, "");
272
+ return inner.includes("/") ? inner.slice(inner.lastIndexOf("/") + 1) : inner;
273
+ };
274
+ for (const word of command.split(/[\s<>|;&()]+/)) {
275
+ const base = programOf(word);
276
+ if (base === "")
277
+ continue;
278
+ if ((allow.has(base) && !NO_PATH_OPERAND_COMMANDS.has(base)) || SHELL_REENTRY_PROGRAM_RE.test(base))
279
+ return true;
280
+ }
281
+ for (const segment of command.split(/\n|;|&&|\|\||\||(?<![>&\d])&(?![>&])/)) {
282
+ for (const raw of segment.trim().split(/\s+/)) {
283
+ const word = fold(raw);
284
+ if (word === "")
285
+ continue;
286
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(word) || SHELL_CONTROL_PREFIXES.has(word) || SHELL_BLOCK_WORDS.has(word) || word === "{" || word === "}" || COMMAND_LAUNCHERS.has(programOf(word)) || word.startsWith("-"))
287
+ continue;
288
+ if (/^[$`(]/.test(word))
289
+ return true;
290
+ break;
291
+ }
292
+ }
293
+ return false;
294
+ }
295
+ function unwrapLauncher(folded, allow) {
296
+ for (let i = 1; i < folded.length; i++) {
297
+ const t = folded[i];
298
+ if (ASSIGNMENT_WORD.test(t) || /^\d+[smhd]?$/.test(t) || COMMAND_LAUNCHERS.has(t))
299
+ continue;
300
+ if (t.startsWith("-"))
301
+ return { kind: "refused", reason: `"${folded[0]}" is given an option before the program it runs — which program runs is not readable without the launcher's option table ${NOT_AUTO_ALLOWED}` };
302
+ const head = t.includes("/") ? t.slice(t.lastIndexOf("/") + 1) : t;
303
+ if (SHELL_CONTROL_PREFIXES.has(head) || SHELL_BLOCK_WORDS.has(head))
304
+ return { kind: "refused", reason: `"${folded[0]}" is given shell control syntax ("${head}") where the program it runs would be ${NOT_AUTO_ALLOWED}` };
305
+ return allow.has(head) || head === "cd" ? { kind: "reader", index: i, head } : { kind: "unlisted" };
306
+ }
307
+ return { kind: "unlisted" };
308
+ }
235
309
  function tokenizeSegment(segment) {
236
310
  const raw = splitWordsQuoteAware(segment);
237
311
  return { folded: raw.map((r) => { const f = foldQuoteRemovalToken(r); return tildeIsLiteral(r) ? literalTildeSpelling(f) : f; }), raw };
@@ -803,12 +877,51 @@ function segmentCompoundForReadonly(command, homeDir) {
803
877
  export function classifyCompoundReadonlyDetailed(command, allow, boundary, opts) {
804
878
  const split = segmentCompoundForReadonly(command, boundary?.homeDir);
805
879
  if ("reject" in split)
806
- return { reason: split.reject };
880
+ return { reason: split.reject, refusedWhole: true };
807
881
  const { segments, pipeFed } = split;
808
- for (const segment of segments) {
809
- const reason = coarseReadonlyCheck(segment, allow, { quotedOperatorsAreText: true });
810
- if (reason !== undefined)
811
- return { reason };
882
+ let nameRefusal;
883
+ const unlistedSegments = new Set();
884
+ const reheaded = new Map();
885
+ for (let si = 0; si < segments.length; si++) {
886
+ const toks = tokenizeSegment(segments[si]);
887
+ const folded = toks.folded;
888
+ let from = 0;
889
+ while (from < folded.length && isBareControlWord(toks, from, SHELL_CONTROL_PREFIXES))
890
+ from++;
891
+ const written = folded[from];
892
+ if (written === undefined)
893
+ return { reason: "empty command" };
894
+ if (isBareControlWord(toks, from, SHELL_BLOCK_WORDS)) {
895
+ unlistedSegments.add(si);
896
+ nameRefusal ??= `command "${written}" is not in the read-only allowlist`;
897
+ continue;
898
+ }
899
+ const parsed = parseLeadingCommandName(toks.raw.slice(from).join(" "), { quotedOperatorsAreText: true, pathPrefixedNameIsText: true });
900
+ if ("reject" in parsed)
901
+ return { reason: parsed.reject, refusedWhole: true };
902
+ const pathPrefixed = parsed.name.includes("/");
903
+ const name = pathPrefixed ? parsed.name.slice(parsed.name.lastIndexOf("/") + 1) : parsed.name;
904
+ if (pathPrefixed)
905
+ nameRefusal ??= "the command must be a bare name resolved via PATH (no path prefix)";
906
+ if (allow.has(name)) {
907
+ if (pathPrefixed || from > 0)
908
+ reheaded.set(si, { from, head: name });
909
+ if (from > 0)
910
+ nameRefusal ??= `command "${folded[0]}" is not in the read-only allowlist`;
911
+ continue;
912
+ }
913
+ nameRefusal ??= `command "${parsed.name}" is not in the read-only allowlist`;
914
+ if (COMMAND_LAUNCHERS.has(name)) {
915
+ const unwrapped = unwrapLauncher(folded.slice(from), allow);
916
+ if (unwrapped.kind === "refused")
917
+ return { reason: unwrapped.reason, refusedWhole: true };
918
+ if (unwrapped.kind === "unlisted")
919
+ unlistedSegments.add(si);
920
+ else
921
+ reheaded.set(si, { from: from + unwrapped.index, head: unwrapped.head });
922
+ continue;
923
+ }
924
+ unlistedSegments.add(si);
812
925
  }
813
926
  const HEAD_AUTO_ALLOW_MAX = 1_000_000;
814
927
  const headBoundIsSmall = (name, toks) => {
@@ -840,9 +953,12 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary, opts)
840
953
  };
841
954
  const STDIN_FILE_FLOOR = { cat: 1, grep: 2, head: 1, tail: 1, wc: 1, cut: 1, tr: Infinity, diff: 2, cmp: 2, comm: 2, sed: 2 };
842
955
  const foldedSegments = [];
843
- let shapeRefusal;
956
+ let shapeRefusal = nameRefusal;
844
957
  for (let si = 0; si < segments.length; si++) {
845
- const segmentTokens = tokenizeSegment(segments[si]);
958
+ if (unlistedSegments.has(si))
959
+ continue;
960
+ const rehead = reheaded.get(si);
961
+ const segmentTokens = rehead === undefined ? tokenizeSegment(segments[si]) : reheadSegment(tokenizeSegment(segments[si]), rehead.head, rehead.from);
846
962
  const toks = segmentTokens.folded;
847
963
  if (toks.length === 0)
848
964
  continue;
@@ -1187,10 +1303,7 @@ export function classifyBoundedReadonlyPollLoopDetailed(command, allow, boundary
1187
1303
  if (readSegments.length === 0) {
1188
1304
  return { reason: "the loop body has no read command — a sleep-only loop observes nothing and is not auto-allowed" };
1189
1305
  }
1190
- const bodyHasCd = readSegments.some((seg) => {
1191
- const p = parseLeadingCommandName(seg);
1192
- return "name" in p && p.name === "cd";
1193
- });
1306
+ const bodyHasCd = readSegments.some((seg) => tokensMoveBase(tokenizeSegment(seg), allow));
1194
1307
  const modelled = bodyHasCd ? Array.from({ length: beats }, () => readSegments.join("; ")).join("; ") : readSegments.join("; ");
1195
1308
  const verdict = classifyCompoundReadonlyDetailed(modelled, allow, boundary, { iterated: bodyHasCd });
1196
1309
  if (verdict.reason !== undefined)