@agfpd/iapeer-memory-core 0.2.4 → 0.2.6
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/package.json +1 -1
- package/src/frontmatter-fill.ts +19 -4
- package/src/fs-guard.ts +76 -1
- package/src/index.ts +2 -0
- package/src/memoryd.ts +13 -1
- package/src/silent-edit-detect.ts +5 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agfpd/iapeer-memory-core",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.6",
|
|
4
4
|
"description": "iapeer-memory core — host-neutral TypeScript memory primitive: vault schema/taxonomy config, search engine, memoryd, context renderer, role contracts. Consumed by the @agfpd/iapeer-memory facade; version kept in lockstep by its release flow (docs/10-distribution.md).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/src/frontmatter-fill.ts
CHANGED
|
@@ -16,9 +16,14 @@
|
|
|
16
16
|
* - **identity = peer personality** (нюанс 10): `resolveAgentName` prefers
|
|
17
17
|
* `PEER_PERSONALITY`, falling back to `IAPEER_MEMORY_AGENT_NAME`.
|
|
18
18
|
*
|
|
19
|
-
* Zone behaviour (parity with the reference):
|
|
19
|
+
* Zone behaviour (parity with the reference, one deliberate deviation):
|
|
20
20
|
* - inbox: idempotent fill of the 4-field draft frontmatter +
|
|
21
|
-
* needs_review (unless curator)
|
|
21
|
+
* needs_review (unless curator) + UPSERT of the stamp pair
|
|
22
|
+
* (last_edited_by, updated) — a deviation from the reference,
|
|
23
|
+
* load-bearing for the unstamped detector: its inbox branch
|
|
24
|
+
* discriminates by «the stamp pair did not move», which is
|
|
25
|
+
* only a signal when hook edits DO move it (false-positive
|
|
26
|
+
* incident 11.06, boris's Write+Edit repro).
|
|
22
27
|
* - permanent: upsert of service fields (last_edited_by, updated,
|
|
23
28
|
* needs_review).
|
|
24
29
|
* - memory: PERMANENT service-field semantics + idempotent fill of the
|
|
@@ -162,9 +167,19 @@ export function resolveZone(
|
|
|
162
167
|
|
|
163
168
|
export function fillInbox(
|
|
164
169
|
fmBlock: string,
|
|
165
|
-
opts: { path: string; agent: string; today: string; ctx: FillContext },
|
|
170
|
+
opts: { path: string; agent: string; today: string; nowStamp: string; ctx: FillContext },
|
|
166
171
|
): string {
|
|
167
172
|
const { taxonomy } = opts.ctx;
|
|
173
|
+
// STAMP PAIR (дефект-репро boris 11.06, ложный unstamped): upsert как в
|
|
174
|
+
// fillPermanent/fillMemory. Без неё у inbox-черновиков пара тождественно
|
|
175
|
+
// (null, null) — «штамп не двинулся» детектора немых записей выполняется
|
|
176
|
+
// на ЛЮБОЙ легитимной хук-правке, и inbox-ветка вырождается в «hash
|
|
177
|
+
// двинулся → unstamped» (7 ложных ре-стампов за день 11.06). Оба поля
|
|
178
|
+
// сервисные для smart-hash → семантический hash и INBOX_NEW-диффы не
|
|
179
|
+
// двигаются, эхо невозможно. Побочный выигрыш: source-фильтр кураторов
|
|
180
|
+
// INBOX_NEW впервые получает живой last_edited_by.
|
|
181
|
+
fmBlock = upsert(fmBlock, "last_edited_by", opts.agent);
|
|
182
|
+
fmBlock = upsert(fmBlock, "updated", opts.nowStamp);
|
|
168
183
|
fmBlock = setIfMissing(fmBlock, "title", basenameNoExt(opts.path));
|
|
169
184
|
fmBlock = setIfMissing(fmBlock, "status", taxonomy.statusTokens.draft);
|
|
170
185
|
fmBlock = setIfMissing(fmBlock, "created", opts.today);
|
|
@@ -495,7 +510,7 @@ export function processFile(filePath: string, opts: ProcessOptions): boolean {
|
|
|
495
510
|
|
|
496
511
|
let newFm: string | null;
|
|
497
512
|
if (zone === "inbox") {
|
|
498
|
-
newFm = fillInbox(fmBlock, { path: filePath, agent: opts.agent, today, ctx });
|
|
513
|
+
newFm = fillInbox(fmBlock, { path: filePath, agent: opts.agent, today, nowStamp, ctx });
|
|
499
514
|
} else if (zone === "permanent") {
|
|
500
515
|
newFm = fillPermanent(fmBlock, { agent: opts.agent, nowStamp, ctx });
|
|
501
516
|
} else {
|
package/src/fs-guard.ts
CHANGED
|
@@ -21,6 +21,10 @@
|
|
|
21
21
|
*
|
|
22
22
|
* Deliberately NOT guarded in v1: `mkdirSync` (creates empty dirs — no data
|
|
23
23
|
* loss / no content leak; the write that would fill them refuses) and reads.
|
|
24
|
+
* v2 (boris GO 11.06 evening) narrows both exceptions where they fed the
|
|
25
|
+
* residual lane: the segment rule refuses writes into harness trees outside
|
|
26
|
+
* the sandbox roots, and `sandboxBlocksProdRead` gates the prod reads that
|
|
27
|
+
* NAME live cwds (fleet.json, the provider slot).
|
|
24
28
|
*/
|
|
25
29
|
|
|
26
30
|
import fs from "node:fs";
|
|
@@ -56,7 +60,70 @@ export function isUnderProdAnchor(filePath: string): boolean {
|
|
|
56
60
|
);
|
|
57
61
|
}
|
|
58
62
|
|
|
59
|
-
/**
|
|
63
|
+
/**
|
|
64
|
+
* v2 segment rule (accepted by boris 11.06 evening — residual lane of the
|
|
65
|
+
* incident-№4 class): a `.iapeer`, `.claude` or `.codex` directory ANYWHERE
|
|
66
|
+
* on disk is a session surface of some peer — peer cwds live in
|
|
67
|
+
* `~/Projects/*`, `~/Peers/*`, OUTSIDE every prod anchor (probe 11.06:
|
|
68
|
+
* 24/29 fleet cwds; a fragments/surfaces write into a live tree passed the
|
|
69
|
+
* v1 anchor belt). Under the sandbox, a write that crosses a harness
|
|
70
|
+
* segment must sit inside a sandbox root: tmp, or an EXPLICIT
|
|
71
|
+
* IAPEER_ROOT / IAPEER_MEMORY_*_DIR override (an explicit override IS the
|
|
72
|
+
* authorisation — same semantics as the egress hub's `--iapeer-bin`
|
|
73
|
+
* allowance).
|
|
74
|
+
*/
|
|
75
|
+
const HARNESS_SEGMENTS = new Set([".iapeer", ".claude", ".codex"]);
|
|
76
|
+
|
|
77
|
+
function realpathOrSelf(p: string): string {
|
|
78
|
+
try {
|
|
79
|
+
return fs.realpathSync(p);
|
|
80
|
+
} catch {
|
|
81
|
+
return p;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Roots a sandboxed test may legitimately write harness trees under.
|
|
86
|
+
* `/tmp` is listed besides os.tmpdir(): on macOS os.tmpdir() is the
|
|
87
|
+
* per-process $TMPDIR (/var/folders/…) while fixtures commonly use a
|
|
88
|
+
* literal /tmp; the realpath twins cover the /var → /private/var symlink. */
|
|
89
|
+
function sandboxRoots(): string[] {
|
|
90
|
+
const roots = [os.tmpdir(), realpathOrSelf(os.tmpdir()), "/tmp", realpathOrSelf("/tmp")];
|
|
91
|
+
for (const key of [
|
|
92
|
+
"IAPEER_ROOT",
|
|
93
|
+
"IAPEER_MEMORY_STATE_DIR",
|
|
94
|
+
"IAPEER_MEMORY_CACHE_DIR",
|
|
95
|
+
"IAPEER_MEMORY_LOGS_DIR",
|
|
96
|
+
]) {
|
|
97
|
+
const v = process.env[key];
|
|
98
|
+
if (v) roots.push(v);
|
|
99
|
+
}
|
|
100
|
+
const cfg = process.env.IAPEER_MEMORY_CONFIG_FILE;
|
|
101
|
+
if (cfg) roots.push(path.dirname(path.resolve(cfg)));
|
|
102
|
+
return roots.map((r) => path.resolve(r));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** True when the path crosses a harness-surface segment OUTSIDE every
|
|
106
|
+
* sandbox root. Exported for tests (predicate-level, no write armed). */
|
|
107
|
+
export function isHarnessTreeOutsideSandbox(filePath: string): boolean {
|
|
108
|
+
const resolved = path.resolve(filePath);
|
|
109
|
+
if (!resolved.split(path.sep).some((seg) => HARNESS_SEGMENTS.has(seg))) return false;
|
|
110
|
+
const roots = sandboxRoots();
|
|
111
|
+
return !roots.some((r) => resolved === r || resolved.startsWith(r + path.sep));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Read-as-egress parity for prod STATE (the И4 precedent — live config.env
|
|
116
|
+
* skip in cli.ts): data that NAMES live peer cwds (fleet.json, the
|
|
117
|
+
* memory-provider slot) must not flow into a sandboxed process from the
|
|
118
|
+
* prod store — it is the SOURCE feeding the residual write lane above.
|
|
119
|
+
* Callers treat a blocked read as «file absent» and report honestly.
|
|
120
|
+
*/
|
|
121
|
+
export function sandboxBlocksProdRead(filePath: string): boolean {
|
|
122
|
+
return sandboxEnvArmed() && isUnderProdAnchor(filePath);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Throws under an armed sandbox env when the path targets a prod anchor
|
|
126
|
+
* (v1) or a harness tree outside the sandbox roots (v2 segment rule).
|
|
60
127
|
* The op name makes the refusal teach: WHICH write was stopped. */
|
|
61
128
|
export function assertSandboxWritablePath(filePath: string, op: string): void {
|
|
62
129
|
if (!sandboxEnvArmed()) return;
|
|
@@ -67,6 +134,14 @@ export function assertSandboxWritablePath(filePath: string, op: string): void {
|
|
|
67
134
|
"inside its own tmp root; if the path came from the env ladder, the ladder drifted.",
|
|
68
135
|
);
|
|
69
136
|
}
|
|
137
|
+
if (isHarnessTreeOutsideSandbox(filePath)) {
|
|
138
|
+
throw new Error(
|
|
139
|
+
`fs-guard: ${op} refused under the test sandbox — "${filePath}" crosses a .iapeer/.claude/.codex ` +
|
|
140
|
+
"segment OUTSIDE the sandbox roots (tmp, IAPEER_ROOT, IAPEER_MEMORY_*_DIR). Harness surfaces " +
|
|
141
|
+
"of live peers live in arbitrary cwds — a test may only touch such trees inside its own sandbox; " +
|
|
142
|
+
"if the cwd came from fleet.json/the registry, the sandbox read-gate drifted.",
|
|
143
|
+
);
|
|
144
|
+
}
|
|
70
145
|
}
|
|
71
146
|
|
|
72
147
|
export function guardedWriteFileSync(
|
package/src/index.ts
CHANGED
package/src/memoryd.ts
CHANGED
|
@@ -58,7 +58,7 @@ import {
|
|
|
58
58
|
type RenderContext,
|
|
59
59
|
} from "./index-render.js";
|
|
60
60
|
import { renderPeerFragment, type FragmentEnv } from "./context-render.js";
|
|
61
|
-
import { guardedWriteFileSync, guardedUnlinkSync } from "./fs-guard.js";
|
|
61
|
+
import { guardedWriteFileSync, guardedUnlinkSync, sandboxBlocksProdRead } from "./fs-guard.js";
|
|
62
62
|
import {
|
|
63
63
|
isSilentEdit,
|
|
64
64
|
readStampRecord,
|
|
@@ -659,11 +659,23 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
|
|
|
659
659
|
// ── per-peer fragments (docs/05: свежесть за секунды от FS-изменений) ──
|
|
660
660
|
const fragments = opts.fragments ?? null;
|
|
661
661
|
let fleetCache: { mtimeMs: number; peers: FleetPeer[] } | null = null;
|
|
662
|
+
let warnedFleetReadBlocked = false;
|
|
662
663
|
|
|
663
664
|
/** Fail-open fleet map reader with an mtime cache: missing/malformed file
|
|
664
665
|
* → empty fleet (rendering quietly off), never throws. */
|
|
665
666
|
function readFleetMap(): FleetPeer[] {
|
|
666
667
|
if (!fragments) return [];
|
|
668
|
+
// Read-as-egress (И4 parity): the prod fleet map NAMES live cwds — a
|
|
669
|
+
// sandboxed process must not learn them. Empty fleet = rendering off.
|
|
670
|
+
if (sandboxBlocksProdRead(fragments.fleetMapPath)) {
|
|
671
|
+
if (!warnedFleetReadBlocked) {
|
|
672
|
+
warnedFleetReadBlocked = true;
|
|
673
|
+
logger.warn(
|
|
674
|
+
`fragments: live fleet map skipped under the test sandbox (${fragments.fleetMapPath}) — set IAPEER_MEMORY_STATE_DIR/IAPEER_ROOT`,
|
|
675
|
+
);
|
|
676
|
+
}
|
|
677
|
+
return [];
|
|
678
|
+
}
|
|
667
679
|
try {
|
|
668
680
|
const st = fs.statSync(fragments.fleetMapPath);
|
|
669
681
|
if (fleetCache && fleetCache.mtimeMs === st.mtimeMs) return fleetCache.peers;
|
|
@@ -22,7 +22,11 @@
|
|
|
22
22
|
* inbox — UNCONDITIONAL: humanEditPass does not cover the agent inbox at
|
|
23
23
|
* all, human edits there are marginal, and the curator-masquerade
|
|
24
24
|
* (memoryd's 822-suppress) plus the silent author edit are both closed
|
|
25
|
-
* by one branch.
|
|
25
|
+
* by one branch. PRECONDITION (the 11.06 false-positive lesson, boris's
|
|
26
|
+
* Write+Edit repro): the rule is only a discriminator because fillInbox
|
|
27
|
+
* UPSERTS the stamp pair on every hook edit — before that fix the pair
|
|
28
|
+
* was identically (null, null) for author drafts, «stamp unmoved» held
|
|
29
|
+
* vacuously and every second legitimate edit re-stamped as unstamped.
|
|
26
30
|
*
|
|
27
31
|
* Attribution token: `unstamped` — NEUTRAL on purpose (the mechanism
|
|
28
32
|
* cannot tell a Bash agent from a human outside Obsidian; a false «agent»
|