@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer-memory-core",
3
- "version": "0.2.4",
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",
@@ -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
- /** Throws under an armed sandbox env when the path targets a prod anchor.
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
@@ -74,6 +74,8 @@ export { makeLogger, type Logger } from "./log.js";
74
74
  export {
75
75
  sandboxEnvArmed,
76
76
  isUnderProdAnchor,
77
+ isHarnessTreeOutsideSandbox,
78
+ sandboxBlocksProdRead,
77
79
  assertSandboxWritablePath,
78
80
  guardedWriteFileSync,
79
81
  guardedUnlinkSync,
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»