@sema-agent/core 7.31.0 → 7.31.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 CHANGED
@@ -1,5 +1,47 @@
1
1
  # Changelog
2
2
 
3
+ ## 7.31.1 — 2026-09-25
4
+
5
+ ### Fix — a credential slot in the host shell's `SEMA_` namespace is withheld from spawned children (#1099; security; `SecretEnvFindingKind` +1 member `namespace-rule`; +0 exports; @cli @server)
6
+
7
+ - **Before.** The D1 scrub read only a name's LAST word (`…_KEY`, `…_TOKEN`, an optional `_<n>` / `_ID` after it). The shell stores a model entry's key under `SEMA_MODEL_KEY_<ENTRY>` (a websearch provider's under `SEMA_WEBSEARCH_KEY_<PROVIDER>`): the credential word in the middle, a caller-chosen entry after it. Every such slot reached tool shells and language servers, and a model-chosen `env | grep` printed the key into the transcript.
8
+ - **One rule.** Inside the `SEMA_` namespace, and only there, a credential word as any `_`-separated segment makes the name a credential (`SEMA_NAMESPACE_SECRET_RE`). It reads the one credential-word alternation the suffix rule reads (`CREDENTIAL_WORD`, one definition), the count-word reading of `TOKENS` included (`SEMA_MAX_TOKENS` is kept). It is consulted after the two exact-name lists and the suffix rule, so no existing finding changes kind. The alternation no longer lists `ACCESS_KEY` / `SESSION_TOKEN`: every name they matched ends in `_KEY` / `_TOKEN`, which `KEY` / `TOKEN` match.
9
+ - **Unchanged.** Every name outside `SEMA_` (verdict and kind pinned against the v7.31.0 regex over a generated set of about 80,000 names); both exact-name lists; the count-word ledger. The withheld-keys notice (`config.secret_env_scrubbed`, `detail.names`) lists the slot like any other withheld name; values are never carried.
10
+ - **Who is hurt.** A `SEMA_` name with a credential-word segment that a child needed. The ones found in the neighbouring trees are paths to a credential file, not values: `SEMA_REGISTRY_TOKEN_FILE` (server config catalog) and `SEMA_LLM_KEY_DB` (the shell's live-test scripts) no longer reach a spawned tool shell or language server. `SEMA_CODE_*` — the shell's brand-renamed twin of CC's `CLAUDE_CODE_*` namespace — matches the same way: six non-credential knobs the shell's own code reads (`SEMA_CODE_IDLE_TOKEN_THRESHOLD`, `SEMA_CODE_API_KEY_HELPER_TTL_MS`, `SEMA_CODE_ENABLE_TOKEN_USAGE_ATTACHMENT`, `SEMA_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR`, `SEMA_CODE_API_KEY_FILE_DESCRIPTOR`, `SEMA_SESSION_INGRESS_TOKEN_FILE`) are withheld from a nested `sema` started inside the tool shell (a `CLAUDE_CODE_` twin, when present, reseeds it), and one real credential, `SEMA_CODE_CLIENT_KEY_PASSPHRASE`, is newly withheld (@cli: check the six). An `inheritEnv` allowlist, `shellEnv` / a per-exec env, and a host-supplied LSP `env` still pass a name verbatim.
11
+ - **Finding kind.** `SecretEnvFinding.kind` gains `namespace-rule` (confidence `medium`). A consumer that totals over the kind (a metrics label, an exhaustive switch) sees a third value.
12
+ - **Pickup.** @cli: nothing to change; the slots stop reaching children once this core is in the bundle. @server: `secret_env_scrubbed_total{kind}` can carry `namespace-rule`; `SEMA_REGISTRY_TOKEN_FILE` is withheld from the model-driven host shell and from the LSP spawn env (`LSP_ENV_ALLOW` names it back if a language server needs it).
13
+ - Pins: `test/backlog1099-sema-namespace-secret-env.test.ts`.
14
+
15
+ ### Fix — a tilde in an assignment value, a literal tilde and every member of a `:` list meet the deny rows at the declared home (#1100; security; behavior: tightening — no verdict loosens against 7.31.0, flip list below; +0 exports; @cli @server)
16
+
17
+ - **Before.** In `F=~/.ssh/id_rsa; cat "$F"` the station cannot resolve `"$F"`, so the assignment is the only place the path is in the text, and a value that expands a leading tilde was placed only when a program in the SAME segment could open it (7.26.2). The command landed on an approvable card (mandated) while `F="$HOME/.ssh/id_rsa"` and `F=/home/u/.ssh/id_rsa` were refused on the deny row. Two more gaps of the same kind: a LITERAL tilde (quoted, escaped, after an option's `=`) is a relative path to bash, but a program that expands `~` itself (npm reads `npm_config_userconfig="~/x"`, `--userconfig "~/x"`, `--userconfig="~/x"` as `$HOME/x`) opened the home file with no card at all (measured headless on 7.31.0: the sentinel leaked); and a `:` list named no candidate once a member expanded a tilde, so `F=<deny path>:~; cat "${F%%:*}"` was a card where `F=<deny path>:/x` was refused under a directory-prefix deny row (under an exact-file row the literal `:` form was a card too, and moves M → R here).
18
+ - **Three rules, one mechanism (the deny floor of #1064 ⑬).** (1) The opener condition is gone from the value reading (`pushAssignment` loses `opensValue`; `segmentFactsRead` / `unlistedWords` stop computing it): an environment value with a leading tilde is placed against the declared home whether or not a program opens it, and the refusal names the real file. (2) Every candidate spelled `./~…` — the one literal-tilde spelling, from an assignment value, an operand, an option value or payload, a redirection — also carries its declared-home spelling as a deny-only view (`floorSpelled`; `spelledWithHome` reads `./~` as it reads `~`): refused on a deny row, never a containment question, nothing added where no deny row matches. (3) A value holding `:` hands each member as a deny-only candidate (a tilde member placed against the home); the whole value is still no containment candidate. The plan's candidate dedupe keys on the word as written (`raw`) as well as its spelling, so two words spelling one path keep their own deny floors. With no declared home nothing is spelled: a later read stays unestablishable (mandated), as before — unless a deny row's own pattern matches the unexpanded spelling (an any-prefix row such as `**/<dir>/**` matches the relative `~/<dir>/…` itself), which refuses as any matching spelling does (criteria car, measured).
19
+ - **Flip list (station, open / roots × bash_off / bash_classify; deny row anchored at the home).** V → R: `npm_config_userconfig="~/<deny>" npm config list`, `export npm_config_userconfig="~/<deny>"; npm config list`, `env npm_config_userconfig="~/<deny>" npm …`, `npm --userconfig "~/<deny>" …`, `npm … --userconfig="~/<deny>"` (real lane: LEAK → refused), `npm_config_userconfig=\~/<deny> npm …`, `F+="~/<deny>"; true`, `cat "~/<deny>"`, `cat < "~/<deny>"`, `HOME=/x; cat '~/<deny>'`, `A=~/.env; true`, `A=~/'.env' true`, `A=~/.env; cat safe.txt`, `GIT_CONFIG_GLOBAL=~/.env echo hi`, `A=a:~/.env cat safe.txt`. M → R: `F=~/<deny>; cat "$F"`, `F+=~/<deny>; cat "$F"`, `F="~/<deny>"; cat "$F"`, `F+="~/<deny>"; cat "$F"`, `A[0]+="~/<deny>"; cat "${A[0]}"`, `F=<deny>:~; cat "${F%%:*}"`, `F=<deny>:~/x; …`, `F=~/<deny>:; cat "${F%:}"`, `F=/x:~/<deny>; IFS=:; cat $F`. R → R: `npm_config_userconfig+="~/<deny>" npm …` and `env A+="~/<deny>" npm …` (refused on 7.31.0; an intermediate build of this release read them V, restored here), every literal `:` form. No R → M / V and no M → V: the dual-run corpus (4,421 tree-fixture inputs × 4 cells, under its own deny configuration) moves 3 inputs, all V → R, and 19 under four deny configurations, all V → R or M → R (the car's 4,447-input run adds 26 inputs of its own; same 3); the F5 shell-read corpus reads the same on all 276 rows, LEAK 0. Controls unchanged: a benign literal tilde (`F+="~/ok"; true`, `npm_config_userconfig="~/ok" npm …`, `cat "~/ok"`) still vouches; `F+="~/ok"; cat "$F"` still asks.
20
+ - **Unchanged.** An environment value is still no containment candidate (KL-945a). An operand-slot value (`dd if=~/x`) still asks both ways (KL-993b ②). `$(…)` and loop variables are not touched (#1102).
21
+ - **Supersedes part of #1004 (7.26.2).** #1004 ruled that an unsettled environment value in front of no opener names nothing (`A=~/.env; true`, `A=~/'.env' true`, `GIT_CONFIG_GLOBAL=~/.env echo hi` vouched, `A=a:~/.env cat x` vouched). They now refuse, naming the real file. #1004's other half (a refusal names a path, never the assignment word) stands; its pins are updated in `test/backlog1004-assignment-word-not-a-path.test.ts`.
22
+ - **Who is hurt.** A text whose value or literal-tilde operand names a deny row under the declared home and is never read that way is now refused, as its `$HOME` and literal spellings already were. Under the built-in table the new refusals are `:` lists and literal tildes that name a deny row without reading it: `KUBECONFIG=~/.kube/config:~/.kube/dev kubectl get pods`, `HISTFILE=/tmp/h:~/.zsh_history true`, `F=~/.netrc; echo hi` (V → R); `cat "~/.ssh/id_rsa"` was already refused on 7.31.0 under the built-in `.ssh` row and stays so (cat opens the relative `./~/…`; KL-1100a); a deployment's own deny row makes `LABEL=a:<row> true` a refusal (KL-1100b). Not reached (a card, as before): a tilde set through `${X:-~/x}`, `printf -v`, `read <<<`, a brace or a pattern substitution — #1102's family.
23
+ - **Pickup.** @cli @server: nothing to change; the refusal is the existing `read_path_denied` shape.
24
+ - Pins: `test/backlog1100-tilde-assignment-deny.test.ts` (station + real lane, npm included); `test/backlog1004-assignment-word-not-a-path.test.ts` ①③④⑦, `test/backlog714-home-expansion-precise.test.ts` (one more checked path), `test/backlog7300-shell-reentry.test.ts` (`HOME=/x; cat '~/key.txt'` refuses) re-pinned.
25
+
26
+ ### Fix — a memory store act is judged by where it lands: erase, delete, write and journal replay no longer follow a directory replaced by a link out of the store (#1094; security; +1 error code `memory.target_outside_store`; +0 exports; @server)
27
+
28
+ - **Before.** `FileMemoryEngineBackend` joined `<root>/<scope>/<slug>.md` and acted on it: `rmSync` (erasure, `applyPatches` delete, including the bound-path arm when the entry's file had moved) and `renameSync` of a staged write (add, update, slug rename). Nothing re-read where that spelling resolved. A directory under the store replaced by a link to a directory elsewhere kept the spelling while the act followed the link: `eraseMemoryEntries` deleted a same-named file OUTSIDE the store and answered `complete`, a delete did the same, and a write landed the entry outside (staged file and created directories included). Measured on 7.31.0 with real links: erase, bound-path delete, add under the link and slug-rename update all escaped. Journal replay was already refused at construction, by the journal validator's containment check, which is physical.
29
+ - **One verdict, every act.** `storeTargetEscape(physicalRoot, target)` (memory layout, internal): the target's directory, resolved through every link on its existing prefix, must be inside the physically resolved store root. The final component is not judged: `rm` removes a link standing there, `rename` replaces it, and the engine's in-place writes refuse it (`ELOOP`), so what the link points at is never touched (rm and rename complete; the write is refused). The root's physical form is taken once, when the backend opens the store: a store mounted through a link is not refused. Every face (both recovery legs, the lock-free faces' layout seat and the adoption face) first checks that the root still resolves there (the refusal says to open a new backend on the directory you mean) (both sides resolved from the normalized spelling every child path carries); a root re-pointed since refuses the face before it reads, adopts, sweeps or restores anything, and an act judges against the root as opened even when the root is re-pointed mid-call. A directory that cannot be resolved (`EACCES`, `ELOOP`, `ENOTDIR`) stops the act with that fault, thrown as itself: never reported as the containment code, and never taken as leave to act (on macOS a link whose own mode refuses resolution is still followed by `unlink` and `rename`). The verdict covers the target and, for a write, its staged file. It runs over the whole batch at the commit point, before the journal is written (`applyPatches` removes the staged files it created, matched by identity, never a same-named file reached through a directory swapped since; the refusal is clean: no journal, the account unchanged), and per op at each act: `applyPatches` staging (which creates the directory and a file at the target) and execute, erasure execute, and journal replay, where the whole journal is judged before its first act too. After the marker a refusal keeps the journal, and the journal replays once the path resolves inside the store again.
30
+ - **The same verdict at four of the acts the engine performs itself** (the remaining engine write sites — the scan-derived containment writes judged once at scan time by a canonical-spelling comparison, and the old `repo_file` projection's `rmSync` — are #1104; their window is in KL-1094-a). Each of these also landed through a directory replaced by a link; all four were measured on 7.31.0.
31
+ - `MemoryEngine`'s copy-out projection write (a non-file backend projected into the memory dir) projected entries outside the tree.
32
+ - The harvest's missing-file restore rebuilt a file the scan could not see (the scan never follows links, so the file read as "missing") from its committed copy, wherever the link pointed.
33
+ - The read-only layer restore overwrote a same-named file outside with committed content.
34
+ - Four `MEMORY.md` writes (index rebuild, the derived-index gate's clear, the capture opt-out residue sweep, the erasure's index rewrite) wrote through a write home replaced by a link. The fifth, the polluted-index restore, keeps its own containment check (predates #1094, unchanged): it refuses an ordinary directory link, but it compares canonical spellings from a total resolver that falls back to the lexical spelling when a component cannot be resolved (`EACCES`, `ELOOP`), where the verdict above throws the fault as itself — on macOS a directory link with mode 000 (`lchmod`) fails `realpath` yet is followed by `open`, so that site (and the engine's other canonical-spelling gates) still writes through such a link (KL-1094-f; the class fix is #1104).
35
+ The restores reached the file backend's zero-copy mode as well. All now pass the verdict first, judged against the mount's root: the engine takes its memory dir's physical form at its first mount, and a later mount of the same engine whose memory dir now resolves elsewhere refuses with the code ("open a new engine on the directory you mean"). The erasure's index rewrite can run on an engine that never mounted, so it is judged against the memory dir as it resolves when the rewrite runs — also on an engine that HAS mounted, where a root re-pointed since is refused by the next mount but still rewritten by an erasure (KL-1094-i; #1104 takes the pinned root when there is one). The mount's own handle re-resolves its roots after the backend's first call, so a root re-pointed inside that first mount is followed by the handle's writes (same KL). Each refusal is not performed and goes to the site's existing failure lane, carrying the code:
36
+ - a refused projection throws, so the mount fails loud (the runner's memory-less arm reports it through `onError`);
37
+ - a refused restore goes to `could NOT be restored` warnings;
38
+ - a refused index write goes to warnings or the sweep's failure list, except the erasure's index rewrite, whose lane (`residuals.indexUncleared`) lists paths only.
39
+ - **Hard links.** `writeFileNoFollow` is the engine's in-place write for restores, projections and the index. It already refused a link at the final component (ELOOP); it now also refuses a file with more than one name, because an in-place write would change those bytes under the other name too, possibly outside the store. The refusal takes the same lanes and names the file (`<path> has N names (a hard link) — …; not written`; no code). The check reads the opened descriptor before anything is truncated, so there is no window. An ordinary file is rewritten in place exactly as before: same inode, same mode, the new bytes. A file that ALREADY has a second name is refused at every in-place write until the extra name is removed: a `cp -al` snapshot of the store, or an instruction-file restore interrupted between its link and its unlink (`noReplaceRestore` leaves the `.staging` name in custody). For a copy-out projection that refusal fails the mount; the message names the file but not the remedy (KL-1094-g; #1105).
40
+ - **Refusal shape.** A plain `Error` with `code: "memory.target_outside_store"`; the message starts with the code and names the op, the path and where it resolves. `applyPatches` refuses the WHOLE batch (it throws; no per-patch conflict row), so a harvest reports `incident.kind: "sidecar_corrupt"` with that text. `eraseMemoryEntries` throws it with nothing deleted and nothing recorded. The code is registered in `NON_GOVERNANCE_MEMORY_CODES` (CONTROL-PLANE IO: operator attention on the store's tree, never a verdict on the caller).
41
+ - **Unchanged.** A tree with no links behaves byte-identically: the outcome fingerprint of writes, deletes and an erasure over the store and the control plane matches 7.31.0, as does a copy-out projection's; a file deleted inside the tree is still restored byte-for-byte. No API, journal format or ledger change. The journal validator (`journalOpInvalid`) keeps its own containment check and runs first.
42
+ - **Who is affected.** A deployment whose memory store holds a link under its root (placed by a session with shell access to the store, or by hand). An act that used to follow a directory link out of the store is now refused until the link is removed; a link at the final component is removed or replaced as the act requires (it used to be too). A harvest over a file-backend store whose whole directory was replaced by a link during the session used to report one `outside_root` rejection per file. It now reports `ok: false`, `incident.kind: "sidecar_corrupt"`, detail carrying the code; nothing is committed either way. A copy-out (non-file) backend still gets the per-file `outside_root` rejections. Known limits: `KL-1094-a` (check→act window), `KL-1094-b` (a replayed journal whose directory became a link surfaces as the validator's corruption error, not this code), `KL-1094-c` (a directory link that stays inside the store is not refused), `KL-1094-d` (a root re-pointed before a backend opens it is taken as the store; measured on 7.31.0 and here: an add then moves that directory's top-level files into `<dir>/<scope>/`), `KL-1094-e` (a root opened under another case spelling on a case-insensitive volume and then deleted refuses until reopened). `KL-1094-f` (a canonical-spelling gate falls back to the lexical spelling under a resolution fault), `KL-1094-g` (a file with a second name is refused in place until that name is removed), `KL-1094-h` (the mount's root check runs after the memory dir is created, so a re-pointed link gets an empty directory before the refusal).
43
+ - **Pickup.** @server: map `memory.target_outside_store` on every face it reaches (the wire sheet lists each and how it arrives). The NP-4 pre-check stopgap is no longer needed for this form once this core is in the bundle.
44
+
3
45
  ## 7.31.0 — 2026-09-25
4
46
 
5
47
  ### The durable park row carries the mandate twin — `PendingAction.tool_approval.mandated` + `CheckpointSummary.mandated` (#1079; additive; +0 exports; +2 optional type keys; no checkpoint-version bump) — and the facts of the judgment that adjudicated the stored shape (#1088; behavior: tightening only) (@cli @client-core @server)
@@ -83,6 +83,7 @@ export const NON_GOVERNANCE_MEMORY_CODES = new Set([
83
83
  "memory.capture_optout_unpersisted",
84
84
  "memory.capture_optout_unavailable",
85
85
  "memory.capture_optout_sweep_failed",
86
+ "memory.target_outside_store",
86
87
  ]);
87
88
  export function governanceRetryClass(code) {
88
89
  if (Object.prototype.hasOwnProperty.call(GOVERNANCE_CODES, code)) {
@@ -833,6 +833,8 @@ export interface SessionMemoryStatus {
833
833
  export declare class MemoryEngine {
834
834
  private readonly backend;
835
835
  private readonly memoryDir;
836
+ /** {@link memoryDir}'s physical form as this engine first mounted it; every later mount compares, never re-resolves. */
837
+ private mountedRoot;
836
838
  private readonly controlDir;
837
839
  private readonly now;
838
840
  private readonly perScopeBudgetBytes;
@@ -1776,10 +1778,17 @@ export declare class MemoryEngine {
1776
1778
  * @returns the rejection to report, or `undefined` when the index is clean/absent.
1777
1779
  */
1778
1780
  private gateDerivedIndex;
1781
+ /** The file backend's verdict at an act the engine itself performs on a store path ({@link
1782
+ * storeTargetEscape}): throws `memory.target_outside_store` when `path` would land outside `root`;
1783
+ * each caller's existing failure lane reports it. `root` is the mount's canonical root where a
1784
+ * handle exists, so a root re-pointed after the mount is judged against the one it mounted. */
1785
+ private refuseOutsideStore;
1779
1786
  /** Projection write: reads the target to skip a no-op, then writes — the same check-then-use shape
1780
1787
  * as the containment restores, so it takes the same no-follow write. A link at a projection path
1781
1788
  * is never a supported layout (the harvest scan refuses symlinked entry files outright), and this
1782
- * site already reports write failures by throwing, so ELOOP joins the failures it already had. */
1789
+ * site already reports write failures by throwing, so ELOOP joins the failures it already had. A
1790
+ * target whose directory resolves outside `root` (a directory on the path is a link) is refused
1791
+ * the same way, before anything is read or written ({@link storeTargetEscape}). */
1783
1792
  private writeIfChanged;
1784
1793
  /** Persist the canonical projection back to the session file (id minting / frontmatter completion).
1785
1794
  * C-F6 (S2-0): called ONLY after the backend transaction committed — never ahead of the journal
@@ -17,7 +17,7 @@ import { landedUndecided, layerDigest, layerObservationLookup, readOnlyLayerCand
17
17
  import { markOriginClearanceTombstoned, openOriginClearance, readOriginClearances, settleOriginClearance } from "./origin-clearance.js";
18
18
  import { DEFAULT_MAX_ENTRY_DEPTH, MEMORY_INDEX_FILENAME, canonicalJsonStringify, captureErasureInput, erasureRequestInvalid, erasureSelectHash, scanEntryFiles, } from "./file-backend.js";
19
19
  import { assembleMemoryExportBundle, computeMemoryBundleHash, memoryBundleInvalid, } from "./export-bundle.js";
20
- import { QUARANTINE_DIR, SCAN_FUSE_THRESHOLD, quarantineAndTombstone, restoreContainedBaseline, readIndexRevs, writeIndexRevs, bumpScanFuse, scanFuseKey, canonicalize, adoptRootScopeLayout, clearScanFuse, adoptCanonicalKeyedControlDir, deriveControlPlaneDir, drainMemoryAnnouncements, enqueueMemoryAnnouncement, ensureDirExists, markSessionPolluted, readSessionPollution, fileSessionCaptureRecordStore, recordRetrievedAccount, writeFileNoFollow, withPermissionRemedy, permissionRemedyOf, readRetrievedAccount, registerScope, registeredScopes, resolveMemoryEngineRoot, scopeDirFor, appendChallengeEvents, appendLineageAudit, rebuildStrictControlPlaneLedger, isStrictControlPlaneLedgerCorrupt, CHALLENGE_LEDGER_MAX_EVENTS, adjudicateLineagePending, challengedEntryIds, clearLineageForEntries, discardLineagePending, lineageAccountOfEntry, lineageContributionsOfSession, lineageLatchedIds, promoteLineagePending, readChallengeEvents, readChallengedHistory, readLineageRecord, recordChallengedHistory, recordLineageCredential, reconcileLineage, readProjectionDebts, resolveChallengeEvent, settleProjectionDebts, stageLineagePending, stageProjectionDebts, } from "./layout.js";
20
+ import { QUARANTINE_DIR, SCAN_FUSE_THRESHOLD, quarantineAndTombstone, restoreContainedBaseline, readIndexRevs, writeIndexRevs, bumpScanFuse, scanFuseKey, canonicalize, landingPathOf, storeTargetEscape, TARGET_OUTSIDE_STORE, adoptRootScopeLayout, clearScanFuse, adoptCanonicalKeyedControlDir, deriveControlPlaneDir, drainMemoryAnnouncements, enqueueMemoryAnnouncement, ensureDirExists, markSessionPolluted, readSessionPollution, fileSessionCaptureRecordStore, recordRetrievedAccount, writeFileNoFollow, withPermissionRemedy, permissionRemedyOf, readRetrievedAccount, registerScope, registeredScopes, resolveMemoryEngineRoot, scopeDirFor, appendChallengeEvents, appendLineageAudit, rebuildStrictControlPlaneLedger, isStrictControlPlaneLedgerCorrupt, CHALLENGE_LEDGER_MAX_EVENTS, adjudicateLineagePending, challengedEntryIds, clearLineageForEntries, discardLineagePending, lineageAccountOfEntry, lineageContributionsOfSession, lineageLatchedIds, promoteLineagePending, readChallengeEvents, readChallengedHistory, readLineageRecord, recordChallengedHistory, recordLineageCredential, reconcileLineage, readProjectionDebts, resolveChallengeEvent, settleProjectionDebts, stageLineagePending, stageProjectionDebts, } from "./layout.js";
21
21
  import { createSessionIncarnationSeat, recalledForAnyIdentity, recalledForIdentity, rememberForIdentity } from "./session-incarnation.js";
22
22
  import { scanMemoryFileName, scanMemoryWrite, scanRemediation } from "./scan.js";
23
23
  import { writableRootOf } from "./types.js";
@@ -310,6 +310,7 @@ const STALE_LEG_NOTE = "memory session account: this harvest belongs to an earli
310
310
  export class MemoryEngine {
311
311
  backend;
312
312
  memoryDir;
313
+ mountedRoot;
313
314
  controlDir;
314
315
  now;
315
316
  perScopeBudgetBytes;
@@ -427,7 +428,7 @@ export class MemoryEngine {
427
428
  }
428
429
  return this.backend.readCommittedShadow?.(id) ?? readSafe(join(this.controlDir, "shadow", `${id}.md`));
429
430
  }
430
- async restoreReadOnlyLayers(candidates, report, selfLeg) {
431
+ async restoreReadOnlyLayers(candidates, report, selfLeg, root) {
431
432
  let writers;
432
433
  for (const c of candidates) {
433
434
  const text = readSafe(c.path);
@@ -450,6 +451,7 @@ export class MemoryEngine {
450
451
  continue;
451
452
  }
452
453
  try {
454
+ this.refuseOutsideStore(root, c.path, "restoring the read-only layer file");
453
455
  writeFileNoFollow(c.path, committed);
454
456
  report.restored.push(c.relPath);
455
457
  }
@@ -755,6 +757,7 @@ export class MemoryEngine {
755
757
  const indexPath = join(writableRoot, MEMORY_INDEX_FILENAME);
756
758
  const baseline = handle.planeBaseline !== undefined ? handle.planeBaseline.index : handle.indexText;
757
759
  try {
760
+ this.refuseOutsideStore(handle.memoryDir, indexPath, "sweeping the index");
758
761
  const first = existsSync(indexPath) ? readSafe(indexPath) : undefined;
759
762
  if (first !== undefined && first !== baseline) {
760
763
  const onDisk = readSafe(indexPath);
@@ -1088,6 +1091,7 @@ export class MemoryEngine {
1088
1091
  if (kept.length === lines.length)
1089
1092
  continue;
1090
1093
  try {
1094
+ this.refuseOutsideStore(landingPathOf(this.memoryDir), indexPath, "rewriting the index");
1091
1095
  writeFileNoFollow(indexPath, kept.join("\n"));
1092
1096
  }
1093
1097
  catch {
@@ -1409,6 +1413,13 @@ export class MemoryEngine {
1409
1413
  }
1410
1414
  async materializeCore(scopes, writeScope, opts, legSeat) {
1411
1415
  ensureDirExists(this.memoryDir);
1416
+ const physicalRoot = landingPathOf(this.memoryDir);
1417
+ this.mountedRoot ??= physicalRoot;
1418
+ if (physicalRoot !== this.mountedRoot) {
1419
+ const e = new Error(`${TARGET_OUTSIDE_STORE}: the memory dir ${this.memoryDir} now resolves to ${physicalRoot}, not ${this.mountedRoot} where this engine first mounted it — refused; open a new engine on the directory you mean`);
1420
+ e.code = TARGET_OUTSIDE_STORE;
1421
+ throw e;
1422
+ }
1412
1423
  ensureDirExists(this.controlDir);
1413
1424
  await this.adoptLayoutBeforeMount(writeScope);
1414
1425
  const restricted = opts?.adoptionRestricted === true;
@@ -1593,6 +1604,7 @@ export class MemoryEngine {
1593
1604
  if (handle.writableRoot !== null)
1594
1605
  ensureDirExists(handle.writableRoot);
1595
1606
  const zeroCopy = this.backendPinnedRoot !== undefined && canonicalize(this.backendPinnedRoot) === canonicalize(this.memoryDir);
1607
+ const projectionRoot = zeroCopy ? undefined : this.mountedRoot;
1596
1608
  for (const [scope, dir] of scopeDirs) {
1597
1609
  const scopeEntries = (byScope.get(scope) ?? []).slice();
1598
1610
  scopeEntries.sort((a, b) => (mtimeById.get(b.id) ?? 0) - (mtimeById.get(a.id) ?? 0));
@@ -1614,8 +1626,8 @@ export class MemoryEngine {
1614
1626
  const { deleted: _stubDrop, ...stubFm } = entry.frontmatter;
1615
1627
  const text = stub ? serializeEntryFile({ id: entry.id, frontmatter: stubFm, body: STUB_ARCHIVED_LINE }) : fullText;
1616
1628
  const target = join(dir, `${entry.slug}.md`);
1617
- if (!zeroCopy)
1618
- this.writeIfChanged(target, text);
1629
+ if (projectionRoot !== undefined)
1630
+ this.writeIfChanged(target, text, projectionRoot);
1619
1631
  const onDiskRev = stub ? revOfText(text, entry.id) : entry.rev;
1620
1632
  const path = canonicalize(target);
1621
1633
  handle.materialized.push({
@@ -2176,7 +2188,7 @@ export class MemoryEngine {
2176
2188
  signalContainment((c) => (c.indexRolledBack = true));
2177
2189
  report.warnings.push(`memory harvest refused by an incident while this session is marked externally exposed — no entries were committed; instruction-form files were withheld and the derived index rolled back: ${inlineUntrusted(pollutedReason, 200)}`);
2178
2190
  };
2179
- await this.restoreReadOnlyLayers(readOnlyRestorable, report, handle.accountLeg);
2191
+ await this.restoreReadOnlyLayers(readOnlyRestorable, report, handle.accountLeg, handle.memoryDir);
2180
2192
  const missingWritable = writable.filter((m) => !recordByPath.has(m.path) && !existsSync(m.path) && !presentIds.has(m.id));
2181
2193
  report.missing.push(...missingWritable.map((m) => m.relPath));
2182
2194
  if (writable.length > 0 && missingWritable.length / writable.length > MASS_DELETION_FUSE_RATIO) {
@@ -2203,6 +2215,7 @@ export class MemoryEngine {
2203
2215
  if (content === undefined)
2204
2216
  continue;
2205
2217
  try {
2218
+ this.refuseOutsideStore(handle.memoryDir, m.path, "restoring");
2206
2219
  mkdirSync(dirname(m.path), { recursive: true });
2207
2220
  writeFileNoFollow(m.path, content);
2208
2221
  report.restored.push(m.relPath);
@@ -4818,6 +4831,7 @@ export class MemoryEngine {
4818
4831
  if (opts.write) {
4819
4832
  let wrote = false;
4820
4833
  try {
4834
+ this.refuseOutsideStore(handle.memoryDir, indexPath, "writing the index");
4821
4835
  mkdirSync(dirname(indexPath), { recursive: true });
4822
4836
  if (readNoFollowSafe(indexPath) !== text)
4823
4837
  writeFileNoFollow(indexPath, text);
@@ -4908,6 +4922,7 @@ export class MemoryEngine {
4908
4922
  }
4909
4923
  let cleared = false;
4910
4924
  try {
4925
+ this.refuseOutsideStore(handle.memoryDir, indexPath, "clearing the index");
4911
4926
  writeFileNoFollow(indexPath, "");
4912
4927
  cleared = true;
4913
4928
  }
@@ -4927,7 +4942,16 @@ export class MemoryEngine {
4927
4942
  contained: cleared,
4928
4943
  };
4929
4944
  }
4930
- writeIfChanged(target, text) {
4945
+ refuseOutsideStore(root, path, act) {
4946
+ const escape = storeTargetEscape(root, path);
4947
+ if (escape === undefined)
4948
+ return;
4949
+ const e = new Error(`${TARGET_OUTSIDE_STORE}: ${act} ${path} was refused — ${escape}; not performed`);
4950
+ e.code = TARGET_OUTSIDE_STORE;
4951
+ throw e;
4952
+ }
4953
+ writeIfChanged(target, text, root) {
4954
+ this.refuseOutsideStore(root, target, "the projection write of");
4931
4955
  const current = readNoFollowSafe(target);
4932
4956
  if (current === text)
4933
4957
  return;
@@ -353,6 +353,8 @@ export declare class FileMemoryEngineBackend implements MemoryBackend {
353
353
  * NEVER under {@link directoryRoot}, always on the config-root side even for an in-repo memory dir.
354
354
  * The engine picks this up (like `directoryRoot`) so both sides share ONE control plane. */
355
355
  readonly controlPlaneRoot: string;
356
+ /** {@link directoryRoot}'s physical form at construction — the root every act on a store path is judged against. */
357
+ private readonly physicalRoot;
356
358
  /** Injected clock (opus 1.258 LOW: match the engine's `this.now` determinism discipline). */
357
359
  private readonly now;
358
360
  /** The construction-time incident sink (partition adoption, layout adoption on the standalone faces). */
@@ -679,6 +681,19 @@ export declare class FileMemoryEngineBackend implements MemoryBackend {
679
681
  * §3.1 ⓪ (r5-①): "no lock" is ENOENT-only — a stat FAILURE folded into "nobody is committing"
680
682
  * let a transient EIO run the unlocked recovery (and its staged sweep) over a live transaction. */
681
683
  private txnInFlight;
684
+ /**
685
+ * The act gate for store paths ({@link storeTargetEscape}) over `ops`' writes and deletes — shadow
686
+ * ops name control-plane files, not store paths. Refuses with `memory.target_outside_store` at the
687
+ * first op that would land outside the store, naming the op and where it resolves. Runs over a
688
+ * whole batch before its commit marker (no journal is written; a caller that staged files removes
689
+ * them) and per op at the act itself; past the marker a refusal keeps the journal, which replays
690
+ * once the path resolves inside the store again.
691
+ */
692
+ private refuseEscapingTargets;
693
+ /** At the head of every face (both recovery legs, the lock-free faces' layout seat, the adoption face):
694
+ * the store root still resolves to the directory this backend opened. A root re-pointed since would
695
+ * carry every read, adoption, sweep and restore — not only the journal's acts — into another directory. */
696
+ private assertRootIntact;
682
697
  /** Remove orphaned `.stage-*` files (a crash BEFORE the journal rename = clean rollback: nothing
683
698
  * was committed, the staged tmps are litter). Dotfiles are invisible to entry scans regardless. */
684
699
  private sweepStagedFiles;
@@ -1,4 +1,4 @@
1
- import { existsSync, linkSync, lstatSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, openSync, fsyncSync, closeSync, utimesSync } from "node:fs";
1
+ import { existsSync, fstatSync, linkSync, lstatSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, openSync, fsyncSync, closeSync, utimesSync } from "node:fs";
2
2
  import { createHash, randomUUID } from "node:crypto";
3
3
  import { dirname, join, relative } from "node:path";
4
4
  import { jaccardDistance, termSet } from "../memory-vector.js";
@@ -8,7 +8,7 @@ import { ambiguousOriginRepresentation, committedDistilledOf, committedOriginOf,
8
8
  import { convertV1SidecarKeys } from "./delegation-settlement.js";
9
9
  import { layerObservationLookup, observedDivergenceNotice } from "./layer-observations.js";
10
10
  import { DEFAULT_MAX_ENTRY_DEPTH, MEMORY_INDEX_FILENAME, scanEntryFiles } from "./entry-scan.js";
11
- import { ControlPlaneCorruptError, CURSORS_FILE, QUARANTINE_DIR, CHALLENGES_FILE, IMPORT_RECEIPTS_DIR, LINEAGE_FILE, SESSION_POLLUTION_DIR, appendChallengeEvents, atomicWriteFileSync, quarantineAndTombstone, captureAndClearLineageForEntries, adoptRootScopeLayout, adoptCanonicalKeyedControlDir, deriveControlPlaneDir, ensureDirExists, enqueueMemoryAnnouncement, importLatchTxnId, importLineageCommitted, importSessionPollution, isContainedIn, permissionRemedyOf, withPermissionRemedy, lineageContributionsOfSession, listSessionPollution, readChallengeEvents, readLineageRecord, registerScope, registeredScopes, releaseImportLatch, layoutNeedsAdoption, resolveMemoryEngineRoot, scopeDirFor, scopeDirName, scopeRegistryShape, stageImportLatch, writeAllSync, } from "./layout.js";
11
+ import { ControlPlaneCorruptError, CURSORS_FILE, QUARANTINE_DIR, CHALLENGES_FILE, IMPORT_RECEIPTS_DIR, LINEAGE_FILE, SESSION_POLLUTION_DIR, appendChallengeEvents, atomicWriteFileSync, quarantineAndTombstone, captureAndClearLineageForEntries, adoptRootScopeLayout, adoptCanonicalKeyedControlDir, deriveControlPlaneDir, ensureDirExists, enqueueMemoryAnnouncement, importLatchTxnId, importLineageCommitted, importSessionPollution, isContainedIn, permissionRemedyOf, withPermissionRemedy, lineageContributionsOfSession, listSessionPollution, readChallengeEvents, readLineageRecord, registerScope, registeredScopes, releaseImportLatch, layoutNeedsAdoption, resolveMemoryEngineRoot, scopeDirFor, scopeDirName, scopeRegistryShape, stageImportLatch, landingPathOf, storeTargetEscape, TARGET_OUTSIDE_STORE, writeAllSync, } from "./layout.js";
12
12
  import { screenInboundEntries } from "./data-plane.js";
13
13
  import { scanMemoryFileName, scanMemoryWrite } from "./scan.js";
14
14
  import { thrownText } from "../thrown-value.js";
@@ -522,6 +522,7 @@ function probeProjection(absPath, id) {
522
522
  export class FileMemoryEngineBackend {
523
523
  directoryRoot;
524
524
  controlPlaneRoot;
525
+ physicalRoot;
525
526
  now;
526
527
  onIncident;
527
528
  ledger;
@@ -545,6 +546,7 @@ export class FileMemoryEngineBackend {
545
546
  adoptCanonicalKeyedControlDir(resolveMemoryEngineRoot(), dir, opts.onIncident);
546
547
  this.controlPlaneRoot = opts.controlDir ?? deriveControlPlaneDir(resolveMemoryEngineRoot(), dir);
547
548
  ensureDirExists(dir);
549
+ this.physicalRoot = landingPathOf(dir);
548
550
  ensureDirExists(this.controlPlaneRoot);
549
551
  this.recoverJournal({ unlocked: true });
550
552
  try {
@@ -577,6 +579,7 @@ export class FileMemoryEngineBackend {
577
579
  });
578
580
  }
579
581
  ensureLayout(ownerCandidate) {
582
+ this.assertRootIntact();
580
583
  if (!layoutNeedsAdoption(this.directoryRoot, this.controlPlaneRoot))
581
584
  return;
582
585
  const lock = this.tryAcquireTxnLockSync();
@@ -591,6 +594,7 @@ export class FileMemoryEngineBackend {
591
594
  }
592
595
  }
593
596
  async adoptRootScopeLayout(opts = {}) {
597
+ this.assertRootIntact();
594
598
  if (!layoutNeedsAdoption(this.directoryRoot, this.controlPlaneRoot))
595
599
  return;
596
600
  const lock = await this.acquireTxnLock();
@@ -1211,6 +1215,7 @@ export class FileMemoryEngineBackend {
1211
1215
  }
1212
1216
  }
1213
1217
  recoverJournal(fence) {
1218
+ this.assertRootIntact();
1214
1219
  if (fence.token === undefined) {
1215
1220
  if (this.txnInFlight())
1216
1221
  return;
@@ -1399,7 +1404,10 @@ export class FileMemoryEngineBackend {
1399
1404
  }
1400
1405
  }
1401
1406
  this.assertTxnLockOwnership(fenceToken);
1407
+ const replayAt = `the journal replay (journal kept at ${jp})`;
1408
+ this.refuseEscapingTargets(journal.ops, replayAt);
1402
1409
  for (const op of journal.ops) {
1410
+ this.refuseEscapingTargets([op], replayAt);
1403
1411
  try {
1404
1412
  if (op.kind === "write" && op.content !== undefined) {
1405
1413
  atomicWriteFileSync(op.target, op.content);
@@ -1457,6 +1465,24 @@ export class FileMemoryEngineBackend {
1457
1465
  throw new ControlPlaneCorruptError(`txn lock state could not be probed (${code ?? "io error"}) at ${this.txnLockDir()} — a probe failure is not "no writer" (fail-closed)`);
1458
1466
  }
1459
1467
  }
1468
+ refuseEscapingTargets(ops, at) {
1469
+ for (const op of ops) {
1470
+ if (op.kind !== "write" && op.kind !== "delete")
1471
+ continue;
1472
+ for (const path of op.staged === undefined ? [op.target] : [op.target, op.staged]) {
1473
+ const escape = storeTargetEscape(this.physicalRoot, path);
1474
+ if (escape === undefined)
1475
+ continue;
1476
+ throw erasureCodedError(`${TARGET_OUTSIDE_STORE}: ${at} refused a ${op.kind} of ${op.target}${path === op.target ? "" : ` (its staged file ${path})`} — ${escape}; not performed`, TARGET_OUTSIDE_STORE);
1477
+ }
1478
+ }
1479
+ }
1480
+ assertRootIntact() {
1481
+ const now = landingPathOf(this.directoryRoot);
1482
+ if (now === this.physicalRoot)
1483
+ return;
1484
+ throw erasureCodedError(`${TARGET_OUTSIDE_STORE}: the store root ${this.directoryRoot} now resolves to ${now}, not ${this.physicalRoot} where this backend opened it — refused; open a new backend on the directory you mean`, TARGET_OUTSIDE_STORE);
1485
+ }
1460
1486
  sweepStagedFiles() {
1461
1487
  const walk = (d, depth) => {
1462
1488
  let names;
@@ -2366,16 +2392,37 @@ export class FileMemoryEngineBackend {
2366
2392
  return report;
2367
2393
  }
2368
2394
  const txn = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
2395
+ const stagedFiles = [];
2396
+ const refuseUnstaging = (batch, at) => {
2397
+ try {
2398
+ this.refuseEscapingTargets(batch, at);
2399
+ }
2400
+ catch (err) {
2401
+ for (const f of stagedFiles) {
2402
+ try {
2403
+ const st = lstatSync(f.path);
2404
+ if (st.dev === f.dev && st.ino === f.ino)
2405
+ rmSync(f.path, { force: true });
2406
+ }
2407
+ catch {
2408
+ }
2409
+ }
2410
+ throw err;
2411
+ }
2412
+ };
2369
2413
  let n = 0;
2370
2414
  for (const op of ops) {
2371
2415
  if (op.kind !== "write" || op.content === undefined)
2372
2416
  continue;
2417
+ refuseUnstaging([op], "applyPatches (staging)");
2373
2418
  const staged = join(dirname(op.target), `.stage-${txn}-${n++}`);
2374
2419
  mkdirSync(dirname(op.target), { recursive: true });
2375
2420
  const fd = openSync(staged, "w", 0o600);
2376
2421
  try {
2377
2422
  writeAllSync(fd, op.content);
2378
2423
  fsyncSync(fd);
2424
+ const st = fstatSync(fd);
2425
+ stagedFiles.push({ path: staged, dev: st.dev, ino: st.ino });
2379
2426
  }
2380
2427
  finally {
2381
2428
  closeSync(fd);
@@ -2393,8 +2440,10 @@ export class FileMemoryEngineBackend {
2393
2440
  if (coherentTransfers.length > 0)
2394
2441
  this.precheckTransfersAppendable();
2395
2442
  const journal = { txn, ops, ledger2: ledgerEnvelope(rows, this.envelopeExtras), ...(coherentTransfers.length > 0 ? { transfers: coherentTransfers } : {}) };
2443
+ refuseUnstaging(ops, "applyPatches (commit point)");
2396
2444
  atomicWriteFileSync(join(this.controlPlaneRoot, JOURNAL_FILE), JSON.stringify(journal));
2397
2445
  for (const op of ops) {
2446
+ this.refuseEscapingTargets([op], "applyPatches (after the commit point; the journal is kept)");
2398
2447
  if (op.kind === "write" && op.staged) {
2399
2448
  renameSync(op.staged, op.target);
2400
2449
  }
@@ -2819,8 +2868,10 @@ export class FileMemoryEngineBackend {
2819
2868
  this.assertTxnLockOwnership(lock.token);
2820
2869
  this.precheckTransfersAppendable();
2821
2870
  const journal = { txn, ops, ledger2: ledgerEnvelope(rows, this.envelopeExtras), transfers };
2871
+ this.refuseEscapingTargets(ops, "eraseWithEvidence (commit point)");
2822
2872
  atomicWriteFileSync(join(this.controlPlaneRoot, JOURNAL_FILE), JSON.stringify(journal));
2823
2873
  for (const op of ops) {
2874
+ this.refuseEscapingTargets([op], "eraseWithEvidence (after the commit point; the journal is kept)");
2824
2875
  if (op.kind === "delete") {
2825
2876
  rmSync(op.target, { force: true });
2826
2877
  }
@@ -259,6 +259,28 @@ export declare function adoptRootScopeLayout(rootPath: string, controlDir: strin
259
259
  export declare const canonicalize: typeof physicalPathOfExisting;
260
260
  /** True iff `child` (canonicalized) is `root` or inside it. */
261
261
  export declare function isContainedIn(root: string, child: string): boolean;
262
+ /** The code an act on a store path is refused with when it would land outside the store ({@link storeTargetEscape}). */
263
+ export declare const TARGET_OUTSIDE_STORE = "memory.target_outside_store";
264
+ /** Where an act at `p` lands: the physical path of its normalized spelling (the spelling a child path
265
+ * joined under it carries), a MISSING tail rejoined under the deepest existing directory (what the act
266
+ * creates). Any other resolution fault (EACCES, ELOOP, ENOTDIR) is THROWN as itself: an act that
267
+ * cannot be placed is not performed — a failed resolution never implies the act would fail too
268
+ * (a link whose own mode refuses `realpath` is still followed by `unlink` and `rename`). */
269
+ export declare function landingPathOf(p: string): string;
270
+ /**
271
+ * Where a write, rename or delete at `target` would physically LAND, judged against the store's
272
+ * `physicalRoot`: `undefined` = inside, otherwise the reason. The target's directory, resolved through
273
+ * every link on its existing prefix (a missing tail is what the act creates, under the deepest existing
274
+ * directory), must be the root or inside it. `physicalRoot` is the root's physical form as the store
275
+ * was opened and is not re-resolved here: a store mounted through a link is judged by what the link
276
+ * resolved to, and a root spelling re-pointed after opening moves the acts, not the store. The final
277
+ * component is not judged: `rename` and `unlink` act on it and never through it, and the engine's
278
+ * writes open it no-follow — a link there is replaced, removed or refused, and what it points at is
279
+ * untouched. A lexical spelling under the root proves nothing: a directory under it replaced by a link
280
+ * keeps the spelling while the act follows the link out. A directory that cannot be resolved throws its
281
+ * fault ({@link landingPathOf}).
282
+ */
283
+ export declare function storeTargetEscape(physicalRoot: string, target: string): string | undefined;
262
284
  /** mkdir -p convenience used by the engine/backend (0o700 like the file stores). */
263
285
  export declare function ensureDirExists(dir: string): void;
264
286
  /** The one remedy every surfaced permission refusal carries — `undefined` unless `err` is a write
@@ -324,10 +346,13 @@ export interface QuarantineOutcome {
324
346
  * without native bindings. What this buys is the leaf variant, which is the cheap one; the ancestor
325
347
  * variant stays open and is recorded with the rest of the family rather than papered over.
326
348
  *
327
- * Throws like `writeFileSync`, including ELOOP when the final component IS a symlink. The write
328
- * itself goes through `writeFileSync` on the open descriptor, which writes every byte or throws —
329
- * a bare `writeSync` may short-write, and reporting a truncated restore as contained (after
330
- * `O_TRUNC` already destroyed the destination) is the failure this guard exists to prevent.
349
+ * Throws like `writeFileSync`, including ELOOP when the final component IS a symlink. A HARD link is the
350
+ * same tamper by other means — the file's bytes live under a second name, possibly outside the store, and
351
+ * an in-place write would change them there too — so an opened file with more than one name is refused
352
+ * the same way, before anything is truncated (the check reads the opened descriptor: no window). The
353
+ * write itself goes through `writeFileSync` on the open descriptor, which writes every byte or throws —
354
+ * a bare `writeSync` may short-write, and reporting a truncated restore as contained (after the
355
+ * truncation already destroyed the destination) is the failure this guard exists to prevent.
331
356
  */
332
357
  export declare function writeFileNoFollow(path: string, content: string): void;
333
358
  /** {@link writeFileNoFollow}'s NO-REPLACE twin: write `content` only if nothing is at `path`. `"written"`
@@ -1,10 +1,10 @@
1
- import { closeSync, constants as fsConstants, copyFileSync, existsSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, rmSync, rmdirSync, statSync, unlinkSync, utimesSync, writeFileSync, writeSync } from "node:fs";
2
- const { O_WRONLY, O_RDONLY, O_CREAT, O_TRUNC, O_NOFOLLOW, O_NONBLOCK, O_EXCL } = fsConstants;
1
+ import { closeSync, constants as fsConstants, copyFileSync, existsSync, fstatSync, fsyncSync, ftruncateSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, rmSync, rmdirSync, statSync, unlinkSync, utimesSync, writeFileSync, writeSync } from "node:fs";
2
+ const { O_WRONLY, O_RDONLY, O_CREAT, O_NOFOLLOW, O_NONBLOCK, 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
+ import { physicalPathOf, physicalPathOfExisting, priorCallerCasedPathOf } from "../physical-path.js";
8
8
  import { thrownLabel, thrownText } from "../thrown-value.js";
9
9
  const SCOPES_FILE = "scopes.json";
10
10
  export const CURSORS_FILE = "cursors.json";
@@ -724,6 +724,22 @@ export function isContainedIn(root, child) {
724
724
  const c = canonicalize(child);
725
725
  return c === r || c.startsWith(`${r}${sep}`);
726
726
  }
727
+ export const TARGET_OUTSIDE_STORE = "memory.target_outside_store";
728
+ export function landingPathOf(p) {
729
+ const normalized = resolve(p);
730
+ try {
731
+ return physicalPathOf(normalized);
732
+ }
733
+ catch (err) {
734
+ if (err.code !== "ENOENT")
735
+ throw err;
736
+ return canonicalize(normalized);
737
+ }
738
+ }
739
+ export function storeTargetEscape(physicalRoot, target) {
740
+ const dir = landingPathOf(dirname(target));
741
+ return dir === physicalRoot || dir.startsWith(`${physicalRoot}${sep}`) ? undefined : `its directory resolves to ${dir}, outside the store root ${physicalRoot}`;
742
+ }
727
743
  export function ensureDirExists(dir) {
728
744
  if (!existsSync(dir))
729
745
  mkdirSync(dir, { recursive: true, mode: 0o700 });
@@ -749,8 +765,12 @@ export function withPermissionRemedy(err, memoryDir) {
749
765
  export const QUARANTINE_DIR = "quarantine";
750
766
  const DELETED_TOMBSTONE = "---\ndeleted: true\n---\n";
751
767
  export function writeFileNoFollow(path, content) {
752
- const fd = openSync(path, O_WRONLY | O_CREAT | O_TRUNC | O_NOFOLLOW, 0o644);
768
+ const fd = openSync(path, O_WRONLY | O_CREAT | O_NOFOLLOW, 0o644);
753
769
  try {
770
+ const links = fstatSync(fd).nlink;
771
+ if (links > 1)
772
+ throw Object.assign(new Error(`${path} has ${links} names (a hard link) — a write through one name changes the file under the others; not written`), { path });
773
+ ftruncateSync(fd, 0);
754
774
  writeFileSync(fd, content, "utf8");
755
775
  }
756
776
  finally {
@@ -19,7 +19,7 @@
19
19
  import type { RedactionConfidence, RedactionSource } from "./arg-summary.js";
20
20
  import { type EngineNotice } from "./engine-notice.js";
21
21
  /** Which rule classified a key as secret-shaped — the env arm's `kind` vocabulary. */
22
- export type SecretEnvFindingKind = "suffix-rule" | "exact-name";
22
+ export type SecretEnvFindingKind = "suffix-rule" | "namespace-rule" | "exact-name";
23
23
  /** One env entry the `scrub` policy dropped: the key (the VALUE is never carried — this record is meant
24
24
  * to be loggable), the rule that classified it, how strong that claim is, and the minting module. */
25
25
  export interface SecretEnvFinding {
@@ -1,6 +1,8 @@
1
1
  import { deliverEngineNotice } from "./engine-notice.js";
2
2
  const TOKEN_COUNT_WORDS = ["MAX", "OUTPUT", "INPUT", "THINKING", "NUM", "TOTAL"];
3
- const SECRET_ENV_RE = new RegExp(`(^|_)(KEY|KEYS|TOKEN|(?<!(?:^|_)(?:${TOKEN_COUNT_WORDS.join("|")})_)TOKENS|SECRET|SECRETS|PASSWORD|PASSWORDS|PASSWD|CREDENTIAL|CREDENTIALS|PRIVATEKEY|APIKEY|ACCESS_KEY|SESSION_TOKEN)(?:_(?:\\d+|ID))?$`, "i");
3
+ const CREDENTIAL_WORD = `KEY|KEYS|TOKEN|(?<!(?:^|_)(?:${TOKEN_COUNT_WORDS.join("|")})_)TOKENS|SECRET|SECRETS|PASSWORD|PASSWORDS|PASSWD|CREDENTIAL|CREDENTIALS|PRIVATEKEY|APIKEY`;
4
+ const SECRET_ENV_RE = new RegExp(`(^|_)(${CREDENTIAL_WORD})(?:_(?:\\d+|ID))?$`, "i");
5
+ const SEMA_NAMESPACE_SECRET_RE = new RegExp(`^SEMA_(?:.*_)?(?:${CREDENTIAL_WORD})(?:_|$)`, "is");
4
6
  const SECRET_ENV_EXACT_NAMES = new Set([
5
7
  "DATABASE_URL", "CONNECTION_STRING", "DSN", "NPM_AUTH",
6
8
  "PGPASSWORD", "MYSQL_PWD", "SSHPASS", "GPG_PASSPHRASE", "GH_PAT", "GITHUB_PAT",
@@ -16,6 +18,7 @@ const NON_SECRET_ENV_EXACT_NAMES = new Set([
16
18
  const SECRET_ENV_KIND_CONFIDENCE = {
17
19
  "exact-name": "high",
18
20
  "suffix-rule": "medium",
21
+ "namespace-rule": "medium",
19
22
  };
20
23
  export function classifyExactEnvName(key, credentialNames, exemptNames) {
21
24
  const upper = key.toUpperCase();
@@ -31,7 +34,9 @@ function classifySecretEnvKey(key) {
31
34
  return "exact-name";
32
35
  if (byExactName === "exempt")
33
36
  return undefined;
34
- return SECRET_ENV_RE.test(key) ? "suffix-rule" : undefined;
37
+ if (SECRET_ENV_RE.test(key))
38
+ return "suffix-rule";
39
+ return SEMA_NAMESPACE_SECRET_RE.test(key) ? "namespace-rule" : undefined;
35
40
  }
36
41
  export function isSecretEnvKey(key) {
37
42
  return classifySecretEnvKey(key) !== undefined;
@@ -62,8 +62,8 @@ export interface ReadCandidate {
62
62
  * A deny row is a floor, so the judge resolves the spelling against these for deny rows ONLY — never to
63
63
  * establish a read. `cd sub && cd $'\x2e' && cat <deny row under sub>` (bash: `cd .`) is refused through it. */
64
64
  readonly floorBases?: readonly string[];
65
- /** A home word the plan does not place (the text may rebind `HOME`, or it sits in a value): spelled with the DECLARED
66
- * home. A deny row is a floor: the judge reads it for deny rows ONLY, never to establish a read. */
65
+ /** A home word the plan does not place (the text may rebind `HOME`, it sits in a value, or it is a literal tilde the
66
+ * program may expand itself): spelled with the DECLARED home. The judge reads it for deny rows ONLY. */
67
67
  readonly floorSpelled?: string;
68
68
  /** A deny-floor shadow of a `floorSpelled` spelling, established as a read and judged for deny rows only. */
69
69
  readonly denyOnly?: true;
@@ -193,7 +193,7 @@ function segmentFacts(segment, p, decl, home, nestedPlan) {
193
193
  unresolvedRaw.add(w.raw);
194
194
  }
195
195
  const homeWithheld = home === undefined && decl.homeDir !== undefined;
196
- const floor = (c) => { const f = c.expands === "unresolvable" && decl.homeDir !== undefined ? spelledWithHome(c.spelled, c.raw, decl.homeDir) : undefined; return f === undefined ? c : { ...c, floorSpelled: f }; };
196
+ const floor = (c) => { const f = (c.expands === "unresolvable" || c.spelled.startsWith("./~")) && decl.homeDir !== undefined ? spelledWithHome(c.spelled, c.raw, decl.homeDir) : undefined; return f === undefined ? c : { ...c, floorSpelled: f }; };
197
197
  const unsettle = (c) => floor((unresolvedRaw.has(c.raw) || (homeWithheld && c.spelled.startsWith("~"))) && c.expands !== "unresolvable" ? { ...c, expands: "unresolvable" } : c);
198
198
  const move = facts.baseMove;
199
199
  return {
@@ -268,9 +268,8 @@ function segmentFactsRead(segment, p, decl, home, nestedPlan) {
268
268
  const basename = program?.basename;
269
269
  const listed = basename !== undefined && (decl.readers.has(basename) || basename === "cd");
270
270
  const verb = program === undefined ? undefined : { name: program.name, basename: program.basename, listed };
271
- const envOpens = basename !== undefined && !NO_PATH_OPERAND_COMMANDS.has(basename);
272
271
  for (const k of pos.assignments)
273
- pushAssignment(reads, toks[k].word, "env_assignment", envOpens);
272
+ pushAssignment(reads, toks[k].word, "env_assignment");
274
273
  for (const l of pos.launchers) {
275
274
  const r = toks[l.index];
276
275
  if (isPathShapedToken(r.folded))
@@ -313,7 +312,7 @@ function segmentFactsRead(segment, p, decl, home, nestedPlan) {
313
312
  continue;
314
313
  }
315
314
  if (r.word.assignment !== undefined) {
316
- pushAssignment(reads, r.word, "env_assignment", envOpens);
315
+ pushAssignment(reads, r.word, "env_assignment");
317
316
  continue;
318
317
  }
319
318
  reads.push(bare(r, "unlisted_word"));
@@ -462,7 +461,7 @@ function dedupe(reads) {
462
461
  const seen = new Set();
463
462
  const out = [];
464
463
  for (const c of reads) {
465
- const key = `${c.provenance}\u0000${c.spelled}\u0000${c.expands}\u0000${c.denyOnly === true}`;
464
+ const key = `${c.provenance}\u0000${c.spelled}\u0000${c.expands}\u0000${c.raw}\u0000${c.denyOnly === true}`;
466
465
  if (seen.has(key))
467
466
  continue;
468
467
  seen.add(key);
@@ -488,7 +487,7 @@ function redirectFact(r, home) {
488
487
  return { op: r.op, direction: "in" };
489
488
  return { op: r.op, direction: "out", target: bare(readsOf([r.target], home)[0], "write_redirect_target") };
490
489
  }
491
- function pushAssignment(reads, w, provenance, opensValue = true) {
490
+ function pushAssignment(reads, w, provenance) {
492
491
  const a = w.assignment;
493
492
  if (a === undefined)
494
493
  return;
@@ -498,13 +497,19 @@ function pushAssignment(reads, w, provenance, opensValue = true) {
498
497
  const named = a.value.folded === "" || (a.op === "+=" ? expandsTilde : !settled) ? undefined : value;
499
498
  if (a.value.folded === "" && settled)
500
499
  return;
501
- const v = { spelled: a.value.folded, raw: a.bare ? a.value.raw : w.raw, provenance };
500
+ const v = { spelled: value, raw: a.bare ? a.value.raw : w.raw, provenance };
502
501
  const unsettled = provenance !== "env_assignment" ? { ...v, expands: "unresolvable" } : v.spelled.startsWith("~") && !v.spelled.includes(":") ? { ...v, expands: expandsOf(a.value) } : undefined;
503
- if (!settled && opensValue && unsettled !== undefined)
502
+ if (!settled && unsettled !== undefined)
504
503
  reads.push(unsettled);
505
504
  const substitutes = a.value.expansions.some((p) => p.expansion?.kind === "command" || p.expansion?.kind === "process" || p.expansion?.kind === "arithmetic");
506
505
  if (named !== undefined && !substitutes)
507
506
  reads.push({ spelled: named, raw: w.raw, provenance, expands: expandsOf(w) });
507
+ if (a.value.folded.includes(":"))
508
+ for (const m of a.value.folded.split(":"))
509
+ if (m !== "") {
510
+ const r = readOfText(m);
511
+ reads.push({ spelled: r.folded, raw: r.raw, provenance, expands: r.expands, denyOnly: true });
512
+ }
508
513
  if (a.form === "compound")
509
514
  for (const e of compoundArrayElements(a.value))
510
515
  reads.push({ spelled: e.folded, raw: e.raw, provenance, expands: e.expands, ...(e.denyOnly === true ? { denyOnly: true } : {}) });
@@ -527,7 +532,7 @@ function unlistedWords(reads, toks, from, launcherIndices, provenance, program)
527
532
  expanding.add(j);
528
533
  reads.push(bare(toks[j], j === argvStart ? "program_path" : provenance));
529
534
  if (j < argvStart)
530
- pushAssignment(reads, toks[j].word, "env_assignment", slotVerb !== undefined && !noPathOperands);
535
+ pushAssignment(reads, toks[j].word, "env_assignment");
531
536
  else if (j > argvStart && !declaredNotPaths)
532
537
  pushAssignment(reads, toks[j].word, provenance);
533
538
  }
@@ -605,7 +610,7 @@ function unlistedWords(reads, toks, from, launcherIndices, provenance, program)
605
610
  }
606
611
  reads.push(bare(r, j === argvStart ? "program_path" : provenance));
607
612
  if (j < argvStart)
608
- pushAssignment(reads, r.word, "env_assignment", slotVerb !== undefined && !noPathOperands);
613
+ pushAssignment(reads, r.word, "env_assignment");
609
614
  else if (!declaredNotPaths)
610
615
  pushAssignment(reads, r.word, provenance);
611
616
  }
@@ -25,10 +25,11 @@ export declare function readsOf(words: readonly LexedWord[], home: string | unde
25
25
  export declare const rewritten: (r: Read) => boolean;
26
26
  /** The reading of a text that came out of a word (a sed script's file operand): one shell word, or unresolvable. */
27
27
  export declare function readOfText(text: string): Read;
28
- /** A home-rooted candidate spelled with `home`: a leading `~` / `~/` (a literal tilde is spelled `./~` before this
29
- * point, so a leading `~` IS the expansion), or a `$HOME` / `${HOME}` word as {@link homeExpansionOf} reads it —
30
- * read off the VALUE's pieces when the word is assignment-shaped (`A="$HOME/x"`, `dd if="$HOME/x"`: the candidate is
31
- * the value, quotes kept); `undefined` for any other (`~user`, a payload, a word that is no home word). */
28
+ /** A home-rooted candidate spelled with `home`: a leading `~` / `~/` — the shell's expansion, or the literal-tilde
29
+ * spelling `./~` of one it leaves alone (quoted, escaped, after an option's `=`), which the program it reaches may
30
+ * expand itself — or a `$HOME` / `${HOME}` word as {@link homeExpansionOf} reads it, read off the VALUE's pieces when
31
+ * the word is assignment-shaped (`A="$HOME/x"`, `dd if="$HOME/x"`: the candidate is the value, quotes kept);
32
+ * `undefined` for any other (`~user`, a word that is no home word). */
32
33
  export declare function spelledWithHome(spelled: string, raw: string, home: string): string | undefined;
33
34
  /** A function definition's head names nothing that runs here and is dropped from a segment's tokens, in place:
34
35
  * `NAME ( )` (an identifier), and `function NAME [( )]` — a bare `function` at command position, a name of any
@@ -20,8 +20,9 @@ export function readOfText(text) {
20
20
  return { word, raw: text, folded: text, expands: expandsOf(word) };
21
21
  }
22
22
  export function spelledWithHome(spelled, raw, home) {
23
- if (spelled.startsWith("~"))
24
- return /^~(?:\/|$)/.test(spelled) ? `${home}${spelled.slice(1)}` : undefined;
23
+ const tilde = spelled.startsWith("./~") ? spelled.slice(2) : spelled;
24
+ if (tilde.startsWith("~"))
25
+ return /^~(?:\/|$)/.test(tilde) ? `${home}${tilde.slice(1)}` : undefined;
25
26
  if (!raw.includes("HOME"))
26
27
  return undefined;
27
28
  const w = readOfText(raw).word;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/core",
3
- "version": "7.31.0",
3
+ "version": "7.31.1",
4
4
  "description": "Stateless, task-oriented AI agent core",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",
@@ -0,0 +1,189 @@
1
+ {
2
+ "repo": "core",
3
+ "version": "7.31.1",
4
+ "from": "289b2d1998a3e890f6a8659ffc4397074ddd720a",
5
+ "to": "v7.31.1",
6
+ "added": [],
7
+ "removed": [],
8
+ "changed": [],
9
+ "narrowed": [
10
+ {
11
+ "id": "F1",
12
+ "source": "behaviorFlips.summary",
13
+ "text": "NARROWING (#1094, memory store; +1 error code `memory.target_outside_store`, registered in `NON_GOVERNANCE_MEMORY_CODES`; +0 exports, +0 wire keys): an act at a memory store path is judged by where it LANDS, not by how it is spelled. One verdict — the target's DIRECTORY, resolved through every link on its existing prefix, must be the store root's physical form or inside it (the final component is not judged: the backend's `rm` removes a link standing there and `rename` replaces it, the engine's in-place writes refuse it with `ELOOP` — none follows it) — gates `FileMemoryEngineBackend.applyPatches` (per op at staging, the whole batch at the commit point, per op at each act), erasure (`MemoryEngine.eraseMemoryEntries`: commit point and act), journal replay (the whole journal before its first act, each op again at its act), and the engine's own writes at store paths: the copy-out projection, the harvest's missing-file restore, the read-only layer restore and the `MEMORY.md` index writes. The backend takes its root's physical form when it opens the store and re-checks it at the head of every face; the engine takes its memory dir's physical form at its first mount and a later mount that resolves elsewhere is refused. Separately, the engine's in-place write refuses a file with more than one name (a hard link), before anything is truncated. A refused `applyPatches` / erasure throws a plain `Error` whose `.code` is the new code — before the commit marker nothing is written and no journal is left, after it the journal is kept and replays once the path resolves inside again; a refused mount throws the code; a refused restore or index write is not performed and is reported on `HarvestReport.warnings` (or `incident.detail`) with the code, a hard-link refusal names the file. WHO IS HURT: a deployment whose store holds a directory link under its root, or a hard-linked memory file — the act that used to follow it is refused until it is removed. Known limits KL-1094-a … i."
14
+ },
15
+ {
16
+ "id": "F2",
17
+ "source": "behaviorFlips.summary",
18
+ "text": "NARROWING (#1099, spawned-environment scrub; `SecretEnvFindingKind` +1 member `namespace-rule`, confidence `medium`; +0 exports, +0 wire keys): inside the host shell's `SEMA_` namespace, and only there, a credential word as ANY `_`-separated segment makes the name a credential (`SEMA_NAMESPACE_SECRET_RE`, over the one `CREDENTIAL_WORD` alternation the suffix rule reads, the count-word reading of `TOKENS` included) — so `SEMA_MODEL_KEY_<ENTRY>`, `SEMA_WEBSEARCH_KEY_<PROVIDER>` and `SEMA_REGISTRY_TOKEN_FILE` no longer reach a tool's child shell or a language server, and the one `config.secret_env_scrubbed` notice lists them in `detail.names`. The rule is consulted after the two exact-name lists and the suffix rule, so no existing finding changes kind, and every name outside `SEMA_` keeps its v7.31.0 verdict and kind. WHO IS HURT: a `SEMA_` name with a credential-word segment that a child needed (`SEMA_REGISTRY_TOKEN_FILE`, `SEMA_LLM_KEY_DB`, six `SEMA_CODE_*` knobs a nested `sema` reads); an `inheritEnv` allowlist, `shellEnv` / a per-exec env and a host-supplied LSP `env` still pass a name verbatim."
19
+ },
20
+ {
21
+ "id": "F3",
22
+ "source": "behaviorFlips.summary",
23
+ "text": "NARROWING (#1100, shell read boundary; tightening only — no verdict loosens against 7.31.0; +0 exports, +0 wire keys; supersedes #1004's no-opener ruling for tilde values): three rules on one mechanism, the declared-home deny floor. (1) The opener condition leaves the assignment-value reading: an environment value with a leading tilde is placed against the declared home whether or not a program opens it, so `F=~/<deny row>; cat \"$F\"` meets the deny judge exactly as its `$HOME` and literal spellings already did. (2) A LITERAL tilde (quoted, escaped, after an option's `=`) — a relative `./~…` path to bash, but a program such as npm expands it itself — also carries its declared-home spelling as a deny-only view: refused on a deny row, never a containment question. (3) Each member of a `:` list meets the deny rows (a tilde member placed against the home); the whole value is still no containment candidate. Where no deny row is hit nothing changes. WHO IS HURT: a text whose value or literal-tilde operand names a deny row under the declared home and is never read that way is refused, as its `$HOME` and literal spellings already were (KL-1100a / KL-1100b)."
24
+ }
25
+ ],
26
+ "closedSetMembers": [
27
+ {
28
+ "set": "SecretEnvFindingKind",
29
+ "at": [
30
+ "src/core/secret-env.ts:181"
31
+ ],
32
+ "newSet": false,
33
+ "added": [
34
+ "namespace-rule"
35
+ ],
36
+ "removed": [],
37
+ "breaking": false,
38
+ "criteriaCovered": false
39
+ }
40
+ ],
41
+ "closedSetsUnreadable": [],
42
+ "typeKeys": [],
43
+ "typeKeysUnreadableArms": [],
44
+ "typeKeysFlattenedCount": 216,
45
+ "declarationMoves": [],
46
+ "mintMoves": [],
47
+ "criteriaObserves": [
48
+ {
49
+ "kind": "engineNoticeCode",
50
+ "name": "config.secret_env_scrubbed",
51
+ "assertions": [
52
+ "G2.a"
53
+ ]
54
+ },
55
+ {
56
+ "kind": "typeMember",
57
+ "name": "AskRequest.mandated",
58
+ "assertions": [
59
+ "G3.a",
60
+ "G3.b"
61
+ ]
62
+ },
63
+ {
64
+ "kind": "typeMember",
65
+ "name": "EngineNotice.detail",
66
+ "assertions": [
67
+ "G2.a"
68
+ ]
69
+ },
70
+ {
71
+ "kind": "typeMember",
72
+ "name": "FileMemoryEngineBackend.applyPatches",
73
+ "assertions": [
74
+ "G1.a"
75
+ ]
76
+ },
77
+ {
78
+ "kind": "typeMember",
79
+ "name": "HarvestReport.incident",
80
+ "assertions": [
81
+ "G1.a"
82
+ ]
83
+ },
84
+ {
85
+ "kind": "typeMember",
86
+ "name": "HarvestReport.ok",
87
+ "assertions": [
88
+ "G1.b"
89
+ ]
90
+ },
91
+ {
92
+ "kind": "typeMember",
93
+ "name": "HarvestReport.restored",
94
+ "assertions": [
95
+ "G1.a",
96
+ "G1.b"
97
+ ]
98
+ },
99
+ {
100
+ "kind": "typeMember",
101
+ "name": "HarvestReport.warnings",
102
+ "assertions": [
103
+ "G1.a"
104
+ ]
105
+ },
106
+ {
107
+ "kind": "typeMember",
108
+ "name": "MemoryEngine.eraseMemoryEntries",
109
+ "assertions": [
110
+ "G1.a"
111
+ ]
112
+ },
113
+ {
114
+ "kind": "typeMember",
115
+ "name": "MemoryEngine.materialize",
116
+ "assertions": [
117
+ "G1.a"
118
+ ]
119
+ },
120
+ {
121
+ "kind": "typeMember",
122
+ "name": "MemoryErasureAttestation.status",
123
+ "assertions": [
124
+ "G1.b"
125
+ ]
126
+ },
127
+ {
128
+ "kind": "typeMember",
129
+ "name": "PatchReport.applied",
130
+ "assertions": [
131
+ "G1.b"
132
+ ]
133
+ },
134
+ {
135
+ "kind": "typeMember",
136
+ "name": "SecretEnvFinding.confidence",
137
+ "assertions": [
138
+ "G2.a"
139
+ ]
140
+ },
141
+ {
142
+ "kind": "typeMember",
143
+ "name": "SecretEnvFinding.key",
144
+ "assertions": [
145
+ "G2.b"
146
+ ]
147
+ },
148
+ {
149
+ "kind": "typeMember",
150
+ "name": "SecretEnvFinding.kind",
151
+ "assertions": [
152
+ "G2.a",
153
+ "G2.b"
154
+ ]
155
+ },
156
+ {
157
+ "kind": "typeMember",
158
+ "name": "ShellReadVerdict.disposition",
159
+ "assertions": [
160
+ "G3.a",
161
+ "G3.b"
162
+ ]
163
+ },
164
+ {
165
+ "kind": "typeMember",
166
+ "name": "ShellReadVerdict.finding",
167
+ "assertions": [
168
+ "G3.a",
169
+ "G3.b"
170
+ ]
171
+ },
172
+ {
173
+ "kind": "typeMember",
174
+ "name": "ShellReadVerdict.readRootCandidate",
175
+ "assertions": [
176
+ "G3.b"
177
+ ]
178
+ }
179
+ ],
180
+ "consumersHint": {
181
+ "cli": [],
182
+ "server": [],
183
+ "sdk": [],
184
+ "note": "scanned cli@4e593722 server@c60beebe sdk@a9440f2 — a name listed is MENTIONED there today, which is evidence of consumption, not proof of it. Ledger files (**/docs/core-consumption/**, **/docs/wire-consumption/**, **/*LEDGER*.json) are not evidence of use — server: 1 name(s) matched ONLY in bookkeeping files and were set aside (SecretEnvFindingKind)"
185
+ },
186
+ "breakingCount": 0,
187
+ "breakingItems": [],
188
+ "toResolvedAt": "c7c5484e2ab1b4abcba1297de62424e71306e66c"
189
+ }