@sema-agent/core 7.17.0 → 7.17.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 (43) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/dist/core/fs-write-gate-policy.js +4 -0
  3. package/dist/core/memory-engine/engine.js +2 -1
  4. package/dist/core/memory-engine/layout.d.ts +18 -6
  5. package/dist/core/memory-engine/layout.js +40 -21
  6. package/dist/core/physical-path.d.ts +37 -0
  7. package/dist/core/physical-path.js +30 -0
  8. package/dist/core/runner/contracts.d.ts +3 -1
  9. package/dist/core/runner/prepare-artifact.d.ts +4 -5
  10. package/dist/core/runner/prepare-artifact.js +2 -16
  11. package/dist/core/runner/prepare-ask-lane.d.ts +3 -0
  12. package/dist/core/runner/prepare-ask-lane.js +3 -3
  13. package/dist/core/runner/prepare-park-ask.d.ts +3 -0
  14. package/dist/core/runner/prepare-park-ask.js +3 -2
  15. package/dist/core/runner/prepare-policy-chain.js +2 -2
  16. package/dist/core/runner/prepare-question-face.js +2 -1
  17. package/dist/core/runner/prepare-task.js +5 -5
  18. package/dist/core/runner/run-harness-handlers.js +4 -1
  19. package/dist/core/runner/run-leg.js +4 -1
  20. package/dist/core/sensitive-path-policy.js +7 -8
  21. package/dist/core/skills-directory.js +4 -3
  22. package/dist/core/spec-contract.js +5 -4
  23. package/dist/core/task-registry-shared.d.ts +5 -1
  24. package/dist/core/task-registry-shared.js +1 -0
  25. package/dist/core/tool-catalog-entries.js +1 -1
  26. package/dist/core/tool-policy.d.ts +56 -0
  27. package/dist/core/tool-policy.js +6 -0
  28. package/dist/engine/execution-env/node-execution-env.js +4 -3
  29. package/dist/index.d.ts +1 -0
  30. package/dist/index.js +1 -0
  31. package/dist/orchestration/workflow-script-store.js +9 -25
  32. package/dist/orchestration/workflow.js +6 -3
  33. package/dist/stores/cc/task-list-store.js +2 -10
  34. package/dist/stores/file/fs-atomic.d.ts +15 -18
  35. package/dist/stores/file/fs-atomic.js +4 -14
  36. package/dist/stores/file/mailbox-store.d.ts +7 -11
  37. package/dist/stores/file/mailbox-store.js +4 -11
  38. package/dist/tools/artifact/local-stub.js +4 -3
  39. package/dist/tools/fs/bash-readonly-classifier.d.ts +19 -1
  40. package/dist/tools/fs/bash-readonly-classifier.js +413 -12
  41. package/dist/tools/fs/fs-bash.js +42 -17
  42. package/package.json +1 -1
  43. package/test/export-surface.snapshot.json +5 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,56 @@
1
1
  # Changelog
2
2
 
