@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 +42 -0
- package/dist/core/governance-codes.js +1 -0
- package/dist/core/memory-engine/engine.d.ts +10 -1
- package/dist/core/memory-engine/engine.js +30 -6
- package/dist/core/memory-engine/file-backend.d.ts +15 -0
- package/dist/core/memory-engine/file-backend.js +53 -2
- package/dist/core/memory-engine/layout.d.ts +29 -4
- package/dist/core/memory-engine/layout.js +24 -4
- package/dist/core/secret-env.d.ts +1 -1
- package/dist/core/secret-env.js +7 -2
- package/dist/tools/fs/shell-read-plan.d.ts +2 -2
- package/dist/tools/fs/shell-read-plan.js +15 -10
- package/dist/tools/fs/shell-read-word.d.ts +5 -4
- package/dist/tools/fs/shell-read-word.js +3 -2
- package/package.json +1 -1
- package/wire-changes/core@7.31.1.json +189 -0
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 (
|
|
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
|
-
|
|
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.
|
|
328
|
-
*
|
|
329
|
-
*
|
|
330
|
-
*
|
|
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,
|
|
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 |
|
|
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 {
|
package/dist/core/secret-env.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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`,
|
|
66
|
-
*
|
|
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"
|
|
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"
|
|
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
|
|
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:
|
|
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 &&
|
|
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"
|
|
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"
|
|
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 `~` / `~/`
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* the
|
|
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
|
-
|
|
24
|
-
|
|
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
|
@@ -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
|
+
}
|