@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.
- package/CHANGELOG.md +36 -0
- package/dist/core/memory-engine/engine.js +2 -1
- package/dist/core/memory-engine/layout.d.ts +18 -6
- package/dist/core/memory-engine/layout.js +40 -21
- package/dist/core/physical-path.d.ts +37 -0
- package/dist/core/physical-path.js +30 -0
- package/dist/core/runner/contracts.d.ts +1 -1
- package/dist/core/runner/prepare-artifact.d.ts +4 -5
- package/dist/core/runner/prepare-artifact.js +2 -16
- package/dist/core/runner/prepare-policy-chain.js +2 -2
- package/dist/core/runner/prepare-question-face.js +2 -1
- package/dist/core/runner/prepare-task.js +2 -2
- package/dist/core/runner/run-harness-handlers.js +4 -1
- package/dist/core/sensitive-path-policy.js +7 -8
- package/dist/core/skills-directory.js +4 -3
- package/dist/core/spec-contract.js +5 -4
- package/dist/core/task-registry-shared.d.ts +5 -1
- package/dist/core/task-registry-shared.js +1 -0
- package/dist/core/tool-catalog-entries.js +1 -1
- package/dist/core/tool-policy.d.ts +15 -0
- package/dist/core/tool-policy.js +3 -0
- package/dist/engine/execution-env/node-execution-env.js +4 -3
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/orchestration/workflow-script-store.js +9 -25
- package/dist/orchestration/workflow.js +6 -3
- package/dist/stores/cc/task-list-store.js +2 -10
- package/dist/stores/file/fs-atomic.d.ts +15 -18
- package/dist/stores/file/fs-atomic.js +4 -14
- package/dist/stores/file/mailbox-store.d.ts +7 -11
- package/dist/stores/file/mailbox-store.js +4 -11
- package/dist/tools/artifact/local-stub.js +4 -3
- package/dist/tools/fs/bash-readonly-classifier.d.ts +17 -1
- package/dist/tools/fs/bash-readonly-classifier.js +125 -12
- package/dist/tools/fs/fs-bash.js +42 -17
- package/package.json +1 -1
- package/test/export-surface.snapshot.json +5 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,41 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 7.17.1 — 2026-09-13
|
|
4
|
+
|
|
5
|
+
### Narrowing — the shell read boundary never vouches a read it did not judge (#737; test [7074] G12 / G14; @server @cli @test)
|
|
6
|
+
- **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.
|
|
7
|
+
- **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).
|
|
8
|
+
- **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.
|
|
9
|
+
- **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.
|
|
10
|
+
- **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.
|
|
11
|
+
- **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`.
|
|
12
|
+
- **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.
|
|
13
|
+
- 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).
|
|
14
|
+
|
|
15
|
+
### Fix — a core-minted tool declares its content-origin class on every mounting seat (#747; cli B-103; @server @cli)
|
|
16
|
+
- **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.
|
|
17
|
+
- **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).
|
|
18
|
+
- 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`).
|
|
19
|
+
|
|
20
|
+
### P3 batch — mechanical (#719/#722/#724/#733/#734/#720②/#725; no BREAKING)
|
|
21
|
+
- **#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`.
|
|
22
|
+
- **#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`.
|
|
23
|
+
- **#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.
|
|
24
|
+
- **#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`.
|
|
25
|
+
- **#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`.
|
|
26
|
+
- **#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`.
|
|
27
|
+
- **#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.
|
|
28
|
+
|
|
29
|
+
### Contract — ONE physical-path canonicalizer: every face answers one spelling per directory (#700; exports +2, additive; @server @cli @test)
|
|
30
|
+
- **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).
|
|
31
|
+
- **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.
|
|
32
|
+
- **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).
|
|
33
|
+
- **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).
|
|
34
|
+
- **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.
|
|
35
|
+
- **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`.
|
|
36
|
+
- **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.
|
|
37
|
+
- 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`.
|
|
38
|
+
|
|
3
39
|
## 7.17.0 — 2026-09-12
|
|
4
40
|
|
|
5
41
|
### Abstraction review of the merged tree (the 7.17.0 triple review's first leg; @server @cli @test)
|
|
@@ -4444,7 +4444,8 @@ export class MemoryEngine {
|
|
|
4444
4444
|
let wrote = false;
|
|
4445
4445
|
try {
|
|
4446
4446
|
mkdirSync(dirname(indexPath), { recursive: true });
|
|
4447
|
-
|
|
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
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
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
|
-
/**
|
|
252
|
-
|
|
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,
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|
|
@@ -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
|
-
*
|
|
8
|
-
* (`capabilities.storageDir`) must lie OUTSIDE every root the run may write or read —
|
|
9
|
-
*
|
|
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 {
|
|
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 =
|
|
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`);
|
|
@@ -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 && (
|
|
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 && (
|
|
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);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { realpathSync } from "node:fs";
|
|
2
1
|
import { resolve as resolveFsPath } from "node:path";
|
|
3
2
|
import { AgentHarness, DEFAULT_CLAMP_TOLERANCE, DEFAULT_COMPACTION_SETTINGS, summaryOutputBudgetTokens } from "../../internal/harness.js";
|
|
4
3
|
import { sanitizeCompactionSettings } from "../auto-compaction.js";
|
|
4
|
+
import { physicalPathOf } from "../physical-path.js";
|
|
5
5
|
import { projectStaleToolResults, resolveStaleToolResultOffload } from "./compaction-call-options.js";
|
|
6
6
|
import { uuidv7 } from "../../engine/session/uuid.js";
|
|
7
7
|
import { brainToRuntime } from "../runtime.js";
|
|
@@ -346,7 +346,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
|
|
|
346
346
|
else {
|
|
347
347
|
const canonical = (p, what) => {
|
|
348
348
|
try {
|
|
349
|
-
return
|
|
349
|
+
return physicalPathOf(resolveFsPath(p));
|
|
350
350
|
}
|
|
351
351
|
catch (err) {
|
|
352
352
|
return failIsolation(`cannot canonicalize ${what} (${err instanceof Error ? err.message : String(err)}) — an unverifiable path is treated as not isolated`);
|
|
@@ -287,7 +287,10 @@ export function createHarnessHandlers(input) {
|
|
|
287
287
|
prepared.workCutRef.abortedBeforeTurnEnd = true;
|
|
288
288
|
const stopExtra = rs.turn.turnStopReason !== undefined ? { stopReason: rs.turn.turnStopReason } : {};
|
|
289
289
|
const usage = rs.turn.turnUsage ?? { inputTokens: 0, totalInputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, costMicroUsd: 0 };
|
|
290
|
-
|
|
290
|
+
const turnUsageUnknown = rs.turn.turnUsage === undefined || rs.turn.turnUsageMissing;
|
|
291
|
+
if (turnUsageUnknown)
|
|
292
|
+
stats.usageMissing = true;
|
|
293
|
+
queue.push({ type: "turn_end", usage, ...(turnUsageUnknown ? { usageMissing: true } : {}), ...stopExtra, ...ident() });
|
|
291
294
|
rs.turn.turnUsageMissing = false;
|
|
292
295
|
rs.turn.turnStopReason = undefined;
|
|
293
296
|
if (parentToolCallId !== undefined) {
|
|
@@ -104,14 +104,13 @@ export function createSensitivePathPolicy(opts) {
|
|
|
104
104
|
return { action: "allow" };
|
|
105
105
|
const canon = await canonicalizeTarget(opts.env, path, signal, pathFamilyOf({ root: opts.rootPath, cwd: req.cwd ?? opts.env.cwd }), pathTargetBaseOf(protective, { root: opts.rootPath, cwd: req.cwd }));
|
|
106
106
|
if (!canon.ok) {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
}
|
|
114
|
-
return { action: "allow" };
|
|
107
|
+
return {
|
|
108
|
+
action: "deny",
|
|
109
|
+
message: canon.unresolvedSymlink
|
|
110
|
+
? `write to "${path}" is blocked: it is a symlink whose real target could not be resolved (it could point onto a guarded sensitive path)`
|
|
111
|
+
: `write to "${path}" is blocked: its target could not be resolved on this tree (${canon.refused ?? canon.message}), so the sensitive-path guard cannot judge it`,
|
|
112
|
+
decisionReason: "safety",
|
|
113
|
+
};
|
|
115
114
|
}
|
|
116
115
|
let relative = null;
|
|
117
116
|
for (const rootKey of await dataRootKeys(signal)) {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { readFileSync, readdirSync,
|
|
1
|
+
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
2
2
|
import { isAbsolute, join, relative } from "node:path";
|
|
3
|
+
import { physicalPathOf } from "./physical-path.js";
|
|
3
4
|
import { isSkillSpecifierEnforced, parseSkillToolEntry } from "./skill-tool-specifier.js";
|
|
4
5
|
const SKILL_FILE = "SKILL.md";
|
|
5
6
|
const RESOURCE_DIRS = ["assets", "references", "scripts"];
|
|
@@ -200,7 +201,7 @@ function resolveInsideSkill(abs, rel, walk) {
|
|
|
200
201
|
let real;
|
|
201
202
|
let stats;
|
|
202
203
|
try {
|
|
203
|
-
real =
|
|
204
|
+
real = physicalPathOf(abs);
|
|
204
205
|
stats = statSync(abs);
|
|
205
206
|
}
|
|
206
207
|
catch (err) {
|
|
@@ -220,7 +221,7 @@ function resolveInsideSkill(abs, rel, walk) {
|
|
|
220
221
|
function readAttachments(skillDir, skillName, budgetBytes, warn) {
|
|
221
222
|
let realRoot;
|
|
222
223
|
try {
|
|
223
|
-
realRoot =
|
|
224
|
+
realRoot = physicalPathOf(skillDir);
|
|
224
225
|
}
|
|
225
226
|
catch (err) {
|
|
226
227
|
warn({ code: "read_failed", skill: skillName, detail: `the skill directory could not be resolved (${errText(err)}) — attachments skipped` });
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
|
-
import { lstatSync, readFileSync,
|
|
3
|
+
import { lstatSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { dirname, isAbsolute, resolve, sep } from "node:path";
|
|
5
|
+
import { physicalPathOf } from "./physical-path.js";
|
|
5
6
|
const DEFAULT_GATE_TIMEOUT_MS = 60_000;
|
|
6
7
|
const GATE_MAX_BUFFER = 16 * 1024 * 1024;
|
|
7
8
|
const OUTPUT_TAIL_CHARS = 1500;
|
|
@@ -139,8 +140,8 @@ export async function runOracle(gates, cwdRoot, opts) {
|
|
|
139
140
|
continue;
|
|
140
141
|
}
|
|
141
142
|
try {
|
|
142
|
-
const physRoot =
|
|
143
|
-
const phys =
|
|
143
|
+
const physRoot = physicalPathOf(root);
|
|
144
|
+
const phys = physicalPathOf(candidate);
|
|
144
145
|
if (phys !== physRoot && !phys.startsWith(physRoot + sep)) {
|
|
145
146
|
results.push(err(gate, `gate cwd physically escapes the spec root (symlink): ${gate.cwd}`));
|
|
146
147
|
continue;
|
|
@@ -247,7 +248,7 @@ function restoreOneAtomic(p, bytes) {
|
|
|
247
248
|
}
|
|
248
249
|
function safeRealpath(p) {
|
|
249
250
|
try {
|
|
250
|
-
return
|
|
251
|
+
return physicalPathOf(p);
|
|
251
252
|
}
|
|
252
253
|
catch {
|
|
253
254
|
return undefined;
|
|
@@ -40,7 +40,11 @@ export interface UnifiedTaskOutput {
|
|
|
40
40
|
code?: string;
|
|
41
41
|
/** RB-386② — machine-readable failure code on a FAILED background_agent row's poll
|
|
42
42
|
* details (the child's TaskResult.errorCode taxonomy — brain codes / `limit.*` / `budget.*` …).
|
|
43
|
-
* Additive; absent on non-failed rows, on rows whose failure carried no code, and on other lanes.
|
|
43
|
+
* Additive; absent on non-failed rows, on rows whose failure carried no code, and on other lanes.
|
|
44
|
+
* The same key also carries a failed WORKFLOW run's `WorkflowRun.errorCode` (
|
|
45
|
+
* `workflow.*` for the orchestration's own refusals / a failed child's terminal code), populated
|
|
46
|
+
* by `formatWorkflowRun`: one field name for "why this poll answer is a failure", whichever lane
|
|
47
|
+
* minted the failure. */
|
|
44
48
|
errorCode?: string;
|
|
45
49
|
/** RB-386② — retryability verdict for a FAILED background_agent row, derived at settle time by the
|
|
46
50
|
* same classifier the sync sub-agent report's `error_kind (retryable: …)` line uses. Additive. */
|
|
@@ -259,6 +259,7 @@ export function formatWorkflowRun(run) {
|
|
|
259
259
|
status: run.status,
|
|
260
260
|
retrieval_status: "success",
|
|
261
261
|
...(run.completionId !== undefined ? { completionId: run.completionId } : {}),
|
|
262
|
+
...(run.errorCode !== undefined ? { errorCode: run.errorCode } : {}),
|
|
262
263
|
details: summary,
|
|
263
264
|
},
|
|
264
265
|
};
|
|
@@ -165,7 +165,7 @@ export function toolFace(id) {
|
|
|
165
165
|
const e = byId.get(id);
|
|
166
166
|
if (e === undefined)
|
|
167
167
|
throw new Error(`tool catalog: no definition ${JSON.stringify(id)}`);
|
|
168
|
-
return structuredClone(e.face);
|
|
168
|
+
return { contentOrigin: "local", ...structuredClone(e.face) };
|
|
169
169
|
}
|
|
170
170
|
function deepFreeze(value) {
|
|
171
171
|
if (value !== null && typeof value === "object" && !Object.isFrozen(value)) {
|
|
@@ -1419,6 +1419,21 @@ export declare function durableMandateSourceOf(args: {
|
|
|
1419
1419
|
durableApprovalDeclared: boolean;
|
|
1420
1420
|
onAsk: OnAsk | undefined;
|
|
1421
1421
|
}): DurableMandateSource | undefined;
|
|
1422
|
+
/**
|
|
1423
|
+
* Has this leg DECLARED a durable park opt-in, from EITHER source (the per-principal
|
|
1424
|
+
* entitlement or the task's own `durableApproval`) — with NO live-seat narrowing. Deliberately
|
|
1425
|
+
* NOT {@link durableMandateSourceOf}: that one asks "is every ask MANDATED to gate durably" (a
|
|
1426
|
+
* live approver seat beside a mere `durableApproval` opt-in defeats the mandate), while this one
|
|
1427
|
+
* asks "does this leg have a durable park FACILITY at all" — the question-face mount decision and
|
|
1428
|
+
* the inherited-mandate float both need the bare declaration, seat or no seat, and had each spelled
|
|
1429
|
+
* `forceDurableGate === true || durableApprovalDeclared` by hand (two call sites, `prepare-
|
|
1430
|
+
* question-face.ts` and `prepare-policy-chain.ts`, one drifting from the other silently if either
|
|
1431
|
+
* grows a third source or a typo'd operator).
|
|
1432
|
+
*/
|
|
1433
|
+
export declare function durableParkOptInDeclared(args: {
|
|
1434
|
+
forceDurableGate: boolean;
|
|
1435
|
+
durableApprovalDeclared: boolean;
|
|
1436
|
+
}): boolean;
|
|
1422
1437
|
/** EXACT structural clone for the approval boundary (alias isolation) — fail-closed, never lossy,
|
|
1423
1438
|
* never identity: a JSON round-trip would silently DROP unclonable members (presented ≠ executing =
|
|
1424
1439
|
* consent misrepresentation) and an identity fallback would hand the approver the live executing
|
package/dist/core/tool-policy.js
CHANGED
|
@@ -914,6 +914,9 @@ export function durableMandateSourceOf(args) {
|
|
|
914
914
|
return "durable_approval";
|
|
915
915
|
return undefined;
|
|
916
916
|
}
|
|
917
|
+
export function durableParkOptInDeclared(args) {
|
|
918
|
+
return args.forceDurableGate || args.durableApprovalDeclared;
|
|
919
|
+
}
|
|
917
920
|
export function tryCloneArgs(v) {
|
|
918
921
|
try {
|
|
919
922
|
const value = structuredClone(v);
|