3
+ ## 7.17.2 — 2026-09-13
4
+
5
+ ### Fix — the directories the prompt advertises as writable feed the write gate's exemption through the request (#749; cli B-102; server S-261; @server @cli)
6
+ - **Before.** Two seats judged one fact. The root fence's advertised-writable-directory seat (#691) admits the memory engine's write home — that admission is exactly what licenses the `# Memory` instruction's "write to it directly with the Write tool". The deployment's write gate (`createFsWriteGatePolicy`) knew nothing about it: the seat is minted inside prepare and its class is not exported, so under the manual-mode default (`defaultWrite: "ask"`, i.e. the `default` / `auto` / `acceptEdits` tiers) every write the prompt had just invited raised an approval card — an entry plus its index, two cards for one remembered fact. The only workaround open to a deployment was to copy the memory home into `exemptDirs`, making it a SECOND author of a list the fence already owns.
7
+ - **After (one rule).** The seat is the single source and it travels on the call. `ToolCallRequest.advertisedWritableRoots` (optional, additive, read-only) carries the seat's admitted CANONICAL roots — the memory write home, the deployment's scratchpad — stamped by the Runner beside the live-cwd stamp at every adjudication seat and read live off the seat's own getter. `createFsWriteGatePolicy` checks it ahead of `exemptDirs` / `acceptDirs`: same verdict (allow, never asked), same containment comparison, same fold. The advertised roots are compared **frozen** — the seat admits only canonical spellings and `resolveKey`'s root fence compares its admitted roots exactly as they stand, so this gate must too. Re-resolving them per call would follow whatever the name points at AT ADJUDICATION TIME: replacing an admitted memory home with a symlink to the workspace (no race needed — any moment between two calls) would move the exemption domain onto the whole workspace and turn every ordinary source write from `ask` into `allow`. The CONFIGURED domains keep their per-call resolution, because they are deployment-authored text (a relative spelling, a path that did not exist at wiring time) rather than admitted keys. Nothing else changes — a sensitive-path `deny` composed alongside still outranks it, an unresolvable target still fails closed to `ask`, and an ABSENT or EMPTY list is NO exemption (never a wildcard). `FsWriteGatePolicyOptions` gains no option: the seat belongs to the request, not to the construction.
8
+ - **The stamp is a frozen copy.** One helper mints the field at every station (`advertisedWritableRootsSeat`, beside the field's own declaration — the `callFaceSeat` shape), and it hands out `Object.freeze([...seat.roots])`. The seat's getter returns its own admission ledger and `combinePolicies` walks every layer with ONE request object, so a layer handed the live array could append to the very domain the gate exempts — for that call and every later one. `readonly` is a compile-time word; the freeze is the runtime one. (A layer REPLACING the whole field on the shared request is the pre-existing shape `cwd`, `budget` and `face` already live with; that belongs to the fold, not to this seat.)
9
+ - **Stamp seats (five).** `prepare-ask-lane.ts` (the live gate), `prepare-park-ask.ts` (the parked call's projection re-adjudication), and `run-leg.ts` ×3 (the resume belts: the approver-edit recheck, the deny-narrowing lane, the persisted-rule lane). The last two are tightening-only lanes that never read the field; they are stamped so the field's own sentence — the Runner stamps it on every policy adjudication of the run — is true and cannot drift. `Prepared` carries the seat so the resume belts adjudicate against the same list the live gate used.
10
+ - **For consumers.** @server: **zero changes to take this** — a run whose prompt advertises a memory home stops asking about writes into it, on your existing gate wiring. Your `exemptDirs: [scratchpadDir]` stays legal and correct; it is now redundant for any directory the seat already admits, and `isExempt` is untouched (it is still consulted after the domain checks). Do NOT add the memory home to `exemptDirs` — that is the second-writer shape this closes. @cli: the counter-control for B-102 is that an interactive `default`-tier run that stores a memory entry no longer raises a card for it; a probe that pinned that card reds.
11
+ - Pins: `test/backlog749-advertised-roots-feed-write-gate.test.ts` (the gate unit: inside/outside/absent/empty/symlink-escape/unresolvable, an admitted root REPLACED by a symlink after admission not moving the domain, and the sensitive-path deny still outranking; the Runner end-to-end on a real memory wiring: the memory write lands with zero asks while an ordinary in-root write still asks exactly once, with the stamp positively observed on the adjudicated request; the structural invariant that every Runner line stamping `cwd` also stamps the seat, five stations, all through the one helper; the helper's frozen copy, and a greedy policy layer composed ahead of the gate failing to widen it).
12
+
13
+ ### Fix — a here-document or here-string is a reader's stdin, not a file: the read-evidence scan counts operands (#751; test [7111]; @cli @server)
14
+ - **Before.** The read-evidence scan behind the shell read boundary (the rule that a command the grammar refused whole is mandated only when it may read) counted a listed path reader by NAME: `cat <<'EOF' > out.txt` — the everyday heredoc write — and `wc -l <<< "x"` raised a mandated ask under the default doctrine while `tr … <<< hello` did not (`tr` reads no path).
15
+ - **After (one rule, a whitelist).** A refused command is exempt from the mandate only when every part of it is a shape the scan fully understands and none of them reads a file outside the walk's reach — everything else, including any token the scan does not understand, is read evidence. Exempt shapes, per segment: a program that reads no path (`echo`, `tee out.txt <<EOF`, `python3 - <<'EOF'`, an unlisted program); `cat` / `wc` with a payload-free flag cluster whose input is a here-document, a here-string or a literal in-root stdin file (`cat <<'EOF' > out.txt`, `wc -l <<< "x"`, `cat < secret`); a directory change (after which every relative stdin literal in the command is unjudged). A substitution body (`$(…)`, backticks, `<(…)`) is judged on its own — green under the compound walk (`echo $(cat src/a)`, `VAR=$(cat src/a)`) or exempt under the whitelist (`echo $(date)`) — and a here-document body is text. Mandated: a stdin source outside the root, deny-listed, expanded anywhere in its spelling or relative after a `cd`/`pushd` (`cat < /etc/passwd`, `cat < .${K}rc`, `pushd /etc; cat < hosts`); any listed reader other than `cat`/`wc` fed by a here-string (`sed -e'r/etc/passwd' <<< x`, `grep -f x <<< y`); an option that may carry a file (`cat -`, `sed -fsecret`); a reader inside a substitution that reads outside the root (`echo x$(cat /etc/hosts)`); a re-entry (`bash -c "$CMD"`, `eval`); an unterminated here-document. A shell re-entry program (`sh -c …`, `bash script.sh`, `eval …`, `source x`, `. x`), bare or behind a launcher, runs its argument as commands the walk never sees: refused whole and mandated on both seats (the 7.17.0–7.17.1 walk read it as an unlisted program and the boundary seat vouched it). `refusedCommandMayRead(command, allow, boundary)` is the complement of `refusedCommandExempt`. **Residual, disclosed (#753):** the exemption is a hand-written parser beside the compound walk's lexer; five adversarial rounds closed twenty-one corners (heredoc delimiters and bodies, quoting, substitutions, launchers, moved bases, option payloads); the class is open by construction until the whitelist runs over the compound lexer itself.
16
+ - Pins: `test/backlog696-read-boundary-any-doctrine.test.ts` (eight non-objection heredoc / here-string / in-root stdin forms; six mandated stdin forms).
17
+
18
+ ## 7.17.1 — 2026-09-13
19
+
20
+ ### Narrowing — the shell read boundary never vouches a read it did not judge (#737; test [7074] G12 / G14; @server @cli @test)
21
+ - **Before.** Under the default read doctrine (`shellGate` absent / `"off"`) the boundary seat (`bashReadBoundaryProbe`) VOUCHED every command the read faces refused whole for their grammar — an escape (`cat /etc/pass\wd`), a substitution (`echo $(cat ~/.ssh/id_rsa)`, `cat "$(…)"`), a variable operand (`cat $F`), a redirection (`cat ~/.ssh/id_rsa > /tmp/x`), a UNC spelling on a POSIX tree (`cat \\srv\share\x`): the grammar gate stopped the operand walk before any read was judged, and "refused whole" had been read as "not this seat's question". Under that doctrine no other seat asks, so those reads RAN with zero asks — a bypass of the read boundary #696 installed. An operand the walk could not RESOLVE (a `~` operand with no declared home — an `inheritEnv` allowlist that withholds HOME; a `cd` it cannot follow) was a PLAIN fail-closed verdict on both seats: under the default doctrine that is no ask at all, and under `classify` the read-only name arm cleared it (`cat` is a listed reader) — `cat ~/.ssh/id_rsa` with HOME withheld read the key under both doctrines.
22
+ - **After (one rule, both seats).** A read the boundary did not JUDGE is MANDATED — never retired by a name-reading arm or a stored allow rule. `readBoundaryMandate` (shared by the classify seat and the boundary-only seat) mandates on the boundary's declared objections as before (deny row / recursive walk / out of root) AND on `boundaryUnjudged`: an operand it could not resolve (`CompoundReadonlyVerdict.unresolvedOperands`) or a command the grammar gate refused whole (new structural field `CompoundReadonlyVerdict.refusedWhole` — an escape, a substitution, a subshell, a redirection, a line break; what the shell would run is not knowable from the text, since a reader can be spelled `ca\t`, fed by `<` or hidden in `$(…)`, so no word scan is attempted). The boundary-only seat vouches only a command whose reads it walked and found inside; a per-segment SHAPE refusal (a write, an unlisted program, follow mode) had its operands walked and stays not this seat's question; the bounded poll-loop face is consulted the same way. Only an expansion the walk parsed but cannot enumerate (a glob) stays the ruled plain ask. The lexical operand resolver runs `win32NamespaceScreen` first: a namespace spelling the tree refuses is unresolvable, never joined under the base as a relative name (the screen the Read face already ran).
23
+ - **Behaviour, for consumers (both doctrines).** A shell command whose text carries `$`, `` ` ``, `\`, `(`, a redirection or a line break now asks ONCE, mandated (`AskRequest.ruleOffersAbsence: "mandated"` with an armed rule lane) — including `echo $X` and `git commit -m "a\nb"`, which ran unasked before: the boundary cannot tell a harmless spelling from `cat</home/u/.ssh/id_rsa`, and the reference harness asks on every such spelling too (its read-only table refuses them all). A `~`-relative read with no single home asks once, mandated; `cat '$HOME/x'` (a literal, single-quoted `$`) asks too. Commands with no such character are unchanged. @cli: one more mandated-ask source on the default tier; a probe that pinned zero asks for a `$`/escape-carrying command reds. @server: the default lane (`shellGate` absent) is the boundary seat. @test: G12 (UNC on Bash) and G14 (HOME withheld) re-pinned as one mandated ask each on both doctrines; the bytes never flow. Finer grammar (letting a provably harmless `$VAR` spelling through without the mandate) is a follow-up, #738.
24
+ - **Same rule, two more spellings (adversarial rounds).** An UNLISTED segment beside a listed reader (`cat ~/.ssh/id_rsa; :`) used to end the compound walk at the name check — the reader's operands were never judged and the boundary seat vouched the command; the listed segments are now walked regardless (their findings ride beside the unlisted-name sentence; the unlisted segment itself is not a reader and is not walked). A leading name the parser cannot read (`ca''t`, `cat` to the shell) is refused whole. The bounded poll-loop face gets its turn BEFORE any mandate on the boundary seat: `for i in $(seq 1 2); do cat x; sleep 1; done` is refused by the compound gate (`$(`) but is the loop face's own grammar — an in-root body is vouched as before, a home-relative read with no home is mandated. A boundary-free seat mints no boundary mandate (`echo hi > out.txt` on a seat with no roots stays the plain classifier ask). A launcher, a path-prefixed name or a pipe into an unlisted program (`env cat ~/.ssh/id_rsa`, `/bin/cat /etc/passwd`, `cat /etc/passwd | base64`, `FOO=1 cat /etc/passwd`) hid the reader from the boundary seat, which vouched: both faces refused for shape and the seat asked nothing more. It now asks the containment question over the command text alone — the classify seat's own arm (`classifyOutOfRootReadGate`, launcher- and prefix-aware) read through the shared `readBoundaryMandate` — so the two seats answer alike; a path-prefixed listed reader (`/bin/cat src/a.ts`) is walked by its basename (the boundary judges what is read, never which binary). That gate judges a launcher segment whole and fail-closed (its documented over-fire: `env rm /tmp/cache`, `env echo /etc/passwd` name an out-of-root path and gate), so under the default doctrine those now ask once too, as does a working-directory move the walk cannot model (`pushd . && echo hello` — later operands are unplaced, the gate's base-mover arm). Across the two read faces a MANDATED finding on either outranks a plain one on the other (`for i in 1 2; do cat .ssh/id_rsa; cat *.log; done`: the loop face's deny row, not the compound face's glob). A launcher segment (`command cat .ssh/id_rsa`, `sudo cat …`) is walked WHOLE against the full boundary — deny judge and home included — not only the containment-only text gate (which had let a deny-row read and a no-home `~` read through); a path-prefixed reader carries its basename into the walk's tokens so `/usr/bin/grep -r . .` keeps `grep -r`'s recursive-read reading; the containment question is asked before any plain verdict, so a glob elsewhere in the command (`cat *.log; command cat /etc/passwd`) cannot outrank an out-of-root read. A launcher is UNWRAPPED to the program it runs (past `NAME=value` words, a bare number, nested launchers) so that program's own verb reading survives — `command grep -r x .` is `grep -r`, `command cd .claude; cat settings.json` moves the base; an option where the program would stand (`sudo -u root cat …`, `env -u X cat …`) is refused whole (its meaning needs the launcher's option table). Shell control words that precede a command (`! cat …`, `if …; then cat …; fi`, `while …; do cat …`) are transparent: the command behind them is judged; bare block words (`for …`, `fi`, `done`) name no reader. A control word counts only when bash sees it BARE — `'if' cd sub; cat ../secret` runs a program named `if`, the directory never changes and the read lands on the root's parent, so the quoted spelling is a command name the parser refuses whole; and a cd behind a control word or a launcher inside a loop body (`for i in 1 2; do ! cd ..; cat secret; done`) still moves the base per iteration — the loop face models every beat, where a single pass saw only the first. A command the grammar refused WHOLE is mandated only when it MAY READ — a listed path reader, a shell re-entry (`bash -c "$CMD"`), a substitution where the program name stands (`$C /etc/passwd`), a stdin redirection from a non-literal source (`cat < $F`), or a literal path token outside the roots — while a refused command with no read in it (`echo hi > out.txt`, a function definition, a heredoc into an interpreter) is the boundary's non-objection and the classify seat's own plain ask; a control word in front of a listed reader (`time cat x`) is walked but never auto-allowed on the classify seat.
25
+ - **Not changed (ruled).** Globs and recursive walks without a deny judge stay plain asks on both seats (no filesystem expansion); the `Read` face (already refused the UNC spelling by name); the classifier's own sentences.
26
+ - **Same rule, the write guard (server [7083] ②).** `createSensitivePathPolicy` answered `allow` for a target its canonicalizer REFUSED (a UNC spelling on a POSIX tree — `\\host\share\.env` — was `deny` under 7.16.0's lexical join and became `allow` once #653 refused the spelling by name; only the unresolvable-symlink arm denied). One arm now: a target the guard could not resolve is a target it did not judge ⇒ `deny`, the message naming the cause (`unc_on_posix_tree`, or the symlink sentence). @server: the sandbox lane's `sensitivePatterns` cell for the UNC spelling reads `deny` again. Pin: `test/sensitive-path-policy.test.ts`.
27
+ - **Seat type (server [7083] ①).** `RunInternals.workflowParkedResume[].outcome` is OPTIONAL, as the tool face `WorkflowParkedResume.outcome` already was — `{ runId, token }` alone is the acknowledgement that settles a not-pending token (#652 obligation ③); the type had lagged the contract, so a host could not reach the acknowledgement form without a cast.
28
+ - Pins: `test/backlog696-read-boundary-any-doctrine.test.ts` (the UNREAD halves; refused-whole legs incl. `cat</etc/passwd` and `ca\t /etc/passwd` mandated on both seats; shape refusals still vouched on the boundary seat; the loop face's variable operand), `test/backlog714-home-expansion-precise.test.ts` (Runner real load path: HOME withheld ⇒ one mandated ask and the key never read; the literal `'$HOME/…'` spelling; the unresolved-operand legs mandated on both seats).
29
+
30
+ ### Fix — a core-minted tool declares its content-origin class on every mounting seat (#747; cli B-103; @server @cli)
31
+ - **Before.** A core definition mounted through a host's own `tools` seat (`TaskCreate` and its siblings, `LSP`) carried no declared content-origin class, so the memory session's classifier took the fail-closed branch reserved for a tool nobody described (`isCallerTool` ⇒ `external`): one `TaskCreate` call marked the session's memory polluted (`memory.session_polluted`), every entry it wrote carried an external-origin marker under the default `carry` provenance, and instruction-form files were withheld.
32
+ - **After (one rule).** The catalog face is the one source of a definition's class: `toolFace(id)` carries `contentOrigin` — the catalog's declared class (`execution` for Bash, `external` for the web tools) or `local` — so every core factory product says what it is, whichever seat mounts it. A host-authored tool that declares nothing is still classed `external` fail-closed (unchanged; negative control pinned).
33
+ - Pins: `test/backlog747-builtin-content-origin-declared.test.ts` (every catalog face carries a class; the task-list products read `local` on the caller lane; the undeclared host tool stays `external`).
34
+
35
+ ### P3 batch — mechanical (#719/#722/#724/#733/#734/#720②/#725; no BREAKING)
36
+ - **#719** Repeated `MemoryEngine.materialize` over an unchanged store no longer rewrites MEMORY.md, `index-revs.json` or `scopes.json` (mtime churn only, byte-identical content): `writeIfChanged` (content-gated `atomicWriteFileSync`, `src/core/memory-engine/layout.ts`) backs `writeIndexRevs` and `commitScopesRecord`'s journal/file dance, and the MEMORY.md write-back re-reads the live file (no-follow) before writing. Lock/heartbeat files are unchanged (still call `atomicWriteFileSync` directly). Pin: `test/backlog719-derived-write-idempotence.test.ts`.
37
+ - **#722** `TaskResult.stats.usageMissing` now follows the exact law #711 minted for the `turn_end` wire frame: a turn with NO usage frame at all (`onTurnEnd`, `run-harness-handlers.ts`) sets `stats.usageMissing = true`, not only the brain-flagged branch (a message that HAS a `usage` object carrying its own `usageMissing`, #692 C-b) — closing the gap where a fully-missing turn's counted zeros read as a free turn at the task-result face while the same turn's wire frame already said unknown. Pin: `test/backlog722-stats-usage-missing.test.ts`.
38
+ - **#724** (additive) `formatWorkflowRun`'s poll result now populates `details.errorCode` from `WorkflowRun.errorCode` (#688 C4) for a failed workflow run — the same key `serveDurableAgentRow` already populates for a failed `background_agent` row. Before, the run-level machine code reached only the `content` JSON text, so a consumer reading `TaskOutput.details.errorCode` (client-core / cli's workflow panel run row, cli L-235) got nothing for a workflow run. Pin: `test/backlog724-workflow-error-code-structural.test.ts`. Wire row: `docs/wire-consumption/unreleased-724.md` @cli @server.
39
+ - **#733** `durableParkOptInDeclared` (`src/core/tool-policy.ts`, beside the sibling `durableMandateSourceOf` it is deliberately not a live-seat-narrowed test) folds the two hand-written `spec.durableApproval !== undefined || runtimeCaps?.forceDurableGate === true` copies in `prepare-question-face.ts`'s `durableQuestionFace` and `prepare-policy-chain.ts`'s `mandatedCallFloats` onto one predicate — pure refactor, no behavior change (each conjunct these two conditions AND against, the question-park-store presence and the checkpoint-store presence, is untouched). Pin: `test/backlog733-durable-park-opt-in-declared.test.ts`.
40
+ - **#734** `parkLaneArmed` (`src/orchestration/workflow.ts`) folds three separately-spelled "store present and checkpointStore is a real store object" tests (the `checkpointStore`-without-`store` entry guard, the `defaultDurableApproval`-without-both entry guard, and `assertChildParkSeat`'s "does this run have any park seat at all") onto one predicate — pure refactor, byte-identical behavior (the #642/#652 park-lane suites pass unchanged). Pin: `test/backlog734-park-lane-armed.test.ts`.
41
+ - **#720②** (tooling) `scripts/wire-changes.mjs`'s `closedSetMembers[]` entries carry an optional `persistedBy?: "durable-column" | "checkpoint-state" | "none"` hint, mined from the `docs/wire-consumption/*.md` corpus rather than hand-kept: present only when the entry has a removed half, absent (not `"none"`) when the corpus never mentions the set or a removed member. `removed`'s own shape (`string[]`) is unchanged. New `wireConsumptionCorpus()` export reads the current working tree's docs; `buildReport` stays pure (the corpus is a new optional parameter, default `""`). Pins: `test/wire-changes-gate.test.ts`.
42
+ - **#725 余项** (test-only) `test/gate-negative-controls.test.ts` closes its remaining two P3 items: a citation for `flaky-guard.test.ts`'s existing G8 negative controls in the "already covered" register (it had coverage but no citation), and a new full-tree `gate:tool-names` `verify(root)` negative control (clones the real `src/` + `docs/TOOL-NAME-TABLES.json`, confirms zero problems, then plants one unregistered retired-name table site and confirms it reds naming the file) — closing the registration-reconciliation half the existing `scanFile`-only pin did not reach.
43
+
44
+ ### Contract — ONE physical-path canonicalizer: every face answers one spelling per directory (#700; exports +2, additive; @server @cli @test)
45
+ - **Before.** Two canonicalizers stood over one question. The memory layout's `canonicalize` used node's JS `fs.realpathSync`, which resolves symlinks but PRESERVES the caller's casing; `ExecutionEnv.canonicalPath` used the libuv realpath, which answers with the directory entry's ON-DISK spelling. Twelve `realpathSync` stations across nine modules spelled the call three different ways (bare, `.native`, and the fs-promises form). On a case-insensitive volume a memory root configured as `…/Proj` was therefore keyed `…/Proj` by the engine's write gate and resolved to `…/proj` by the fence — the advertised-writable-directory seat saw the mismatch and refused the hole its own `# Memory` instruction taught (#691's loud `expectCanonical` skip was that divergence's compensation, not its fix).
46
+ - **After.** `src/core/physical-path.ts` is the tree's one mint, three doors and zero options: `physicalPathOf` (sync, libuv realpath, throws on an unresolvable path), `physicalPathOfAsync` (the same libuv call, non-blocking — the `ExecutionEnv` faces take this one so a hung network mount cannot block the event loop and every abort with it), and `physicalPathOfExisting` (total: the deepest ancestor that resolves, with the missing tail rejoined, for a path that need not exist yet). Every station routes through it — spec-contract (oracle cwd + frozen-path parents), skills-directory (attachment containment), prepare-task (the isolation claim), prepare-artifact (the artifact store's root check), memory-engine/layout (`canonicalize` is now a NAME for the mint, one implementation behind both), fs-atomic (`resolveDataRoot`, `canonicalStoreKey`), the mailbox store, the cc task-list store's lock target, the workflow script store's physical containment arm, the artifact local stub's snapshot read, and `NodeExecutionEnv.canonicalPath`. A case-variant configured directory now resolves to the SAME string on the engine's key mints and on the env face, so the seat ADMITS it: one directory, one spelling, one key.
47
+ - **Case FOLDING is not part of a path's identity** and stays where it always belonged — at the one call site that keys on it (`canonicalStoreKey`, realpath + fold). Folding a filesystem PATH names a different directory on a case-SENSITIVE volume, which is why the cc task-list store's lock target must not use the store key (RB-173's lesson, now stated once).
48
+ - **Loud where it was silent.** The mailbox store's and the cc task-list store's private `realpathSyncSafe` (realpath with a silent lexical fallback) are gone: both canonicalize a directory they just created, so an unresolvable path is a real fault (it vanished, or an ancestor became untraversable) and now THROWS — a lexical fallback would key one physical directory two ways, which is the defect the canonicalization exists to close. The swallow arms that stay are the ones with a contract, unchanged: spec-contract's `undefined` (the frozen entry is skipped and the oracle judges), skills-directory's warn-and-skip, and the store key's lexical form of last resort (now expressed once, inside the mint).
49
+ - **Exports (+2, additive, tier `advanced`)**: `physicalPathOf`, `physicalPathOfExisting`. @server: a host that keys its own store or gate on a directory (`boot/stores.ts`) should key it through these rather than its own realpath call — otherwise one directory can still get two keys ACROSS the process boundary, which is the same defect one repo up. No wire key, no payload shape, no notice code changes.
50
+ - **One directory spelled two ways is not a migration.** The memory library's one-shot key migration (`migrateKeyedPartition`, both adoption directions) tested its two coordinates for identity with a STRING compare. That was sound while both mints preserved the configured casing; with the partition key now answering in the on-disk case, a case-variant configured root makes the two coordinates spell ONE directory on a case-insensitive volume — and the lexical guard read that as two, disclosing `memory.partition_split` on EVERY mount with advice to move the rows out of what is in fact the live partition and remove it (constructed and measured, then fixed in the same car). Identity is physical: the guard now canonicalizes both sides through the mint. **Upgrade (merged-tree review).** `adoptLegacyRepoDirs` also probes the spelling the layout keyed by BEFORE the one mint — link-resolved, caller-cased (`/var/TMP` keyed `-private-var-TMP`; today `-private-var-tmp`) — so a partition minted by 7.17.0 under a case-variant alias on a mixed-case-sensitivity setup is adopted onto today's key instead of silently stranded; a populated competitor is the disclosed `memory.partition_split`.
51
+ - **Upgrade, registered rather than fixed.** The partition key this module minted before this change was the same realpath with the CALLER's casing — which, absent a symlink, is exactly the spelling `deriveDirKey` mints, so the existing adoption arm moves such a partition onto the new key as it always did. The one shape it does not cover: a symlinked ancestor whose stored target text differs in CASE from the on-disk spelling AND a memory library on a volume that does not fold case — three coordinates instead of two, and the old partition stays where it is (untouched, never merged, never deleted) while the mount starts a fresh one. Noted in `deriveRepoKey`'s own doc. On a folding library volume (the default on the platforms where the case axis exists at all) the old and new keys name ONE directory, so nothing is stranded.
52
+ - Pins: `test/backlog700-physical-path-one-canonicalizer.test.ts` (8 cells: the on-disk spelling; layout mint = env face = async door; the seat admitting a case-variant spelling while its mismatch arm still refuses; the deepest-existing-ancestor form under a symlinked parent; folding at the store key and never in the mint; the false-split trap above; on a case-SENSITIVE volume the case cells assert spelling identity rather than skipping), a `physical-path` row in `gate:single-mint` (a realpath call outside the mint is red), `docs/LAYERING.json` (the mint on the floor), and a new parent-arm cell in `test/spec-contract.test.ts`.
53
+
3
54
  ## 7.17.0 — 2026-09-12
4
55
 
5
56
  ### Abstraction review of the merged tree (the 7.17.0 triple review's first leg; @server @cli @test)
@@ -31,6 +31,10 @@ export function createFsWriteGatePolicy(opts) {
31
31
  if (!canon.ok) {
32
32
  return ask(`write to "${path}" requires approval: its real target could not be resolved (${canon.message})${advisory}`);
33
33
  }
34
+ for (const root of req.advertisedWritableRoots ?? []) {
35
+ if (isWithin(root, canon.key))
36
+ return { action: "allow" };
37
+ }
34
38
  for (const dirs of [exemptDirs, acceptDirs]) {
35
39
  if (!dirs)
36
40
  continue;
@@ -4444,7 +4444,8 @@ export class MemoryEngine {
4444
4444
  let wrote = false;
4445
4445
  try {
4446
4446
  mkdirSync(dirname(indexPath), { recursive: true });
4447
- writeFileNoFollow(indexPath, text);
4447
+ if (readNoFollowSafe(indexPath) !== text)
4448
+ writeFileNoFollow(indexPath, text);
4448
4449
  wrote = true;
4449
4450
  }
4450
4451
  catch {
@@ -1,5 +1,6 @@
1
1
  import { type MemoryAnnouncement } from "./types.js";
2
2
  import type { AssertAllKeysHandled } from "../ask-origin.js";
3
+ import { physicalPathOfExisting } from "../physical-path.js";
3
4
  /** Cursor sidecar for the design/84 Seam B pair on the FileBackend (`{ [scope]: cursor }`). B3: control plane. */
4
5
  export declare const CURSORS_FILE = "cursors.json";
5
6
  /** The control-plane subdir name under a key dir — ONE spelling for every derivation and for the
@@ -32,10 +33,11 @@ export declare class ControlPlaneCorruptError extends Error {
32
33
  * Its input is the HOST-declared repo root; a model-reachable directory is keyed by
33
34
  * {@link deriveDirKey} instead, and that asymmetry is deliberate (see there).
34
35
  *
35
- * Known limit, registered rather than fixed (ruled 2026-08-03): on a case-INSENSITIVE volume two
36
- * spellings that differ only in case still mint two keys realpath does not case-fold, and folding
37
- * here would MERGE two genuinely distinct repos on a case-SENSITIVE volume. This round closes the
38
- * symlink axis only; the case axis predates it and is unchanged.
36
+ * The case axis (once a known limit here) closes with the same call: the mint answers with the ON-DISK
37
+ * spelling, so two case-variant spellings name ONE key on a case-INSENSITIVE volume, while on a
38
+ * case-SENSITIVE volume they are two distinct directories and keep two (FOLDING would merge that pair).
39
+ * Registered, not fixed: the PREVIOUS key was this realpath with the CALLER's casing = `deriveDirKey`'s
40
+ * spelling, which the arm below adopts, EXCEPT under a case-varying symlink on a non-folding library.
39
41
  */
40
42
  export declare function deriveRepoKey(repoRoot: string): string;
41
43
  /** Sink for the mount-time partition-key incidents (`memory.partition_split` /
@@ -48,6 +50,9 @@ export type MemoryPartitionIncidentSink = (err: Error & {
48
50
  * when the two mints differ its `memory/` + `.engine/` + siblings live under {@link deriveDirKey}'s
49
51
  * dir. Move the whole partition onto the canonical key (see {@link migrateKeyedPartition}). */
50
52
  export declare function adoptLegacyRepoDirs(configRoot: string, repoRoot: string, onIncident?: MemoryPartitionIncidentSink): void;
53
+ /** Every spelling an earlier release keyed this repo's partition by, minus today's: the resolve-only key and the
54
+ * link-resolved, caller-cased key (`/var/TMP` keyed `-private-var-TMP`; today `-private-var-tmp`). */
55
+ export declare function earlierRepoKeysOf(repoRoot: string): string[];
51
56
  /**
52
57
  * The REVERSE one-shot migration (ruled 2026-08-03): for the width of ONE release the control-plane
53
58
  * key canonicalized like the repo key does, which put a directory-pinned mount's `.engine` under the
@@ -248,8 +253,10 @@ export declare function layoutNeedsAdoption(root: string, controlDir: string): b
248
253
  * 6. commit B: the marker removed; `memory.layout_migrated` disclosed.
249
254
  */
250
255
  export declare function adoptRootScopeLayout(rootPath: string, controlDir: string, opts?: AdoptRootScopeLayoutOptions): void;
251
- /** Canonicalize a path for containment checks (resolves symlinks on the EXISTING prefix). */
252
- export declare function canonicalize(p: string): string;
256
+ /** The memory layout's name for the tree's one physical-path mint (symlinks on the EXISTING prefix
257
+ * resolved, the missing tail rejoined). ONE implementation behind both names — a second canonicalizer
258
+ * here is what let a key minted by this layout name a directory the fence resolved differently. */
259
+ export declare const canonicalize: typeof physicalPathOfExisting;
253
260
  /** True iff `child` (canonicalized) is `root` or inside it. */
254
261
  export declare function isContainedIn(root: string, child: string): boolean;
255
262
  /** mkdir -p convenience used by the engine/backend (0o700 like the file stores). */
@@ -976,4 +983,9 @@ export declare function writeAllSync(fd: number, data: string): void;
976
983
  * or re-derived); do not route data that can't through here. Throws on failure (fail-closed: a
977
984
  * sidecar that cannot be written must not be silently skipped). */
978
985
  export declare function atomicWriteFileSync(path: string, data: string): void;
986
+ /** Content-gated {@link atomicWriteFileSync} for a DERIVED/control-plane file: a byte-identical
987
+ * rewrite (the common case on a repeat mount over an unchanged store) is skipped entirely, so the
988
+ * file's mtime stays put. ONLY for files whose mtime carries no meaning of its own — a lock or a
989
+ * heartbeat file's mtime IS its signal and must keep calling {@link atomicWriteFileSync} directly. */
990
+ export declare function writeIfChanged(path: string, data: string): void;
979
991
  export {};
@@ -1,9 +1,10 @@
1
- import { chmodSync, closeSync, constants as fsConstants, copyFileSync, existsSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, rmdirSync, statSync, unlinkSync, utimesSync, writeFileSync, writeSync } from "node:fs";
1
+ import { chmodSync, closeSync, constants as fsConstants, copyFileSync, existsSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, rmdirSync, statSync, unlinkSync, utimesSync, writeFileSync, writeSync } from "node:fs";
2
2
  const { O_WRONLY, O_CREAT, O_TRUNC, O_NOFOLLOW, O_EXCL } = fsConstants;
3
3
  import { homedir } from "node:os";
4
4
  import { createHash } from "node:crypto";
5
5
  import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
6
6
  import { MEMORY_ORIGIN_CAUSES } from "./types.js";
7
+ import { physicalPathOfExisting, priorCallerCasedPathOf } from "../physical-path.js";
7
8
  const SCOPES_FILE = "scopes.json";
8
9
  export const CURSORS_FILE = "cursors.json";
9
10
  export const CONTROL_PLANE_DIR = ".engine";
@@ -84,7 +85,7 @@ function migrateKeyedPartition(m) {
84
85
  };
85
86
  const splitText = `memory partition split detected for ${m.subject}: BOTH ${m.fromDir} and ${m.toDir} hold content. This session uses ${m.toDir}; the other dir is left untouched (never merged — CAS baselines would collide). If it holds the newer memory, move its rows by hand and remove it.`;
86
87
  try {
87
- if (m.fromDir === m.toDir)
88
+ if (m.fromDir === m.toDir || canonicalize(m.fromDir) === canonicalize(m.toDir))
88
89
  return;
89
90
  if (!existsSync(m.fromDir))
90
91
  return;
@@ -113,12 +114,20 @@ function migrateKeyedPartition(m) {
113
114
  }
114
115
  }
115
116
  export function adoptLegacyRepoDirs(configRoot, repoRoot, onIncident) {
116
- migrateKeyedPartition({
117
- fromDir: join(configRoot, deriveDirKey(repoRoot)),
118
- toDir: join(configRoot, deriveRepoKey(repoRoot)),
119
- subject: repoRoot,
120
- onIncident,
121
- });
117
+ const toDir = join(configRoot, deriveRepoKey(repoRoot));
118
+ for (const key of earlierRepoKeysOf(repoRoot)) {
119
+ migrateKeyedPartition({ fromDir: join(configRoot, key), toDir, subject: repoRoot, onIncident });
120
+ }
121
+ }
122
+ export function earlierRepoKeysOf(repoRoot) {
123
+ const today = deriveRepoKey(repoRoot);
124
+ const keys = [deriveDirKey(repoRoot)];
125
+ try {
126
+ keys.push(slugifyPath(priorCallerCasedPathOf(resolve(repoRoot))));
127
+ }
128
+ catch {
129
+ }
130
+ return [...new Set(keys)].filter((k) => k !== today);
122
131
  }
123
132
  export function adoptCanonicalKeyedControlDir(configRoot, dir, onIncident) {
124
133
  migrateKeyedPartition({
@@ -403,6 +412,15 @@ function commitScopesRecord(controlDir, lock, token, next) {
403
412
  const data = `${JSON.stringify(next, null, 2)}\n`;
404
413
  const fence = () => assertSidecarLockOwnership(lock, token, "scope registry", "strict");
405
414
  fence();
415
+ let current;
416
+ try {
417
+ current = readFileSync(file, "utf8");
418
+ }
419
+ catch {
420
+ current = undefined;
421
+ }
422
+ if (current === data)
423
+ return;
406
424
  atomicWriteFileSync(journal, data);
407
425
  fence();
408
426
  atomicWriteFileSync(file, data);
@@ -701,18 +719,7 @@ function moveLooseContent(root, controlDir, rec, owner, label, renew, sink, resu
701
719
  writeIndexRevs(controlDir, {});
702
720
  discloseLayoutIncident(sink, "memory.layout_migrated", `memory layout adopted at ${root}: ${moved} loose entr${moved === 1 ? "y" : "ies"} moved into the home of ${JSON.stringify(owner)} (${label})${resumed ? " — an interrupted move was finished" : ""}`);
703
721
  }
704
- export function canonicalize(p) {
705
- try {
706
- return realpathSync(p);
707
- }
708
- catch {
709
- const parent = resolve(p, "..");
710
- if (parent === p)
711
- return p;
712
- const rest = p.slice(parent.endsWith(sep) ? parent.length : parent.length + 1);
713
- return join(canonicalize(parent), rest);
714
- }
715
- }
722
+ export const canonicalize = physicalPathOfExisting;
716
723
  export function isContainedIn(root, child) {
717
724
  const r = canonicalize(root);
718
725
  const c = canonicalize(child);
@@ -1034,7 +1041,7 @@ export function readIndexRevs(controlDir) {
1034
1041
  export function writeIndexRevs(controlDir, revs) {
1035
1042
  try {
1036
1043
  ensureDirExists(controlDir);
1037
- atomicWriteFileSync(join(controlDir, INDEX_REVS_FILE), `${JSON.stringify(revs, null, 2)}\n`);
1044
+ writeIfChanged(join(controlDir, INDEX_REVS_FILE), `${JSON.stringify(revs, null, 2)}\n`);
1038
1045
  }
1039
1046
  catch {
1040
1047
  }
@@ -2056,3 +2063,15 @@ export function atomicWriteFileSync(path, data) {
2056
2063
  throw err;
2057
2064
  }
2058
2065
  }
2066
+ export function writeIfChanged(path, data) {
2067
+ let current;
2068
+ try {
2069
+ current = readFileSync(path, "utf8");
2070
+ }
2071
+ catch {
2072
+ current = undefined;
2073
+ }
2074
+ if (current === data)
2075
+ return;
2076
+ atomicWriteFileSync(path, data);
2077
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * THE physical path of a path — one spelling for the whole tree.
3
+ *
4
+ * A physical path is what `realpath(3)` answers: every symlink resolved and, on a case-insensitive
5
+ * volume, the directory entry's ON-DISK spelling. That last half is the reason this module exists.
6
+ * Node has two realpaths and they disagree exactly there (measured on macOS): `realpathSync.native`
7
+ * and `fsPromises.realpath` are the libuv call and return the disk case, while the JS `fs.realpathSync`
8
+ * resolves the links but PRESERVES the caller's casing. A tree that used both minted two spellings for
9
+ * one directory, so a key minted on one face could not be found on the other — a memory write root
10
+ * spelled `…/Proj` by configuration was keyed `…/Proj` by the engine's gates and resolved to `…/proj`
11
+ * by the execution env's fence, and the advertised-writable-directory seat had to refuse the hole its
12
+ * own instruction taught.
13
+ *
14
+ * Case FOLDING is not part of a path's identity and is not done here: folding a filesystem PATH names a
15
+ * different directory on a case-sensitive volume. A store whose in-memory KEY must fold (see
16
+ * `canonicalStoreKey`) folds this answer at its own call site.
17
+ */
18
+ export declare function physicalPathOf(p: string): string;
19
+ /** The spelling the memory layout keyed by BEFORE this mint — links resolved, the caller's casing kept (the JS
20
+ * `fs.realpathSync`, which differs from the native call exactly there). Migration probes only: never an identity. */
21
+ export declare function priorCallerCasedPathOf(p: string): string;
22
+ /** The async door onto the same rule (`fsPromises.realpath` is the libuv call `realpathSync.native`
23
+ * makes). The `ExecutionEnv` faces take this one: a sync realpath against a hung network mount blocks
24
+ * the event loop for the mount timeout, and every pending abort with it. */
25
+ export declare function physicalPathOfAsync(p: string): Promise<string>;
26
+ /**
27
+ * {@link physicalPathOf} for a path that need not exist yet: the deepest ancestor that resolves,
28
+ * canonicalized, with the missing tail rejoined — so a not-yet-created target is still judged against
29
+ * roots that are physical paths, and a symlinked PARENT still cannot carry a write past them (the
30
+ * parent is exactly what gets resolved before the tail is rejoined).
31
+ *
32
+ * Total, by construction rather than by swallowing: a path with no resolvable ancestor at all has no
33
+ * physical form to answer with, and the absolute lexical form is that answer. Callers that must
34
+ * distinguish "unresolvable" from "does not exist yet" call {@link physicalPathOf} and handle the throw
35
+ * beside their own contract.
36
+ */
37
+ export declare function physicalPathOfExisting(p: string): string;
@@ -0,0 +1,30 @@
1
+ import { realpathSync } from "node:fs";
2
+ import { realpath } from "node:fs/promises";
3
+ import { basename, dirname, join, resolve } from "node:path";
4
+ export function physicalPathOf(p) {
5
+ return realpathSync.native(p);
6
+ }
7
+ export function priorCallerCasedPathOf(p) {
8
+ return realpathSync(p);
9
+ }
10
+ export async function physicalPathOfAsync(p) {
11
+ return await realpath(p);
12
+ }
13
+ export function physicalPathOfExisting(p) {
14
+ const abs = resolve(p);
15
+ const missingTail = [];
16
+ let cur = abs;
17
+ for (;;) {
18
+ try {
19
+ const real = physicalPathOf(cur);
20
+ return missingTail.length === 0 ? real : join(real, ...[...missingTail].reverse());
21
+ }
22
+ catch {
23
+ const parent = dirname(cur);
24
+ if (parent === cur)
25
+ return abs;
26
+ missingTail.push(basename(cur));
27
+ cur = parent;
28
+ }
29
+ }
30
+ }
@@ -291,6 +291,8 @@ export interface Prepared {
291
291
  /** The per-task logical cwd ref when a real shell is mounted (else undefined). The Runner
292
292
  * reads `cwdRef.current` after each tool to detect a `cd` move and emit `workspace_changed`. */
293
293
  cwdRef?: CwdRef;
294
+ /** The run's advertised-writable-directory seat (present with hands): `roots` is what the prompt advertised as writable and the fence admitted. Read live wherever a policy is adjudicated off a resumed leg, so the write gate's exemption domain is that same list. */
295
+ advertisedWritableDirs?: Pick<import("./advertised-writable-dirs.js").AdvertisedWritableDirs, "roots">;
294
296
  /** The ACTIVE EnterWorktree session ref (mounted with the worktree tools; undefined without
295
297
  * real write hands). runtask's settle write reads `current` for the workspace-state entry. */
296
298
  worktreeSessionRef?: {
@@ -1517,7 +1519,7 @@ export interface RunInternals {
1517
1519
  workflowParkedResume?: ReadonlyArray<{
1518
1520
  runId: string;
1519
1521
  token: CheckpointToken;
1520
- outcome: ResumeOutcome;
1522
+ outcome?: ResumeOutcome;
1521
1523
  inheritedGate?: InheritedGate;
1522
1524
  }>;
1523
1525
  /**
@@ -3,11 +3,10 @@
3
3
  * nothing and announces nothing; a seat whose declaration fails an invariant mounts nothing and announces
4
4
  * `config.artifact_host_invalid` (operator audience) so a bad wiring can never read as "no hosting"; a
5
5
  * valid seat mounts ONE `Artifact` tool assembled from the declaration, on the same optional band the LSP
6
- * seat uses. The tool's local-file read goes through the run's fs fence (root containment, read-deny,
7
- * read face) exactly as `Read` does, then reads the bytes once from the execution env. A local stub's store
8
- * (`capabilities.storageDir`) must lie OUTSIDE every root the run may write or read — the task root and both
9
- * classes of additional directories because a ledger the model can edit through the fs tools is not a trust
10
- * boundary; a store inside one refuses the mount with the same loud notice, naming the directory and the root.
6
+ * seat uses. The tool's local-file read goes through the run's fs fence (root containment, read-deny, read
7
+ * face) exactly as `Read` does, then reads the bytes once from the execution env. A local stub's store
8
+ * (`capabilities.storageDir`) must lie OUTSIDE every root the run may write or read — a ledger the model can
9
+ * edit through the fs tools is not a trust boundary and a store inside one refuses the mount, naming both.
11
10
  * Synchronous: the stretch has no await (the file read is a closure the tool calls later).
12
11
  */
13
12
  import type { ExecutionEnv } from "../../internal/harness.js";
@@ -4,23 +4,9 @@ import { deliverEngineNotice } from "../engine-notice.js";
4
4
  import { RosterBuilder } from "../tool-roster.js";
5
5
  import { defineTool } from "../tools.js";
6
6
  import { inlineUntrusted } from "../untrusted-text.js";
7
- import { existsSync, realpathSync } from "node:fs";
8
- import { basename, dirname, join, resolve } from "node:path";
7
+ import { physicalPathOfExisting } from "../physical-path.js";
9
8
  import { resolveKey, violationDetails, violationText, withinAnyRoot } from "../../tools/fs/safety.js";
10
9
  import { createArtifactTool } from "../../tools/artifact/artifact-tool.js";
11
- function realAncestorPath(p) {
12
- const abs = resolve(p);
13
- const tail = [];
14
- let cur = abs;
15
- while (!existsSync(cur)) {
16
- const parent = dirname(cur);
17
- if (parent === cur)
18
- return abs;
19
- tail.unshift(basename(cur));
20
- cur = parent;
21
- }
22
- return tail.length === 0 ? realpathSync(cur) : join(realpathSync(cur), ...tail);
23
- }
24
10
  export function prepareArtifact(input) {
25
11
  const { deps, runId, taskId, sessionId, agentName, executionEnv, rootCanonical, additionalRootsCanonical, readDenyMatcher, resolvedReadFace, handsCwdRef, roster, firstPartyOffload, envHandToolNames } = input;
26
12
  const host = deps.artifactHost;
@@ -39,7 +25,7 @@ export function prepareArtifact(input) {
39
25
  return refuse(verdict.problem);
40
26
  const storageDir = verdict.capabilities.storageDir;
41
27
  if (storageDir !== undefined && rootCanonical !== undefined) {
42
- const real = realAncestorPath(storageDir);
28
+ const real = physicalPathOfExisting(storageDir);
43
29
  const inside = [rootCanonical, ...additionalRootsCanonical].find((root) => withinAnyRoot([root], real));
44
30
  if (inside !== undefined)
45
31
  return refuse(`the local artifact store ${storageDir} lies inside a root this run may reach through its file tools (${inside}); put the store outside every task root and additional directory`);
@@ -18,6 +18,7 @@
18
18
  * the orchestrator grows no branch.
19
19
  */
20
20
  import type { AgentTool } from "../../internal/harness.js";
21
+ import type { AdvertisedWritableDirs } from "./advertised-writable-dirs.js";
21
22
  import type { AutoModeDenialTracker } from "../auto-mode.js";
22
23
  import { type Hooks } from "../hooks.js";
23
24
  import { type OnAsk, type ToolCallRequest, type ToolPolicy } from "../tool-policy.js";
@@ -44,6 +45,8 @@ export interface PrepareAskLaneInput {
44
45
  /** borrowed-readonly — the hand's live tracked cwd, or undefined (hands-less). Read per call at RUN time (`current`),
45
46
  * never captured: the fs hand tools resolve their relative paths against this very ref. Not written here. */
46
47
  handsCwdRef: CwdRef | undefined;
48
+ /** borrowed-readonly — the run's advertised-writable-directory seat, or undefined (hands-less / nothing advertised). `roots` is read at RUN time, never captured: it is the fence's own admitted list and the write gate's exemption domain must be that same list. Not written here. */
49
+ advertisedWritableDirs: Pick<AdvertisedWritableDirs, "roots"> | undefined;
47
50
  /** borrowed-readonly — the shared mount roster; read at RUN time by the approval-preview projection (alias-aware
48
51
  * lookup over whatever the roster holds when the ask is minted). Never mutated here. */
49
52
  tools: readonly AgentTool[];
@@ -1,6 +1,6 @@
1
1
  import { primaryActivityArg } from "../arg-summary.js";
2
2
  import { hookSeatExpiredError, runHookSeat } from "../hooks.js";
3
- import { isLiveApproverSeat, resolveAsk } from "../tool-policy.js";
3
+ import { advertisedWritableRootsSeat, isLiveApproverSeat, resolveAsk } from "../tool-policy.js";
4
4
  import { composeCallSignal, raceAbort } from "./abort-race.js";
5
5
  import { gateAskCarry, lateAskSettlementObserver } from "./denial-limit-arms.js";
6
6
  import { consumeInheritedAskGrant } from "./inherited-ask-grants.js";
@@ -55,14 +55,14 @@ function resolveApprovalPreview(tools, toolName, args) {
55
55
  }
56
56
  }
57
57
  export function prepareAskLane(input) {
58
- const { gateMachineryActive, abortController, effectivePolicy, budgetSnapshot, handsCwdRef, tools, inheritedUnavailableAsks, inheritedAskGrants, onAsk, humanReviewRef, now, ruleOffersOf, askSourceIdentity, riskAxesOf, autoModeDenialTracking, spec, deps, sessionId, runId, hooks, hookTimeoutMs, notifyOwnHookCrash } = input;
58
+ const { gateMachineryActive, abortController, effectivePolicy, budgetSnapshot, handsCwdRef, advertisedWritableDirs, tools, inheritedUnavailableAsks, inheritedAskGrants, onAsk, humanReviewRef, now, ruleOffersOf, askSourceIdentity, riskAxesOf, autoModeDenialTracking, spec, deps, sessionId, runId, hooks, hookTimeoutMs, notifyOwnHookCrash } = input;
59
59
  if (!gateMachineryActive)
60
60
  return { askLane: undefined };
61
61
  const composedCallSignal = (callSignal) => composeCallSignal(abortController.signal, callSignal);
62
62
  const adjudicate = effectivePolicy
63
63
  ? (req, callSignal) => {
64
64
  const signal = composedCallSignal(callSignal);
65
- return raceAbort(Promise.resolve(effectivePolicy.check({ ...req, budget: budgetSnapshot, ...(handsCwdRef !== undefined ? { cwd: handsCwdRef.current } : {}) }, signal)), signal, () => ({
65
+ return raceAbort(Promise.resolve(effectivePolicy.check({ ...req, budget: budgetSnapshot, ...(handsCwdRef !== undefined ? { cwd: handsCwdRef.current } : {}), ...advertisedWritableRootsSeat(advertisedWritableDirs) }, signal)), signal, () => ({
66
66
  action: "deny",
67
67
  message: "policy check aborted (task timed out or cancelled)",
68
68
  }));
@@ -27,6 +27,7 @@ import { type OnAsk, type ToolCallRequest, type ToolPolicy } from "../tool-polic
27
27
  import { type ToolResultStore } from "../tool-result-store.js";
28
28
  import type { RunnerDeps, RuntimeCaps, ShellGateDoctrine, TaskSpec } from "../types.js";
29
29
  import type { CwdRef } from "../../tools/fs/fs-shared.js";
30
+ import type { AdvertisedWritableDirs } from "./advertised-writable-dirs.js";
30
31
  import { type ContentAskBinding } from "./content-ask-bindings.js";
31
32
  import type { AskLane, ParkAsk, Prepared, SuspendSaga } from "./contracts.js";
32
33
  import type { createRuleOffersOf } from "./permission-rule-lanes.js";
@@ -92,6 +93,8 @@ export interface PrepareParkAskInput {
92
93
  /** borrowed-readonly — the hand's live tracked cwd, or undefined; `current` is read at park time for the re-adjudication.
93
94
  * Not written here. */
94
95
  handsCwdRef: CwdRef | undefined;
96
+ /** borrowed-readonly — the SAME seat the live gate stamps from ({@link import("./prepare-ask-lane.js").PrepareAskLaneInput.advertisedWritableDirs}): a parked call's projection re-adjudication asks one question about one call, so it must ask it against the same admitted list. Read at park time, not written here. */
97
+ advertisedWritableDirs: Pick<AdvertisedWritableDirs, "roots"> | undefined;
95
98
  /** borrowed-readonly — the run's offload store, or undefined (a volatile store holding results refuses the park). */
96
99
  offloadStore: ToolResultStore | undefined;
97
100
  /** borrowed-readonly — the per-task owned env, or undefined (a non-remote per-task env refuses the park). Never destroyed. */
@@ -7,7 +7,7 @@ import { BINDING_CHECKPOINT_VERSION, buildRiskDescriptor, debitLedger, encodeAtF
7
7
  import { pauseOf } from "../pause-registry.js";
8
8
  import { askCarryRowMembers } from "../hooks.js";
9
9
  import { defaultTaskRegistry } from "../task-registry.js";
10
- import { isLiveApproverSeat, carriesBidiControls, describeThrown, refuseOutOfContractDecision, tryCloneArgs } from "../tool-policy.js";
10
+ import { advertisedWritableRootsSeat, isLiveApproverSeat, carriesBidiControls, describeThrown, refuseOutOfContractDecision, tryCloneArgs } from "../tool-policy.js";
11
11
  import { InMemoryToolResultStore, ScopedToolResultStore, isVolatileOffloadStore } from "../tool-result-store.js";
12
12
  import { raceAbort, raceSettlementAgainstSignal } from "./abort-race.js";
13
13
  import { checkpointScopeOf } from "./checkpoint-scope.js";
@@ -22,7 +22,7 @@ class ParkRefusal extends Error {
22
22
  }
23
23
  }
24
24
  export function prepareParkAsk(input) {
25
- const { askLane, saga, spec, deps, sessionId, checkpointStore, toolRosterDeltas, parkLaneArmed, contentAskRoutable, liveQuestionFace, mountedQuestionTool, contentAskBindings, lateStrandedAnswers, discloseStrandedAnswers, onAsk, runtimeCaps, inheritedUnavailableAsks, basePolicyForResumeEdit, budgetSnapshot, handsCwdRef, offloadStore, ownedEnv, incompleteSuspendAdapter, session, sessions, suspendChainBase, maxSuspends, remoteEnvFailures, shellGatedTools, effectiveShellGate, durableApproval, priorLedger, liveSpendRef, resourceTotal, faceCheckpointState, f012CheckpointState, orgAdmissionCheckpointState, ruleOffersOf, now, humanReviewRef, abortController, harness, pausedRef } = input;
25
+ const { askLane, saga, spec, deps, sessionId, checkpointStore, toolRosterDeltas, parkLaneArmed, contentAskRoutable, liveQuestionFace, mountedQuestionTool, contentAskBindings, lateStrandedAnswers, discloseStrandedAnswers, onAsk, runtimeCaps, inheritedUnavailableAsks, basePolicyForResumeEdit, budgetSnapshot, handsCwdRef, advertisedWritableDirs, offloadStore, ownedEnv, incompleteSuspendAdapter, session, sessions, suspendChainBase, maxSuspends, remoteEnvFailures, shellGatedTools, effectiveShellGate, durableApproval, priorLedger, liveSpendRef, resourceTotal, faceCheckpointState, f012CheckpointState, orgAdmissionCheckpointState, ruleOffersOf, now, humanReviewRef, abortController, harness, pausedRef } = input;
26
26
  if (askLane === undefined || saga === undefined)
27
27
  return { parkAsk: undefined };
28
28
  const { composedCallSignal, approvalPreviewOf } = askLane;
@@ -160,6 +160,7 @@ export function prepareParkAsk(input) {
160
160
  budget: budgetSnapshot,
161
161
  ...callFaceSeat(req.face),
162
162
  ...(handsCwdRef !== undefined ? { cwd: handsCwdRef.current } : {}),
163
+ ...advertisedWritableRootsSeat(advertisedWritableDirs),
163
164
  }, cutSignal)), cutSignal);
164
165
  if (reprojectedRaced.tag === "aborted")
165
166
  return undefined;
@@ -4,7 +4,7 @@ import { resolveCheckpointStore } from "../checkpoint-store.js";
4
4
  import { cloneObserverInput, resolveHookTimeoutMs } from "../hooks.js";
5
5
  import { isNamespacedCoveringRuleName, namespacedRuleNameCovers } from "../permission-rules.js";
6
6
  import { isIsolated } from "../remote-env.js";
7
- import { askApproverIdentity, carryEngineAttestations, carryPrecedingSettlement, checkToolPolicyProjection, combinePolicies, createTranscriptIntegrityPolicy, createUnverifiableDeletePolicy, isLiveApproverSeat, resolveAsk, toolPolicyNameSets, attestAskUnresolvableCause } from "../tool-policy.js";
7
+ import { askApproverIdentity, carryEngineAttestations, carryPrecedingSettlement, checkToolPolicyProjection, combinePolicies, createTranscriptIntegrityPolicy, createUnverifiableDeletePolicy, durableParkOptInDeclared, isLiveApproverSeat, resolveAsk, toolPolicyNameSets, attestAskUnresolvableCause } from "../tool-policy.js";
8
8
  import { RETIRED_TOOL_NAMES } from "../tool-name-aliases.js";
9
9
  import { NAMESPACED_NAME_SHAPES, protocolOf } from "../protocol-table.js";
10
10
  import { emitTrace } from "../trace.js";
@@ -143,7 +143,7 @@ export async function preparePolicyChain(input) {
143
143
  inheritedUnavailableAsks.add(toolCallId);
144
144
  return true;
145
145
  };
146
- const mandatedCallFloats = (toolCallId) => resolveCheckpointStore(spec, deps) !== undefined && (spec.durableApproval !== undefined || runtimeCaps?.forceDurableGate === true) && markInheritedUnavailable(toolCallId);
146
+ const mandatedCallFloats = (toolCallId) => resolveCheckpointStore(spec, deps) !== undefined && durableParkOptInDeclared({ forceDurableGate: runtimeCaps?.forceDurableGate === true, durableApprovalDeclared: spec.durableApproval !== undefined }) && markInheritedUnavailable(toolCallId);
147
147
  const mandateUnreconstructibleDeny = (creq) => attestAskUnresolvableCause({
148
148
  action: "deny",
149
149
  message: `inherited parent policy requires durable approval for "${creq.toolName}" — the parent's durable ask cannot be reconstructed in a delegated child; denied fail-closed (tighten-only)`,
@@ -1,5 +1,6 @@
1
1
  import { createAskUserQuestionTool, isLiveQuestionFace } from "../ask-question.js";
2
2
  import { boundInputHashOf } from "../canonical-json.js";
3
+ import { durableParkOptInDeclared } from "../tool-policy.js";
3
4
  import { CONTENT_ASK_BINDING_CAP } from "./content-ask-bindings.js";
4
5
  import { RosterBuilder } from "../tool-roster.js";
5
6
  export { CONTENT_ASK_BINDING_CAP } from "./content-ask-bindings.js";
@@ -41,7 +42,7 @@ export function prepareQuestionFace(input) {
41
42
  byDelivery.set(r.deliveryId, r);
42
43
  return [...byDelivery.values()];
43
44
  };
44
- const durableQuestionFace = questionParkStoreWired && (spec.durableApproval !== undefined || runtimeCaps?.forceDurableGate === true);
45
+ const durableQuestionFace = questionParkStoreWired && durableParkOptInDeclared({ forceDurableGate: runtimeCaps?.forceDurableGate === true, durableApprovalDeclared: spec.durableApproval !== undefined });
45
46
  const mountedQuestionFace = liveQuestionFace !== undefined
46
47
  ? async (req, signal) => {
47
48
  const bound = contentAskBindings.get(req.toolCallId);