@agfpd/iapeer-memory-core 0.2.0 → 0.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer-memory-core",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
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",
@@ -27,6 +27,7 @@
27
27
  import fs from "node:fs";
28
28
  import path from "node:path";
29
29
  import crypto from "node:crypto";
30
+ import { guardedWriteFileSync, guardedUnlinkSync } from "./fs-guard.js";
30
31
 
31
32
  export const FRAGMENT_STEM = "iapeer-memory.md";
32
33
 
@@ -140,11 +141,11 @@ export function writeFragmentAtomic(
140
141
  `.${stem}.${crypto.randomBytes(6).toString("hex")}.tmp`,
141
142
  );
142
143
  try {
143
- fs.writeFileSync(tmp, text, "utf-8");
144
+ guardedWriteFileSync(tmp, text, "utf-8");
144
145
  fs.renameSync(tmp, target);
145
146
  } catch (err) {
146
147
  try {
147
- if (fs.existsSync(tmp)) fs.unlinkSync(tmp);
148
+ if (fs.existsSync(tmp)) guardedUnlinkSync(tmp);
148
149
  } catch {
149
150
  // best effort
150
151
  }
@@ -38,6 +38,7 @@ import path from "node:path";
38
38
  import crypto from "node:crypto";
39
39
  import type { TaxonomyPreset } from "./taxonomy.js";
40
40
  import { DEFAULT_CURATOR_SET } from "./taxonomy.js";
41
+ import { guardedWriteFileSync, guardedUnlinkSync } from "./fs-guard.js";
41
42
 
42
43
  const FRONTMATTER_RE = /^---[^\S\n]*\n([\s\S]*?\n)---[^\S\n]*(?:\n|$)/;
43
44
 
@@ -417,11 +418,11 @@ export function atomicWrite(filePath: string, content: string): void {
417
418
  `.fm-${crypto.randomBytes(6).toString("hex")}.tmp`,
418
419
  );
419
420
  try {
420
- fs.writeFileSync(tmp, content, "utf-8");
421
+ guardedWriteFileSync(tmp, content, "utf-8");
421
422
  fs.renameSync(tmp, filePath);
422
423
  } catch (err) {
423
424
  try {
424
- if (fs.existsSync(tmp)) fs.unlinkSync(tmp);
425
+ if (fs.existsSync(tmp)) guardedUnlinkSync(tmp);
425
426
  } catch {
426
427
  // best effort
427
428
  }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * FS belt — the file-system half of deny-by-default
3
+ * (iapeer-memory docs/_planning/DENY_BY_DEFAULT_DESIGN.md §4 П4, accepted
4
+ * by boris 11.06).
5
+ *
6
+ * Incident class №2 («лестница рассинхронилась»): the db path ladder lived
7
+ * in TWO copies (core config + package paths) and a drift wrote a sandbox
8
+ * SQLite into the PROD `~/.iapeer/cache`. Path conventions cannot be the
9
+ * only belt — so every raw write/unlink/rm in BOTH src trees goes through
10
+ * the wrappers below, and under an armed test-sandbox env they REFUSE any
11
+ * path under a production anchor, no matter what the ladder computed:
12
+ *
13
+ * ~/.iapeer — ecosystem state/cache/config of the live host
14
+ * ~/.claude, ~/.codex — harness config surfaces of the live fleet
15
+ * ~/Library/Mobile Documents — the iCloud root (the live vault lives there)
16
+ *
17
+ * Outside the sandbox env the wrappers are pass-through: live init/update/
18
+ * migrate write exactly where they always did. The grep invariant (И3) pins
19
+ * the funnel: no raw `fs.writeFileSync`/`Bun.write`/`fs.rmSync`/
20
+ * `fs.unlinkSync` outside this file in either src tree.
21
+ *
22
+ * Deliberately NOT guarded in v1: `mkdirSync` (creates empty dirs — no data
23
+ * loss / no content leak; the write that would fill them refuses) and reads.
24
+ */
25
+
26
+ import fs from "node:fs";
27
+ import os from "node:os";
28
+ import path from "node:path";
29
+
30
+ /** Both test belts — the ecosystem-wide var survives generic
31
+ * IAPEER_MEMORY_* env-stripping (incident 10.06 №3). The ONE definition
32
+ * for both packages: the package egress hub imports this. */
33
+ export function sandboxEnvArmed(): boolean {
34
+ return (
35
+ process.env.IAPEER_MEMORY_SUPPRESS_IAP_SEND === "1" ||
36
+ process.env.IAPEER_TEST_SANDBOX === "1"
37
+ );
38
+ }
39
+
40
+ function prodAnchors(): string[] {
41
+ const home = os.homedir();
42
+ return [
43
+ path.join(home, ".iapeer"),
44
+ path.join(home, ".claude"),
45
+ path.join(home, ".codex"),
46
+ path.join(home, "Library", "Mobile Documents"),
47
+ ];
48
+ }
49
+
50
+ /** True when the resolved path sits under a production anchor. Exported for
51
+ * tests: the predicate is checkable without arming any write. */
52
+ export function isUnderProdAnchor(filePath: string): boolean {
53
+ const resolved = path.resolve(filePath);
54
+ return prodAnchors().some(
55
+ (a) => resolved === a || resolved.startsWith(a + path.sep),
56
+ );
57
+ }
58
+
59
+ /** Throws under an armed sandbox env when the path targets a prod anchor.
60
+ * The op name makes the refusal teach: WHICH write was stopped. */
61
+ export function assertSandboxWritablePath(filePath: string, op: string): void {
62
+ if (!sandboxEnvArmed()) return;
63
+ if (isUnderProdAnchor(filePath)) {
64
+ throw new Error(
65
+ `fs-guard: ${op} refused under the test sandbox — "${filePath}" is under a production anchor ` +
66
+ "(~/.iapeer, ~/.claude, ~/.codex or the iCloud root). A test must write " +
67
+ "inside its own tmp root; if the path came from the env ladder, the ladder drifted.",
68
+ );
69
+ }
70
+ }
71
+
72
+ export function guardedWriteFileSync(
73
+ filePath: string,
74
+ data: string | NodeJS.ArrayBufferView,
75
+ options?: Parameters<typeof fs.writeFileSync>[2],
76
+ ): void {
77
+ assertSandboxWritablePath(filePath, "write");
78
+ fs.writeFileSync(filePath, data, options);
79
+ }
80
+
81
+ export function guardedUnlinkSync(filePath: string): void {
82
+ assertSandboxWritablePath(filePath, "unlink");
83
+ fs.unlinkSync(filePath);
84
+ }
85
+
86
+ export function guardedRmSync(
87
+ filePath: string,
88
+ options?: Parameters<typeof fs.rmSync>[1],
89
+ ): void {
90
+ assertSandboxWritablePath(filePath, "rm");
91
+ fs.rmSync(filePath, options);
92
+ }
@@ -28,6 +28,7 @@ import path from "node:path";
28
28
  import crypto from "node:crypto";
29
29
  import type { RankingConfig, TaxonomyPreset } from "./taxonomy.js";
30
30
  import { statusGroup as taxonomyStatusGroup } from "./taxonomy.js";
31
+ import { guardedWriteFileSync, guardedUnlinkSync } from "./fs-guard.js";
31
32
 
32
33
  const WIKILINK_RE = /\[\[([^\]|#]+)/g;
33
34
  const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---\n/;
@@ -821,11 +822,11 @@ export function atomicWrite(filePath: string, content: string): void {
821
822
  const dir = path.dirname(filePath) || ".";
822
823
  const tmp = path.join(dir, `.vault-index-${crypto.randomBytes(6).toString("hex")}.tmp`);
823
824
  try {
824
- fs.writeFileSync(tmp, content, "utf-8");
825
+ guardedWriteFileSync(tmp, content, "utf-8");
825
826
  fs.renameSync(tmp, filePath);
826
827
  } catch (err) {
827
828
  try {
828
- if (fs.existsSync(tmp)) fs.unlinkSync(tmp);
829
+ if (fs.existsSync(tmp)) guardedUnlinkSync(tmp);
829
830
  } catch {
830
831
  // best effort
831
832
  }
package/src/index.ts CHANGED
@@ -70,3 +70,12 @@ export { prepareSqliteRuntime, type SqliteRuntime } from "./sqlite-loader.js";
70
70
 
71
71
  // logging
72
72
  export { makeLogger, type Logger } from "./log.js";
73
+
74
+ export {
75
+ sandboxEnvArmed,
76
+ isUnderProdAnchor,
77
+ assertSandboxWritablePath,
78
+ guardedWriteFileSync,
79
+ guardedUnlinkSync,
80
+ guardedRmSync,
81
+ } from "./fs-guard.js";
package/src/memoryd.ts CHANGED
@@ -58,6 +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
62
 
62
63
  // ── identity ────────────────────────────────────────────────────────────────
63
64
 
@@ -442,7 +443,7 @@ export function persistBatchState(
442
443
  ): void {
443
444
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
444
445
  const tmp = `${filePath}.tmp`;
445
- fs.writeFileSync(
446
+ guardedWriteFileSync(
446
447
  tmp,
447
448
  JSON.stringify({
448
449
  inbox: Object.fromEntries(state.inbox),
@@ -470,7 +471,7 @@ export function loadHashState(filePath: string): Map<string, string> {
470
471
  export function persistHashState(filePath: string, map: Map<string, string>): void {
471
472
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
472
473
  const tmp = `${filePath}.tmp`;
473
- fs.writeFileSync(tmp, JSON.stringify(Object.fromEntries(map)), "utf-8");
474
+ guardedWriteFileSync(tmp, JSON.stringify(Object.fromEntries(map)), "utf-8");
474
475
  fs.renameSync(tmp, filePath);
475
476
  }
476
477
 
@@ -729,7 +730,7 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
729
730
  if (decision.action !== "write") return;
730
731
  fs.mkdirSync(path.dirname(tagsMirrorPath), { recursive: true });
731
732
  const tmp = `${tagsMirrorPath}.tmp`;
732
- fs.writeFileSync(tmp, srcContent!, "utf-8");
733
+ guardedWriteFileSync(tmp, srcContent!, "utf-8");
733
734
  fs.renameSync(tmp, tagsMirrorPath);
734
735
  logger.info(`tags mirror updated (${decision.reason})`);
735
736
  }
@@ -776,13 +777,13 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
776
777
  }
777
778
  const tmp = `${filePath}.memoryd.tmp`;
778
779
  try {
779
- fs.writeFileSync(tmp, decision.newContent, "utf-8");
780
+ guardedWriteFileSync(tmp, decision.newContent, "utf-8");
780
781
  fs.renameSync(tmp, filePath);
781
782
  lastSeenHashes.set(filePath, decision.recordHash);
782
783
  logger.info(`human-edit ${decision.reason}: ${path.relative(config.vaultPath, filePath)}`);
783
784
  } catch (err) {
784
785
  try {
785
- fs.unlinkSync(tmp);
786
+ guardedUnlinkSync(tmp);
786
787
  } catch {
787
788
  // best effort
788
789
  }
@@ -905,7 +906,7 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
905
906
  function touchHeartbeat(): void {
906
907
  try {
907
908
  fs.mkdirSync(path.dirname(heartbeatPath), { recursive: true });
908
- fs.writeFileSync(heartbeatPath, `${new Date().toISOString()} ${os.hostname()}\n`);
909
+ guardedWriteFileSync(heartbeatPath, `${new Date().toISOString()} ${os.hostname()}\n`);
909
910
  } catch (err) {
910
911
  logger.error(`heartbeat write failed: ${String(err)}`);
911
912
  }
@@ -1020,7 +1021,7 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
1020
1021
  persistQuiet();
1021
1022
  if (mcp) await mcp.close();
1022
1023
  try {
1023
- fs.unlinkSync(heartbeatPath);
1024
+ guardedUnlinkSync(heartbeatPath);
1024
1025
  } catch {
1025
1026
  // best effort
1026
1027
  }
@@ -32,6 +32,7 @@ import fs from "node:fs";
32
32
  import path from "node:path";
33
33
  import type { TaxonomyPreset } from "./taxonomy.js";
34
34
  import { yamlSafeScalar } from "./fm-update.js";
35
+ import { guardedWriteFileSync, guardedUnlinkSync } from "./fs-guard.js";
35
36
 
36
37
  /** Source files that are backed up but never copied into the vault. */
37
38
  export const SKIP_FILES: ReadonlySet<string> = new Set(["MEMORY.md"]);
@@ -217,7 +218,7 @@ export function applyMigration(opts: {
217
218
  // 2a. Non-md and SKIP_FILES: backup-only, removed from the source.
218
219
  if (!name.endsWith(".md") || SKIP_FILES.has(name)) {
219
220
  try {
220
- fs.unlinkSync(srcPath);
221
+ guardedUnlinkSync(srcPath);
221
222
  } catch (err) {
222
223
  errors.push(`${name}: unlink after backup failed — ${String(err)}`);
223
224
  }
@@ -229,7 +230,7 @@ export function applyMigration(opts: {
229
230
  if (fs.existsSync(targetFile)) {
230
231
  skipped.push(name);
231
232
  try {
232
- fs.unlinkSync(srcPath);
233
+ guardedUnlinkSync(srcPath);
233
234
  } catch (err) {
234
235
  errors.push(`${name}: unlink (already migrated) failed — ${String(err)}`);
235
236
  }
@@ -261,7 +262,7 @@ export function applyMigration(opts: {
261
262
 
262
263
  try {
263
264
  const tmp = `${targetFile}.tmp`;
264
- fs.writeFileSync(tmp, newText, "utf-8");
265
+ guardedWriteFileSync(tmp, newText, "utf-8");
265
266
  fs.renameSync(tmp, targetFile);
266
267
  } catch (err) {
267
268
  errors.push(`${name}: write failed — ${String(err)}`);
@@ -269,7 +270,7 @@ export function applyMigration(opts: {
269
270
  }
270
271
 
271
272
  try {
272
- fs.unlinkSync(srcPath);
273
+ guardedUnlinkSync(srcPath);
273
274
  migrated.push(name);
274
275
  } catch (err) {
275
276
  errors.push(`${name}: written to target but source unlink failed — ${String(err)}`);
@@ -24,6 +24,7 @@
24
24
  import fs from "node:fs";
25
25
  import path from "node:path";
26
26
  import crypto from "node:crypto";
27
+ import { guardedWriteFileSync, guardedUnlinkSync } from "./fs-guard.js";
27
28
 
28
29
  /** `<!-- iapeer-memory doctrine v<version> -->` — machine-checkable. */
29
30
  export function versionMarker(version: string): string {
@@ -93,11 +94,11 @@ export function renderDoctrine(opts: {
93
94
  `.IAPEER.md.${crypto.randomBytes(6).toString("hex")}.tmp`,
94
95
  );
95
96
  try {
96
- fs.writeFileSync(tmp, rendered, "utf-8");
97
+ guardedWriteFileSync(tmp, rendered, "utf-8");
97
98
  fs.renameSync(tmp, target);
98
99
  } catch (err) {
99
100
  try {
100
- if (fs.existsSync(tmp)) fs.unlinkSync(tmp);
101
+ if (fs.existsSync(tmp)) guardedUnlinkSync(tmp);
101
102
  } catch {
102
103
  // best effort
103
104
  }