@agfpd/iapeer-memory 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.
@@ -40,6 +40,7 @@ import {
40
40
  type LocaleId,
41
41
  } from "@agfpd/iapeer-memory-core";
42
42
  import { installBinary } from "../binary.js";
43
+ import type { Egress } from "../egress.js";
43
44
  import { readFleetMap, writeFleetMap } from "../fleet.js";
44
45
  import { memoryPaths } from "../paths.js";
45
46
  import { readRolesManifest } from "../roles.js";
@@ -59,7 +60,7 @@ import {
59
60
  } from "../watcher.js";
60
61
  import { stopMemorydByPidFile } from "./uninstall.js";
61
62
 
62
- export function cmdUpdate(argv: string[]): number {
63
+ export function cmdUpdate(argv: string[], egress: Egress): number {
63
64
  let skipBinary = false;
64
65
  let iapeerBin: string | undefined;
65
66
  for (let i = 0; i < argv.length; i++) {
@@ -92,7 +93,7 @@ export function cmdUpdate(argv: string[]): number {
92
93
  if (skipBinary) {
93
94
  step("binary", "skipped (--skip-binary)");
94
95
  } else {
95
- const bin = installBinary({ outPath: paths.binaryPath });
96
+ const bin = installBinary(egress, { outPath: paths.binaryPath });
96
97
  step(
97
98
  "binary",
98
99
  bin.action === "compiled"
@@ -139,7 +140,7 @@ export function cmdUpdate(argv: string[]): number {
139
140
  // sweep below AND memoryd's fragment renderer, docs/05). BEFORE surfaces
140
141
  // and BEFORE the memoryd restart: both consume the fresh map.
141
142
  {
142
- const fleet = writeFleetMap({ fleetMapPath: paths.fleetMapPath, iapeerBin });
143
+ const fleet = writeFleetMap(egress, { fleetMapPath: paths.fleetMapPath, iapeerBin });
143
144
  step(
144
145
  "fleet",
145
146
  fleet.action === "written"
@@ -200,7 +201,7 @@ export function cmdUpdate(argv: string[]): number {
200
201
  false,
201
202
  );
202
203
  } else {
203
- const off = applyMemoryPlugin({ mode: "off" });
204
+ const off = applyMemoryPlugin(egress, { mode: "off" });
204
205
  step(
205
206
  "plugin-off",
206
207
  off.suppressed
@@ -247,11 +248,11 @@ export function cmdUpdate(argv: string[]): number {
247
248
  } catch {
248
249
  // unprovisioned env — registrations below still re-target
249
250
  }
250
- const w = registerWatcher({ launcherPath: paths.launcherPath });
251
- const s = registerTimer({
251
+ const w = registerWatcher(egress, { launcherPath: paths.launcherPath });
252
+ const s = registerTimer(egress, {
252
253
  message: sweepTimerMessage({ checkScriptPath: paths.checkScriptPath }),
253
254
  });
254
- const d = registerTimer({ message: dreamTimerMessage() });
255
+ const d = registerTimer(egress, { message: dreamTimerMessage() });
255
256
  const sandboxed = w.suppressed && s.suppressed && d.suppressed;
256
257
  step(
257
258
  "triggers",
@@ -282,7 +283,7 @@ export function cmdUpdate(argv: string[]): number {
282
283
  }
283
284
 
284
285
  // 9. memoryd managed restart (the watcher relaunches with the new binary)
285
- step("memoryd", `${stopMemorydByPidFile(paths.pidPath)} — the notifier watcher relaunches it with the new binary`);
286
+ step("memoryd", `${stopMemorydByPidFile(egress, paths.pidPath)} — the notifier watcher relaunches it with the new binary`);
286
287
 
287
288
  console.log(
288
289
  failures
@@ -25,6 +25,7 @@ import {
25
25
  renderDoctrine,
26
26
  renderedVersion,
27
27
  } from "@agfpd/iapeer-memory-core";
28
+ import type { Egress } from "../egress.js";
28
29
  import { readFleetMap, writeFleetMap } from "../fleet.js";
29
30
  import { memoryPaths, type MemoryPaths } from "../paths.js";
30
31
  import { readRolesManifest } from "../roles.js";
@@ -68,7 +69,7 @@ type RolesManifest = {
68
69
  roles: Array<{ role: string; peerCwd: string; template: string }>;
69
70
  };
70
71
 
71
- export function runVerify(opts: VerifyOptions = {}): CheckResult[] {
72
+ export function runVerify(egress: Egress, opts: VerifyOptions = {}): CheckResult[] {
72
73
  const repair = opts.repair ?? false;
73
74
  const paths = opts.paths ?? memoryPaths();
74
75
  const version = opts.version ?? packageVersion();
@@ -188,7 +189,7 @@ export function runVerify(opts: VerifyOptions = {}): CheckResult[] {
188
189
  if (!repair) {
189
190
  results.push({ name: "fleet-map", status: "fail", detail: problem });
190
191
  } else {
191
- const w = writeFleetMap({ fleetMapPath: paths.fleetMapPath, iapeerBin: opts.iapeerBin });
192
+ const w = writeFleetMap(egress, { fleetMapPath: paths.fleetMapPath, iapeerBin: opts.iapeerBin });
192
193
  results.push(
193
194
  w.action === "written"
194
195
  ? { name: "fleet-map", status: "repaired", detail: `${problem} — ${w.detail}` }
@@ -343,7 +344,7 @@ export function runVerify(opts: VerifyOptions = {}): CheckResult[] {
343
344
  launcherPath: paths.launcherPath,
344
345
  binaryPath: paths.binaryPath,
345
346
  });
346
- return registerWatcher({
347
+ return registerWatcher(egress, {
347
348
  launcherPath: paths.launcherPath,
348
349
  iapeerBin: opts.iapeerBin,
349
350
  });
@@ -373,7 +374,7 @@ export function runVerify(opts: VerifyOptions = {}): CheckResult[] {
373
374
  } catch {
374
375
  // unprovisioned env — the registration alone still heals the trigger
375
376
  }
376
- return registerTimer({
377
+ return registerTimer(egress, {
377
378
  message: sweepTimerMessage({ checkScriptPath: paths.checkScriptPath }),
378
379
  iapeerBin: opts.iapeerBin,
379
380
  });
@@ -386,7 +387,7 @@ export function runVerify(opts: VerifyOptions = {}): CheckResult[] {
386
387
  expect: (t) =>
387
388
  t.target !== "index" ? `target is ${t.target ?? "?"}, expected index` : null,
388
389
  repairSend: () =>
389
- registerTimer({ message: dreamTimerMessage(), iapeerBin: opts.iapeerBin }),
390
+ registerTimer(egress, { message: dreamTimerMessage(), iapeerBin: opts.iapeerBin }),
390
391
  },
391
392
  ];
392
393
  for (const c of checks) {
@@ -499,17 +500,23 @@ export function runVerify(opts: VerifyOptions = {}): CheckResult[] {
499
500
  return results;
500
501
  }
501
502
 
502
- export function cmdVerify(argv: string[]): number {
503
+ export function cmdVerify(argv: string[], egress: Egress): number {
503
504
  let repair = false;
504
- for (const a of argv) {
505
+ let iapeerBin: string | undefined;
506
+ for (let i = 0; i < argv.length; i++) {
507
+ const a = argv[i];
505
508
  if (a === "--repair") repair = true;
509
+ // Mirror of `update --iapeer-bin` (fb662ed): the hermetic CLI test class
510
+ // needs an explicitly named core binary — the egress explicit-bin
511
+ // allowance keys on it.
512
+ else if (a === "--iapeer-bin") iapeerBin = argv[++i];
506
513
  else {
507
514
  console.error(`iapeer-memory verify: unknown flag: ${a}`);
508
515
  return 2;
509
516
  }
510
517
  }
511
518
 
512
- const results = runVerify({ repair });
519
+ const results = runVerify(egress, { repair, iapeerBin });
513
520
  const width = Math.max(...results.map((r) => r.name.length));
514
521
  for (const r of results) {
515
522
  const mark =
package/src/egress.ts ADDED
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Egress hub — the ONE doorway from this package to the live host
3
+ * (docs/_planning/DENY_BY_DEFAULT_DESIGN.md §4, accepted by boris 11.06).
4
+ *
5
+ * Topology, not another fuse: four incidents («тест дотянулся до прода»)
6
+ * shared one root — outbound channels were allowed by default and refused
7
+ * only under a test flag each call site had to remember. This module
8
+ * inverts the default. Modules never spawn/kill/probe the host themselves
9
+ * and never PATH-resolve an external binary — they take an explicit
10
+ * {@link Egress} handle. The single live constructor, {@link liveEgress},
11
+ * is called from `cli.ts main()`; while a test-sandbox env is armed it
12
+ * hands back a REFUSING handle instead, and every channel reports refusal
13
+ * (callers map it to their SKIP semantics — the iapeer `skipped-sandbox`
14
+ * precedent). A module imported directly by a test has no doorway to the
15
+ * host by construction: there is nothing to forget.
16
+ *
17
+ * The grep invariant (И3) pins the topology: no `Bun.spawn*`/`process.kill`
18
+ * outside this file in src/.
19
+ *
20
+ * EXPLICIT ALLOWANCES of the refusing handle — each narrow, each here, all
21
+ * in one place (deny by DEFAULT, authorized consciously):
22
+ *
23
+ * 1. `explicitBin` spawns — argv[0] was NAMED by the operator/test via a
24
+ * flag (`--iapeer-bin <path>`). Same safety class as the sanctioned
25
+ * fake-bin test pattern: a consciously named binary is an authorization,
26
+ * a PATH-resolved default is not. (Closes the old env-juggling dance:
27
+ * fake-bin tests no longer clear the sandbox vars.)
28
+ * 2. Self-runtime spawns — argv[0] === process.execPath (bun): the binary
29
+ * compile (`bun build --compile` to a path-conventioned target) and the
30
+ * hook kick (self `verify --repair`). A child process re-enters through
31
+ * its OWN main() and inherits the sandbox env → its egress refuses too;
32
+ * nothing transitively reaches the host.
33
+ * 3. `ps` probes — read-only process-table lookup feeding the verified-kill
34
+ * guard (`pidLooksLikeOurs`). Refusing it would break the guard whose
35
+ * whole job is to make kill() safe.
36
+ * 4. Loopback fetch — status' own-daemon probes in sandboxed e2e. The
37
+ * host-daemon collision is closed by test port isolation (И3), not by
38
+ * refusing the probe. Non-loopback fetch refuses.
39
+ *
40
+ * kill() stays guarded by the verified-kill contract (owner verification
41
+ * before signalling — accepted at the P3c review), not by refusal: the pid
42
+ * PROVENANCE (sandbox pid file vs prod pid file) is the FS-belt's question
43
+ * (И2), and refusing kill would orphan sandbox daemons in e2e.
44
+ */
45
+
46
+ export type EgressSpawnResult = {
47
+ exitCode: number;
48
+ stdout: string;
49
+ stderr: string;
50
+ /** Set when the spawn itself failed (binary missing) or the egress
51
+ * refused the channel — exitCode is 127 by convention then. */
52
+ spawnError?: string;
53
+ /** True when the refusing egress (test sandbox) blocked the channel. */
54
+ refused?: boolean;
55
+ };
56
+
57
+ export type EgressSpawnOpts = {
58
+ timeoutMs?: number;
59
+ /** argv[0] was explicitly named by the operator/test (a `--*-bin` flag) —
60
+ * allowance 1 of the refusing handle. */
61
+ explicitBin?: boolean;
62
+ };
63
+
64
+ export interface Egress {
65
+ /** True for the refusing handle — callers may report a SKIP up front. */
66
+ readonly refused: boolean;
67
+ /** External binary, synchronous (iapeer, ps, openssl, security, codesign,
68
+ * bun). Never throws — a missing binary is a result, not a crash. */
69
+ spawnSync(argv: string[], opts?: EgressSpawnOpts): EgressSpawnResult;
70
+ /** Fire-and-forget detached spawn (hook kick → self `verify --repair`). */
71
+ spawnDetached(argv: string[]): { started: boolean; detail?: string };
72
+ /** Signal a live process. Never throws; `delivered: false` = process gone. */
73
+ kill(pid: number, signal: NodeJS.Signals): { delivered: boolean };
74
+ /** HTTP probe (status' loopback checks) — read-as-egress (П5). */
75
+ fetch(url: string, init?: RequestInit): Promise<Response>;
76
+ }
77
+
78
+ /** Default name of the ecosystem CLI — the ONE place it lives (П2: no
79
+ * scattered `?? "iapeer"` defaults; the danger moved into the handle). */
80
+ export const IAPEER_BIN = "iapeer";
81
+
82
+ /** The ONE definition of the sandbox-env check lives in core's fs-guard
83
+ * (the FS belt uses it too) — re-exported here for the constructor and
84
+ * its tests. */
85
+ import { sandboxEnvArmed } from "@agfpd/iapeer-memory-core";
86
+ export { sandboxEnvArmed };
87
+
88
+ function rawSpawnSync(argv: string[], opts?: EgressSpawnOpts): EgressSpawnResult {
89
+ try {
90
+ const proc = Bun.spawnSync(argv, {
91
+ stdout: "pipe",
92
+ stderr: "pipe",
93
+ ...(opts?.timeoutMs !== undefined ? { timeout: opts.timeoutMs } : {}),
94
+ });
95
+ return {
96
+ exitCode: proc.exitCode,
97
+ stdout: proc.stdout.toString(),
98
+ stderr: proc.stderr.toString(),
99
+ };
100
+ } catch (err) {
101
+ return { exitCode: 127, stdout: "", stderr: "", spawnError: String(err) };
102
+ }
103
+ }
104
+
105
+ function rawSpawnDetached(argv: string[]): { started: boolean; detail?: string } {
106
+ try {
107
+ const proc = Bun.spawn(argv, {
108
+ stdout: "ignore",
109
+ stderr: "ignore",
110
+ stdin: "ignore",
111
+ });
112
+ proc.unref();
113
+ return { started: true };
114
+ } catch (err) {
115
+ return { started: false, detail: String(err) };
116
+ }
117
+ }
118
+
119
+ function rawKill(pid: number, signal: NodeJS.Signals): { delivered: boolean } {
120
+ try {
121
+ process.kill(pid, signal);
122
+ return { delivered: true };
123
+ } catch {
124
+ return { delivered: false };
125
+ }
126
+ }
127
+
128
+ function isLoopback(url: string): boolean {
129
+ try {
130
+ const host = new URL(url).hostname;
131
+ return host === "127.0.0.1" || host === "localhost" || host === "::1";
132
+ } catch {
133
+ return false;
134
+ }
135
+ }
136
+
137
+ const REFUSAL = "egress refused (test sandbox) — pass a fake egress";
138
+
139
+ function refusedResult(): EgressSpawnResult {
140
+ return { exitCode: 127, stdout: "", stderr: "", spawnError: REFUSAL, refused: true };
141
+ }
142
+
143
+ function refusingEgress(): Egress {
144
+ return {
145
+ refused: true,
146
+ spawnSync(argv, opts) {
147
+ if (opts?.explicitBin) return rawSpawnSync(argv, opts); // allowance 1
148
+ if (argv[0] === process.execPath) return rawSpawnSync(argv, opts); // allowance 2
149
+ if (argv[0] === "ps") return rawSpawnSync(argv, opts); // allowance 3
150
+ return refusedResult();
151
+ },
152
+ spawnDetached(argv) {
153
+ if (argv[0] === process.execPath) return rawSpawnDetached(argv); // allowance 2
154
+ return { started: false, detail: REFUSAL };
155
+ },
156
+ kill: rawKill, // verified-kill contract guards this, not refusal (header)
157
+ fetch(url, init) {
158
+ if (isLoopback(url)) return fetch(url, init); // allowance 4
159
+ return Promise.reject(new Error(REFUSAL));
160
+ },
161
+ };
162
+ }
163
+
164
+ function realEgress(): Egress {
165
+ return {
166
+ refused: false,
167
+ spawnSync: rawSpawnSync,
168
+ spawnDetached: rawSpawnDetached,
169
+ kill: rawKill,
170
+ fetch: (url, init) => fetch(url, init),
171
+ };
172
+ }
173
+
174
+ /**
175
+ * The ONE live constructor — called from `cli.ts main()` only. Refuses
176
+ * (hands back the refusing handle) while a test-sandbox env is armed; the
177
+ * decision is taken ONCE here, never re-checked at call sites.
178
+ */
179
+ export function liveEgress(): Egress {
180
+ return sandboxEnvArmed() ? refusingEgress() : realEgress();
181
+ }
package/src/fleet.ts CHANGED
@@ -7,18 +7,21 @@
7
7
  * new peer wakes → SessionStart kick → `verify --repair` re-writes the
8
8
  * map → memoryd renders the newcomer's fragment within a tick.
9
9
  *
10
- * TEST FUSE (incident 11.06, the FOURTH of its class — first FILE-path one):
11
- * `iapeer list` is read-only, but its RESULT is the target list of the
10
+ * READ-AS-EGRESS (incident 11.06, the FOURTH of its class — first FILE-path
11
+ * one): `iapeer list` is read-only, but its RESULT is the target list of the
12
12
  * surfaces sweep — a sandboxed `verify --repair` with no fleet map repaired
13
13
  * the map from the LIVE registry and then swept the LIVE peers' cwds with
14
14
  * direct surfaces (the send-fuse never saw it: no IAP send involved).
15
- * Querying the live registry from a test IS the leak — so the default
16
- * binary is refused under the sandbox fuse; tests that need a fleet pass a
17
- * fake `iapeerBin` (as before) or write the map file directly.
15
+ * Querying the live registry from a test IS the leak — the query now goes
16
+ * through the egress handle (deny-by-default §4 П5): a refusing handle
17
+ * blocks the default binary; tests pass a fake `iapeerBin` (explicit-bin
18
+ * allowance) or write the map file directly.
18
19
  */
19
20
 
20
21
  import fs from "node:fs";
21
22
  import path from "node:path";
23
+ import { IAPEER_BIN, type Egress } from "./egress.js";
24
+ import { guardedWriteFileSync } from "@agfpd/iapeer-memory-core";
22
25
 
23
26
  export type FleetMapResult = {
24
27
  action: "written" | "failed";
@@ -67,42 +70,37 @@ export function readFleetMap(fleetMapPath: string): FleetPeer[] | null {
67
70
  }
68
71
  }
69
72
 
70
- export function writeFleetMap(opts: {
71
- fleetMapPath: string;
72
- iapeerBin?: string;
73
- /** Injectable for tests. */
74
- nowIso?: string;
75
- }): FleetMapResult {
76
- if (
77
- opts.iapeerBin === undefined &&
78
- (process.env.IAPEER_MEMORY_SUPPRESS_IAP_SEND === "1" ||
79
- process.env.IAPEER_TEST_SANDBOX === "1")
80
- ) {
73
+ export function writeFleetMap(
74
+ egress: Egress,
75
+ opts: {
76
+ fleetMapPath: string;
77
+ iapeerBin?: string;
78
+ /** Injectable for tests. */
79
+ nowIso?: string;
80
+ },
81
+ ): FleetMapResult {
82
+ const bin = opts.iapeerBin ?? IAPEER_BIN;
83
+ const proc = egress.spawnSync([bin, "list", "--json"], {
84
+ explicitBin: opts.iapeerBin !== undefined,
85
+ });
86
+ if (proc.refused) {
81
87
  return {
82
88
  action: "failed",
83
89
  count: 0,
84
90
  detail: "live-registry query suppressed (test sandbox) — pass a fake iapeerBin",
85
91
  };
86
92
  }
87
- const bin = opts.iapeerBin ?? "iapeer";
88
- let stdout: string;
89
- try {
90
- const proc = Bun.spawnSync([bin, "list", "--json"], {
91
- stdout: "pipe",
92
- stderr: "pipe",
93
- });
94
- if (proc.exitCode !== 0) {
95
- return {
96
- action: "failed",
97
- count: 0,
98
- detail:
99
- (proc.stderr.toString().trim() || `iapeer list exited ${proc.exitCode}`).slice(0, 160),
100
- };
101
- }
102
- stdout = proc.stdout.toString();
103
- } catch (err) {
104
- return { action: "failed", count: 0, detail: `${bin} unavailable: ${String(err)}` };
93
+ if (proc.spawnError) {
94
+ return { action: "failed", count: 0, detail: `${bin} unavailable: ${proc.spawnError}` };
95
+ }
96
+ if (proc.exitCode !== 0) {
97
+ return {
98
+ action: "failed",
99
+ count: 0,
100
+ detail: (proc.stderr.trim() || `iapeer list exited ${proc.exitCode}`).slice(0, 160),
101
+ };
105
102
  }
103
+ const stdout = proc.stdout;
106
104
 
107
105
  let listed: ListedPeer[];
108
106
  try {
@@ -140,7 +138,7 @@ export function writeFleetMap(opts: {
140
138
  ) + "\n";
141
139
  fs.mkdirSync(path.dirname(opts.fleetMapPath), { recursive: true });
142
140
  const tmp = `${opts.fleetMapPath}.tmp`;
143
- fs.writeFileSync(tmp, body, "utf-8");
141
+ guardedWriteFileSync(tmp, body, "utf-8");
144
142
  fs.renameSync(tmp, opts.fleetMapPath); // atomic — memoryd may race a read
145
143
  return {
146
144
  action: "written",
package/src/provision.ts CHANGED
@@ -13,6 +13,7 @@
13
13
  import fs from "node:fs";
14
14
  import path from "node:path";
15
15
  import type { LocaleId, TaxonomyPreset } from "@agfpd/iapeer-memory-core";
16
+ import { guardedWriteFileSync } from "@agfpd/iapeer-memory-core";
16
17
 
17
18
  export type ProvisionResult = {
18
19
  createdDirs: string[];
@@ -129,7 +130,7 @@ export function provisionVault(opts: {
129
130
  continue;
130
131
  }
131
132
  fs.mkdirSync(path.dirname(file), { recursive: true });
132
- fs.writeFileSync(file, content, "utf-8");
133
+ guardedWriteFileSync(file, content, "utf-8");
133
134
  createdFiles.push(rel);
134
135
  }
135
136
 
@@ -183,6 +184,6 @@ export function writeDefaultConfig(
183
184
  ): "written" | "exists" {
184
185
  if (fs.existsSync(opts.configFile)) return "exists";
185
186
  fs.mkdirSync(path.dirname(opts.configFile), { recursive: true });
186
- fs.writeFileSync(opts.configFile, defaultConfigContent(opts), "utf-8");
187
+ guardedWriteFileSync(opts.configFile, defaultConfigContent(opts), "utf-8");
187
188
  return "written";
188
189
  }
package/src/roles.ts CHANGED
@@ -12,6 +12,7 @@
12
12
 
13
13
  import fs from "node:fs";
14
14
  import path from "node:path";
15
+ import { guardedWriteFileSync } from "@agfpd/iapeer-memory-core";
15
16
 
16
17
  export type RoleEntry = { role: string; peerCwd: string; template: string };
17
18
 
@@ -23,7 +24,7 @@ export function writeRolesManifest(opts: {
23
24
  }): void {
24
25
  fs.mkdirSync(path.dirname(opts.rolesManifestPath), { recursive: true });
25
26
  const tmp = `${opts.rolesManifestPath}.tmp`;
26
- fs.writeFileSync(
27
+ guardedWriteFileSync(
27
28
  tmp,
28
29
  JSON.stringify({ roles: opts.roles } satisfies RolesManifest, null, 2) + "\n",
29
30
  "utf-8",
package/src/signing.ts CHANGED
@@ -38,6 +38,8 @@
38
38
  import fs from "node:fs";
39
39
  import os from "node:os";
40
40
  import path from "node:path";
41
+ import type { Egress } from "./egress.js";
42
+ import { guardedRmSync } from "@agfpd/iapeer-memory-core";
41
43
 
42
44
  export const SIGNING_IDENTITY_CN = "iapeer Local Codesign";
43
45
  export const SIGNING_IDENTIFIER = "com.agfpd.iapeer-memory";
@@ -53,22 +55,16 @@ export type SigningRunner = (
53
55
  args: string[],
54
56
  ) => { status: number | null; stdout: string; stderr: string };
55
57
 
56
- const defaultRunner: SigningRunner = (cmd, args) => {
57
- try {
58
- const r = Bun.spawnSync([cmd, ...args], {
59
- stdout: "pipe",
60
- stderr: "pipe",
61
- timeout: 90_000,
62
- });
63
- return {
64
- status: r.exitCode,
65
- stdout: r.stdout.toString(),
66
- stderr: r.stderr.toString(),
67
- };
68
- } catch (err) {
69
- return { status: null, stdout: "", stderr: String(err) };
70
- }
71
- };
58
+ /** The keychain trio (security/openssl/codesign) goes out through the
59
+ * egress handle — 90 s ceiling so an unanswered keychain prompt can't
60
+ * wedge an unattended update. */
61
+ function egressRunner(egress: Egress): SigningRunner {
62
+ return (cmd, args) => {
63
+ const r = egress.spawnSync([cmd, ...args], { timeoutMs: 90_000 });
64
+ if (r.spawnError) return { status: null, stdout: "", stderr: r.spawnError };
65
+ return { status: r.exitCode, stdout: r.stdout, stderr: r.stderr };
66
+ };
67
+ }
72
68
 
73
69
  export type SigningOutcome = {
74
70
  state:
@@ -120,7 +116,7 @@ function createIdentity(run: SigningRunner): { ok: boolean; detail?: string } {
120
116
  return { ok: true };
121
117
  } finally {
122
118
  try {
123
- fs.rmSync(dir, { recursive: true, force: true });
119
+ guardedRmSync(dir, { recursive: true, force: true });
124
120
  } catch {
125
121
  // best-effort cleanup of the throwaway key material
126
122
  }
@@ -134,14 +130,14 @@ function createIdentity(run: SigningRunner): { ok: boolean; detail?: string } {
134
130
  * grant) stays constant while the bytes change.
135
131
  */
136
132
  export function signInstalledBinary(
133
+ egress: Egress,
137
134
  binPath: string,
138
- run: SigningRunner = defaultRunner,
135
+ run: SigningRunner = egressRunner(egress),
139
136
  ): SigningOutcome {
140
- // Both test belts (keychain is HOST-GLOBAL, same class as live sends).
141
- if (
142
- process.env.IAPEER_TEST_SANDBOX === "1" ||
143
- process.env.IAPEER_MEMORY_SUPPRESS_IAP_SEND === "1"
144
- ) {
137
+ // The keychain is HOST-GLOBAL same class as live sends. The sandbox
138
+ // decision lives in the egress constructor (deny-by-default §4); a
139
+ // refusing handle maps to the historical skipped-sandbox outcome.
140
+ if (egress.refused) {
145
141
  return { state: "skipped-sandbox", detail: "test sandbox — not touching the real keychain" };
146
142
  }
147
143
  let created = false;
package/src/slot.ts CHANGED
@@ -31,6 +31,8 @@
31
31
 
32
32
  import fs from "node:fs";
33
33
  import path from "node:path";
34
+ import { IAPEER_BIN, type Egress } from "./egress.js";
35
+ import { guardedWriteFileSync, guardedUnlinkSync } from "@agfpd/iapeer-memory-core";
34
36
 
35
37
  export const SLOT_PROVIDER = "iapeer-memory";
36
38
  export const SLOT_PACKAGE = "@agfpd/iapeer-memory";
@@ -147,7 +149,7 @@ export function writeSlot(opts: {
147
149
  };
148
150
  fs.mkdirSync(path.dirname(opts.slotPath), { recursive: true });
149
151
  const tmp = `${opts.slotPath}.tmp`;
150
- fs.writeFileSync(tmp, JSON.stringify(slot, null, 2) + "\n", "utf-8");
152
+ guardedWriteFileSync(tmp, JSON.stringify(slot, null, 2) + "\n", "utf-8");
151
153
  fs.renameSync(tmp, opts.slotPath);
152
154
  return { action: "written", existing };
153
155
  }
@@ -159,7 +161,7 @@ export function removeSlot(slotPath: string): SlotRemoveResult {
159
161
  const existing = readSlot(slotPath);
160
162
  if (!existing) return "absent";
161
163
  if (existing.provider !== SLOT_PROVIDER) return "refused-foreign";
162
- fs.unlinkSync(slotPath);
164
+ guardedUnlinkSync(slotPath);
163
165
  return "removed";
164
166
  }
165
167
 
@@ -179,40 +181,38 @@ export type MemoryPluginApplyResult = {
179
181
  * and `off` runs BEFORE removeSlot (agreed order, auto-removal: a dead
180
182
  * provider's plugin must not keep injecting).
181
183
  *
182
- * Hard fuse first (same class as iapSend, incident 10.06): `on --all`
183
- * mutates the HOST fleet — no sandbox env contains it, tests must never
184
- * reach the live core. Both belts honoured.
184
+ * The hard fuse of incident 10.06 (`on --all` mutates the HOST fleet — no
185
+ * sandbox env contains it) lives in the egress constructor now
186
+ * (deny-by-default §4): a refusing handle blocks the spawn here.
185
187
  */
186
- export function applyMemoryPlugin(opts: {
187
- mode: "on" | "off";
188
- iapeerBin?: string;
189
- }): MemoryPluginApplyResult {
190
- if (
191
- process.env.IAPEER_MEMORY_SUPPRESS_IAP_SEND === "1" ||
192
- process.env.IAPEER_TEST_SANDBOX === "1"
193
- ) {
188
+ export function applyMemoryPlugin(
189
+ egress: Egress,
190
+ opts: {
191
+ mode: "on" | "off";
192
+ iapeerBin?: string;
193
+ },
194
+ ): MemoryPluginApplyResult {
195
+ const bin = opts.iapeerBin ?? IAPEER_BIN;
196
+ const proc = egress.spawnSync([bin, "memory-plugin", opts.mode, "--all"], {
197
+ explicitBin: opts.iapeerBin !== undefined,
198
+ });
199
+ if (proc.refused) {
194
200
  return {
195
201
  ok: false,
196
202
  suppressed: true,
197
203
  detail: "memory-plugin call suppressed (test sandbox)",
198
204
  };
199
205
  }
200
- const bin = opts.iapeerBin ?? "iapeer";
201
- try {
202
- const proc = Bun.spawnSync([bin, "memory-plugin", opts.mode, "--all"], {
203
- stdout: "pipe",
204
- stderr: "pipe",
205
- });
206
- if (proc.exitCode !== 0) {
207
- return {
208
- ok: false,
209
- detail:
210
- (proc.stderr.toString().trim() || proc.stdout.toString().trim() || "").slice(0, 200) ||
211
- `iapeer memory-plugin exited ${proc.exitCode}`,
212
- };
213
- }
214
- return { ok: true, detail: proc.stdout.toString().trim() };
215
- } catch (err) {
216
- return { ok: false, detail: `${bin} unavailable: ${String(err)}` };
206
+ if (proc.spawnError) {
207
+ return { ok: false, detail: `${bin} unavailable: ${proc.spawnError}` };
208
+ }
209
+ if (proc.exitCode !== 0) {
210
+ return {
211
+ ok: false,
212
+ detail:
213
+ (proc.stderr.trim() || proc.stdout.trim() || "").slice(0, 200) ||
214
+ `iapeer memory-plugin exited ${proc.exitCode}`,
215
+ };
217
216
  }
217
+ return { ok: true, detail: proc.stdout.trim() };
218
218
  }