@ijfw/memory-server 1.4.0 → 1.4.3

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/README.md ADDED
@@ -0,0 +1,67 @@
1
+ # @ijfw/memory-server
2
+
3
+ IJFW MCP memory server — the runtime backend that powers memory, metrics,
4
+ update checks, and the extension sandbox for all supported AI coding agents.
5
+
6
+ ## Install
7
+
8
+ This package is installed automatically by `@ijfw/install`. You generally
9
+ do not need to install it manually.
10
+
11
+ ```bash
12
+ npm install -g @ijfw/memory-server
13
+ ```
14
+
15
+ ## Extension CLI
16
+
17
+ IJFW ships a full extension system for installing and sandboxing third-party skills.
18
+
19
+ ```bash
20
+ # Publisher key management
21
+ ijfw extension keygen <author> # Generate an Ed25519 publisher keypair
22
+ ijfw extension trust <keyId> <publicKey> # Add a publisher to your trusted store
23
+ ijfw extension trust-registry [<url>] # Pull + apply the hosted publisher registry
24
+ ijfw extension untrust <keyId> # Remove a publisher from your trusted store
25
+ ijfw extension trusted # List all trusted publishers
26
+
27
+ # Extension lifecycle
28
+ ijfw extension add <source> [flags] # Install an extension (npm name, local path, or https git URL)
29
+ --allow-unsigned # Accept extensions with no signature
30
+ --accept-untrusted # Accept extensions signed by an untrusted publisher (prompts on TTY)
31
+ --activate # Auto-activate after install
32
+ ijfw extension activate <name> # Activate an installed extension (enforces declared permissions)
33
+ ijfw extension deactivate # Deactivate the current extension
34
+
35
+ # Admin / registry maintainer (rare)
36
+ ijfw extension rotate-keys <oldKeyId> <newKeyId> # Produce a signed rotation token
37
+ ijfw extension keygen-meta <author> # Generate the registry meta-keypair
38
+ ijfw extension sign-registry <path> # Sign a registry JSON file in place
39
+ ijfw extension verify-registry <path> # Verify a registry JSON signature
40
+ ijfw extension registry-status # Show registry cache age + signature status
41
+ ```
42
+
43
+ The rotation flow and registry maintainer docs live in `docs/REGISTRY-MAINTAINER.md`.
44
+
45
+ ## MCP Tools
46
+
47
+ | Tool | Description |
48
+ |------|-------------|
49
+ | `ijfw_memory_store` | Store a memory entry |
50
+ | `ijfw_memory_recall` | Recall memory entries |
51
+ | `ijfw_memory_search` | Full-text search over memories |
52
+ | `ijfw_memory_prelude` | Load project context at session start |
53
+ | `ijfw_cross_project_search` | Search memories across projects |
54
+ | `ijfw_metrics` | Read cost + usage metrics |
55
+ | `ijfw_update_check` | Check for IJFW updates |
56
+ | `ijfw_update_apply` | Apply a pending IJFW update |
57
+ | `ijfw_prompt_check` | Validate a prompt against IJFW rules |
58
+ | `ijfw_run` | Run a sandboxed IJFW command |
59
+
60
+ ## Build (contributors)
61
+
62
+ ```bash
63
+ cd mcp-server
64
+ npm install
65
+ npm test
66
+ node --experimental-sqlite --test test-*.js
67
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ijfw/memory-server",
3
- "version": "1.4.0",
3
+ "version": "1.4.3",
4
4
  "description": "Cross-platform persistent memory server for IJFW. 10 MCP tools (memory + admin/update). Works with 13 MCP-using platforms (Claude Code, Codex, Gemini CLI, Cursor, Windsurf, Copilot, Hermes, Wayland, OpenCode, QwenCode, Cline, KimiCode, OpenClaw) plus Aider via rules-only tier.",
5
5
  "author": "Sean Donahoe",
6
6
  "license": "MIT",
@@ -0,0 +1,3 @@
1
+ -----BEGIN PUBLIC KEY-----
2
+ MCowBQYDK2VwAyEAL2lCdti0bYiFTGUo/hffy+NiBUBXdbDcdaDmjJS27i0=
3
+ -----END PUBLIC KEY-----
@@ -7,23 +7,40 @@
7
7
  * - installExtension when opts.activate is set
8
8
  */
9
9
 
10
- import { readFile, writeFile, unlink, mkdir } from 'node:fs/promises';
10
+ import { readFile, writeFile, unlink, mkdir, readdir, stat } from 'node:fs/promises';
11
11
  import { join, dirname } from 'node:path';
12
12
  import { homedir } from 'node:os';
13
+ import { randomBytes } from 'node:crypto';
14
+
15
+ import { resetExtensionQuotas } from './extension-quota-tracker.js';
13
16
 
14
17
  const STATE_PATH_REL = ['.ijfw', 'state', 'active-extension.json'];
15
18
 
19
+ // B18 — stale last-seen-by-<ide>.json files older than this get cleaned on read.
20
+ const LAST_SEEN_STALE_MS = 30 * 24 * 60 * 60 * 1000;
21
+
22
+ // Valid IDE id pattern (matches ide-detect.js). Keep in sync.
23
+ const IDE_ID_PATTERN = /^[a-z0-9-]+$/;
24
+
16
25
  function statePath(home) {
17
26
  return join(home || homedir(), ...STATE_PATH_REL);
18
27
  }
19
28
 
29
+ function lastSeenPath(home, ideId) {
30
+ return join(home || homedir(), '.ijfw', 'state', `last-seen-by-${ideId}.json`);
31
+ }
32
+
33
+ function stateDir(home) {
34
+ return join(home || homedir(), '.ijfw', 'state');
35
+ }
36
+
20
37
  /**
21
38
  * Write the active-extension state file from a manifest + scope.
22
39
  * Validates required fields before write. Atomic write via tmp+rename.
23
40
  *
24
41
  * @param {{ name: string, permissions: { reads: string[], writes: string[] } }} manifest
25
42
  * @param {'project'|'org'|'user'} scope
26
- * @param {{ homeDir?: string }} [opts]
43
+ * @param {{ homeDir?: string, ideId?: string|null }} [opts]
27
44
  * @returns {Promise<{ ok: boolean, path?: string, error?: string }>}
28
45
  */
29
46
  export async function writeActiveExtension(manifest, scope, opts = {}) {
@@ -41,19 +58,63 @@ export async function writeActiveExtension(manifest, scope, opts = {}) {
41
58
  }
42
59
  const reads = Array.isArray(manifest.permissions.reads) ? manifest.permissions.reads : [];
43
60
  const writes = Array.isArray(manifest.permissions.writes) ? manifest.permissions.writes : [];
61
+ const activatedAt = new Date().toISOString();
62
+ // B18: stamp activated_by_ide + activated_by_pid when ideId is provided.
63
+ // Caller (CLI) is responsible for calling detectIde() and threading the
64
+ // value in. When opts.ideId is null/undefined, fields are omitted (so the
65
+ // file stays back-compatible with v1.4.1 readers).
66
+ const ideId = (typeof opts.ideId === 'string' && IDE_ID_PATTERN.test(opts.ideId))
67
+ ? opts.ideId
68
+ : null;
44
69
  const out = {
45
70
  name: manifest.name,
46
71
  scope,
47
72
  permissions: { reads, writes },
48
- activated_at: new Date().toISOString(),
73
+ activated_at: activatedAt,
49
74
  };
75
+ if (ideId) {
76
+ out.activated_by_ide = ideId;
77
+ out.activated_by_pid = process.pid;
78
+ }
79
+ // R12-H-01: persist manifest.quotas so the tier-2 hook
80
+ // (extension-permission-check.mjs) can enforce quotas on Edit/Write/Bash
81
+ // dispatch. Without this the tier-2 hook reads `active.quotas` as undefined
82
+ // and silently bypasses the v1.4.3 quota gate that the server-side
83
+ // gatePermissionAndQuota path enforces. Schema (extension-manifest-schema.js):
84
+ // optional object whose values are positive integers — currently
85
+ // max_files_written / max_bytes_written / max_wall_clock_ms (forward-compat:
86
+ // unknown dimensions are kept as-is — schema rejects unknowns at install).
87
+ if (
88
+ manifest.quotas !== undefined &&
89
+ manifest.quotas !== null &&
90
+ typeof manifest.quotas === 'object' &&
91
+ !Array.isArray(manifest.quotas)
92
+ ) {
93
+ const cleanQuotas = {};
94
+ let copied = 0;
95
+ for (const [k, v] of Object.entries(manifest.quotas)) {
96
+ if (typeof v === 'number' && Number.isFinite(v) && Number.isInteger(v) && v > 0) {
97
+ cleanQuotas[k] = v;
98
+ copied++;
99
+ }
100
+ }
101
+ if (copied > 0) out.quotas = cleanQuotas;
102
+ }
50
103
  const home = opts && opts.homeDir ? opts.homeDir : (process.env.HOME || homedir());
51
104
  const path = statePath(home);
52
105
  await mkdir(dirname(path), { recursive: true });
53
- const tmp = `${path}.tmp.${process.pid}.${Date.now()}`;
106
+ const tmp = `${path}.tmp.${randomBytes(4).toString('hex')}`;
54
107
  await writeFile(tmp, JSON.stringify(out, null, 2) + '\n', 'utf8');
55
108
  const { rename } = await import('node:fs/promises');
56
109
  await rename(tmp, path);
110
+ // B16/SEC-M-02: reset quota counters on activate; stamp activated_at so
111
+ // wall_clock_ms can be computed against this activation window.
112
+ try {
113
+ await resetExtensionQuotas(manifest.name, { homeDir: home, activated_at: activatedAt });
114
+ } catch {
115
+ // Quota reset failure must not block activation. Counters will self-heal
116
+ // on next deactivate or the next activate of the same name.
117
+ }
57
118
  return { ok: true, path };
58
119
  }
59
120
 
@@ -65,11 +126,38 @@ export async function writeActiveExtension(manifest, scope, opts = {}) {
65
126
  */
66
127
  export async function clearActiveExtension(opts = {}) {
67
128
  const home = opts && opts.homeDir ? opts.homeDir : (process.env.HOME || homedir());
129
+ // B16/SEC-M-02: read the active extension name BEFORE unlinking so we can
130
+ // clear its quota counters. Best-effort: if the file is missing or
131
+ // malformed, deactivate still succeeds.
132
+ let extName = null;
133
+ try {
134
+ const raw = await readFile(statePath(home), 'utf8');
135
+ const parsed = JSON.parse(raw);
136
+ if (parsed && typeof parsed === 'object' && typeof parsed.name === 'string') {
137
+ extName = parsed.name;
138
+ }
139
+ } catch {
140
+ // ignore — extName stays null
141
+ }
68
142
  try {
69
143
  await unlink(statePath(home));
144
+ if (extName) {
145
+ try {
146
+ await resetExtensionQuotas(extName, { homeDir: home });
147
+ } catch {
148
+ // best-effort
149
+ }
150
+ }
70
151
  return { ok: true, removed: true };
71
152
  } catch (err) {
72
- if (err && err.code === 'ENOENT') return { ok: true, removed: false };
153
+ if (err && err.code === 'ENOENT') {
154
+ if (extName) {
155
+ try {
156
+ await resetExtensionQuotas(extName, { homeDir: home });
157
+ } catch { /* best-effort */ }
158
+ }
159
+ return { ok: true, removed: false };
160
+ }
73
161
  return { ok: false, removed: false };
74
162
  }
75
163
  }
@@ -86,7 +174,7 @@ export async function clearActiveExtension(opts = {}) {
86
174
  *
87
175
  * @param {string} name
88
176
  * @param {string} [projectRoot]
89
- * @param {{ homeDir?: string }} [opts]
177
+ * @param {{ homeDir?: string, strictShadow?: boolean }} [opts]
90
178
  * @returns {Promise<{ ok: boolean, manifest?: object, scope?: string, path?: string, error?: string }>}
91
179
  */
92
180
  export async function findInstalledManifest(name, projectRoot, opts = {}) {
@@ -102,15 +190,233 @@ export async function findInstalledManifest(name, projectRoot, opts = {}) {
102
190
  candidates.push({ scope: 'org', path: join(home, '.ijfw', 'extensions-org', name, 'manifest.json') });
103
191
  candidates.push({ scope: 'user', path: join(home, '.ijfw', 'extensions-user', name, 'manifest.json') });
104
192
 
193
+ // Collect all found manifests to detect project-scope shadowing.
194
+ const found = [];
105
195
  for (const c of candidates) {
106
196
  try {
107
197
  const raw = await readFile(c.path, 'utf8');
108
198
  const manifest = JSON.parse(raw);
109
- return { ok: true, manifest, scope: c.scope, path: c.path };
199
+ found.push({ scope: c.scope, path: c.path, manifest });
110
200
  } catch (err) {
111
201
  if (err && err.code === 'ENOENT') continue;
112
202
  if (err instanceof SyntaxError) continue;
113
203
  }
114
204
  }
115
- return { ok: false, error: `extension "${name}" not found in project/org/user scope` };
205
+
206
+ if (found.length === 0) {
207
+ return { ok: false, error: `extension "${name}" not found in project/org/user scope` };
208
+ }
209
+
210
+ const winner = found[0];
211
+
212
+ // B13.1: warn when project-scope shadows a lower-priority scope entry.
213
+ if (winner.scope === 'project' && found.length > 1) {
214
+ const shadowed = found[1];
215
+ const winnerKeyId = winner.manifest.signature?.keyId ?? '(unsigned)';
216
+ const shadowedKeyId = shadowed.manifest.signature?.keyId ?? '(unsigned)';
217
+ if (opts && opts.strictShadow) {
218
+ return {
219
+ ok: false,
220
+ error: `extension activate: project-scope "${name}" shadows ${shadowed.scope}-scope "${name}" (keyId ${winnerKeyId} vs ${shadowedKeyId}) — refused by strictShadow`,
221
+ };
222
+ }
223
+ process.stderr.write(
224
+ `[ijfw] extension activate: project-scope "${name}" shadows ${shadowed.scope}-scope "${name}" (keyId ${winnerKeyId} vs ${shadowedKeyId}) — using project\n`,
225
+ );
226
+ }
227
+
228
+ return { ok: true, manifest: winner.manifest, scope: winner.scope, path: winner.path };
229
+ }
230
+
231
+ // ============================================================================
232
+ // B18 — Cross-IDE Conflict Detection
233
+ // ============================================================================
234
+
235
+ /**
236
+ * Write the current IDE's last-seen marker. Best-effort: never throws.
237
+ * Atomic via tmp+rename.
238
+ *
239
+ * @param {string} ideId
240
+ * @param {{ homeDir?: string }} [opts]
241
+ */
242
+ async function writeLastSeen(ideId, opts = {}) {
243
+ if (!ideId || typeof ideId !== 'string' || !IDE_ID_PATTERN.test(ideId)) return;
244
+ if (ideId === 'unknown') return;
245
+ const home = opts && opts.homeDir ? opts.homeDir : (process.env.HOME || homedir());
246
+ const path = lastSeenPath(home, ideId);
247
+ try {
248
+ await mkdir(dirname(path), { recursive: true });
249
+ const body = JSON.stringify({ ide: ideId, last_seen_at: new Date().toISOString() }, null, 2) + '\n';
250
+ const tmp = `${path}.tmp.${randomBytes(4).toString('hex')}`;
251
+ await writeFile(tmp, body, 'utf8');
252
+ const { rename } = await import('node:fs/promises');
253
+ await rename(tmp, path);
254
+ } catch {
255
+ // best-effort
256
+ }
257
+ }
258
+
259
+ /**
260
+ * Read another IDE's last-seen marker. Returns null on any read error or
261
+ * missing file.
262
+ *
263
+ * @param {string} ideId
264
+ * @param {{ homeDir?: string }} [opts]
265
+ * @returns {Promise<{ ide: string, last_seen_at: string }|null>}
266
+ */
267
+ async function readLastSeen(ideId, opts = {}) {
268
+ if (!ideId || !IDE_ID_PATTERN.test(ideId)) return null;
269
+ const home = opts && opts.homeDir ? opts.homeDir : (process.env.HOME || homedir());
270
+ try {
271
+ const raw = await readFile(lastSeenPath(home, ideId), 'utf8');
272
+ const parsed = JSON.parse(raw);
273
+ if (!parsed || typeof parsed !== 'object') return null;
274
+ if (typeof parsed.last_seen_at !== 'string') return null;
275
+ return parsed;
276
+ } catch {
277
+ return null;
278
+ }
116
279
  }
280
+
281
+ /**
282
+ * Scan ~/.ijfw/state/ for last-seen-by-<ide>.json files older than 30 days
283
+ * and unlink them. Best-effort: never throws. Returns the number removed.
284
+ *
285
+ * @param {{ homeDir?: string, now?: number }} [opts]
286
+ * @returns {Promise<number>}
287
+ */
288
+ async function cleanupStaleLastSeen(opts = {}) {
289
+ const home = opts && opts.homeDir ? opts.homeDir : (process.env.HOME || homedir());
290
+ const now = typeof opts.now === 'number' ? opts.now : Date.now();
291
+ let removed = 0;
292
+ let entries;
293
+ try {
294
+ entries = await readdir(stateDir(home));
295
+ } catch {
296
+ return 0;
297
+ }
298
+ for (const entry of entries) {
299
+ if (!entry.startsWith('last-seen-by-') || !entry.endsWith('.json')) continue;
300
+ const full = join(stateDir(home), entry);
301
+ try {
302
+ const st = await stat(full);
303
+ if (now - st.mtimeMs > LAST_SEEN_STALE_MS) {
304
+ await unlink(full);
305
+ removed++;
306
+ }
307
+ } catch {
308
+ // best-effort
309
+ }
310
+ }
311
+ return removed;
312
+ }
313
+
314
+ /**
315
+ * Detect divergence between the IDE that wrote active.json and the current IDE.
316
+ *
317
+ * Semantics:
318
+ * - active.json missing OR no activated_by_ide field (pre-v1.4.3) → not divergent
319
+ * - active.activated_by_ide === current_ide → not divergent
320
+ * - active.activated_by_ide !== current_ide AND current_ide has a last-seen
321
+ * file AND active.activated_at is OLDER than current_ide's last_seen_at →
322
+ * divergent (this IDE was here before; a different IDE has since taken over
323
+ * stale state)
324
+ * - otherwise → not divergent (legitimate cross-IDE hand-off)
325
+ *
326
+ * Side effects:
327
+ * - writes current_ide's last-seen marker
328
+ * - cleans up stale last-seen files (>30 days)
329
+ *
330
+ * @param {{ homeDir?: string, currentIde?: string, now?: number }} [opts]
331
+ * @returns {Promise<{ divergent: boolean, last_writer: string|null, current_ide: string, age_seconds: number|null, reason?: string }>}
332
+ */
333
+ export async function detectCrossIdeDivergence(opts = {}) {
334
+ const home = opts && opts.homeDir ? opts.homeDir : (process.env.HOME || homedir());
335
+ const now = typeof opts.now === 'number' ? opts.now : Date.now();
336
+ // Lazy import to avoid a hard module dependency cycle in test scaffolds.
337
+ let currentIde = opts.currentIde;
338
+ if (!currentIde) {
339
+ try {
340
+ const { detectIde } = await import('./ide-detect.js');
341
+ currentIde = detectIde();
342
+ } catch {
343
+ currentIde = 'unknown';
344
+ }
345
+ }
346
+
347
+ // Best-effort stale cleanup on every divergence check.
348
+ await cleanupStaleLastSeen({ homeDir: home, now });
349
+
350
+ // Read active.json. Missing or unreadable → not divergent.
351
+ let active;
352
+ try {
353
+ const raw = await readFile(statePath(home), 'utf8');
354
+ active = JSON.parse(raw);
355
+ } catch {
356
+ return { divergent: false, last_writer: null, current_ide: currentIde, age_seconds: null, reason: 'no active extension' };
357
+ }
358
+
359
+ // Pre-v1.4.3 active.json — silently no-divergence.
360
+ if (!active || typeof active !== 'object' || typeof active.activated_by_ide !== 'string') {
361
+ return { divergent: false, last_writer: null, current_ide: currentIde, age_seconds: null, reason: 'pre-v1.4.3 active.json' };
362
+ }
363
+
364
+ const lastWriter = active.activated_by_ide;
365
+ const activatedAtMs = typeof active.activated_at === 'string' ? Date.parse(active.activated_at) : NaN;
366
+ const ageSeconds = Number.isFinite(activatedAtMs) ? Math.max(0, Math.floor((now - activatedAtMs) / 1000)) : null;
367
+
368
+ // CRITICAL ORDERING: read prior last-seen BEFORE overwriting it. Otherwise
369
+ // the divergence comparison degrades to "now vs activated_at" which is
370
+ // always non-divergent.
371
+ let priorSeen = null;
372
+ if (lastWriter !== currentIde && currentIde !== 'unknown') {
373
+ priorSeen = await readLastSeen(currentIde, { homeDir: home });
374
+ }
375
+
376
+ // Now write our own last-seen marker (best-effort; we ARE the current IDE).
377
+ if (currentIde !== 'unknown') {
378
+ await writeLastSeen(currentIde, { homeDir: home });
379
+ }
380
+
381
+ if (lastWriter === currentIde) {
382
+ return { divergent: false, last_writer: lastWriter, current_ide: currentIde, age_seconds: ageSeconds, reason: 'same ide' };
383
+ }
384
+
385
+ // If current_ide is 'unknown', divergence detection is disabled.
386
+ if (currentIde === 'unknown') {
387
+ return { divergent: false, last_writer: lastWriter, current_ide: currentIde, age_seconds: ageSeconds, reason: 'detection disabled' };
388
+ }
389
+
390
+ if (!priorSeen) {
391
+ // Current IDE has no prior history → legitimate first-time hand-off.
392
+ return { divergent: false, last_writer: lastWriter, current_ide: currentIde, age_seconds: ageSeconds, reason: 'first-time current ide' };
393
+ }
394
+ const seenMs = Date.parse(priorSeen.last_seen_at);
395
+ if (!Number.isFinite(seenMs) || !Number.isFinite(activatedAtMs)) {
396
+ return { divergent: false, last_writer: lastWriter, current_ide: currentIde, age_seconds: ageSeconds, reason: 'unparseable timestamps' };
397
+ }
398
+
399
+ // Design rule (per B18 spec):
400
+ // divergent iff active.activated_by_ide != currentIde
401
+ // AND active.activated_at < currentIde.last_seen
402
+ //
403
+ // Reading: the slot says some other IDE wrote it, but the current IDE has
404
+ // a more recent last-seen — i.e., the current IDE has been touching state
405
+ // more recently than the write, yet a different IDE's name is on it. Stale
406
+ // cross-IDE state divergence.
407
+ if (activatedAtMs < seenMs) {
408
+ return { divergent: true, last_writer: lastWriter, current_ide: currentIde, age_seconds: ageSeconds, reason: 'stale active.json: current ide last-seen is more recent than active.activated_at' };
409
+ }
410
+
411
+ // active was written AFTER current ide's last_seen — legitimate hand-off
412
+ // (another IDE took over while current was away).
413
+ return { divergent: false, last_writer: lastWriter, current_ide: currentIde, age_seconds: ageSeconds, reason: 'legitimate handoff: foreign ide wrote after current ide was last here' };
414
+ }
415
+
416
+ // Internal helpers exported for tests only.
417
+ export const __testing = Object.freeze({
418
+ writeLastSeen,
419
+ readLastSeen,
420
+ cleanupStaleLastSeen,
421
+ LAST_SEEN_STALE_MS,
422
+ });
@@ -0,0 +1,165 @@
1
+ /**
2
+ * dashboard-aggregator.js — IJFW v1.4.3 W9-C (B19)
3
+ *
4
+ * Server-side aggregation of ~/.ijfw/state/permission-events.jsonl for the
5
+ * dashboard's per-tool audit charts. Reads only the last TAIL_CHUNK bytes
6
+ * (same 2MB cap as the events endpoint) so this is bounded-memory even
7
+ * across rotations.
8
+ *
9
+ * Cache: 60s OR until the events file mtime changes, whichever first.
10
+ * Malformed JSONL lines are dropped silently — never crash the dashboard
11
+ * on a partial write.
12
+ *
13
+ * Returned shape:
14
+ * {
15
+ * hourly: { [hourISO]: count },
16
+ * by_extension: { [ext]: { allowed: number, denied: number } },
17
+ * by_tool_denied:{ [tool]: count }
18
+ * }
19
+ *
20
+ * Helper `computeWarnBashBypass(manifest)` implements ARCH-M-01: an extension
21
+ * with `tool:bash` or `tool:exec` in writes AND a strict files/bytes quota
22
+ * declared in the manifest gets a warning chip in the dashboard. The chip is
23
+ * an information channel — quota enforcement still applies, but bash content
24
+ * bypasses per-file accounting at the API surface.
25
+ */
26
+
27
+ import { existsSync, statSync, readFileSync } from 'node:fs';
28
+ import { join } from 'node:path';
29
+
30
+ // Match dashboard-server's TAIL_CHUNK. Kept here so this module is
31
+ // self-contained for the test harness.
32
+ export const TAIL_CHUNK = 2 * 1024 * 1024; // 2MB
33
+ const CACHE_TTL_MS = 60_000;
34
+
35
+ // Module-level cache. Key = canonical path; value = { mtimeMs, builtAt, result }.
36
+ const _cache = new Map();
37
+
38
+ export function _resetAggregatorCacheForTest() {
39
+ _cache.clear();
40
+ }
41
+
42
+ function _readTailLines(eventsPath) {
43
+ if (!existsSync(eventsPath)) return { lines: [], mtimeMs: 0 };
44
+ let st;
45
+ try { st = statSync(eventsPath); } catch { return { lines: [], mtimeMs: 0 }; }
46
+ if (!st.size) return { lines: [], mtimeMs: st.mtimeMs };
47
+ let buf;
48
+ try { buf = readFileSync(eventsPath); } catch { return { lines: [], mtimeMs: st.mtimeMs }; }
49
+ const slice = buf.subarray(Math.max(0, buf.length - TAIL_CHUNK));
50
+ let lines = slice.toString('utf8').split('\n').filter(Boolean);
51
+ // If we sliced mid-line, drop the partial leading element.
52
+ if (buf.length > TAIL_CHUNK) lines = lines.slice(1);
53
+ return { lines, mtimeMs: st.mtimeMs };
54
+ }
55
+
56
+ function _hourBucket(tsMs) {
57
+ const d = new Date(tsMs);
58
+ d.setUTCMinutes(0, 0, 0);
59
+ return d.toISOString();
60
+ }
61
+
62
+ /**
63
+ * Aggregate permission events within `windowMs` of `now`.
64
+ *
65
+ * @param {string} eventsPath absolute path to permission-events.jsonl
66
+ * @param {{ windowMs?: number, now?: number }} [opts]
67
+ */
68
+ export async function aggregateEvents(eventsPath, opts = {}) {
69
+ const windowMs = (opts && typeof opts.windowMs === 'number') ? opts.windowMs : 24 * 3600 * 1000;
70
+ const now = (opts && typeof opts.now === 'number') ? opts.now : Date.now();
71
+
72
+ const { lines, mtimeMs } = _readTailLines(eventsPath);
73
+
74
+ const cached = _cache.get(eventsPath);
75
+ if (cached
76
+ && cached.mtimeMs === mtimeMs
77
+ && (now - cached.builtAt) < CACHE_TTL_MS
78
+ && cached.windowMs === windowMs) {
79
+ return cached.result;
80
+ }
81
+
82
+ const cutoff = now - windowMs;
83
+ const hourly = Object.create(null);
84
+ const byExt = Object.create(null);
85
+ const byToolDenied = Object.create(null);
86
+
87
+ for (const line of lines) {
88
+ let obj;
89
+ try { obj = JSON.parse(line); } catch { continue; }
90
+ if (!obj || typeof obj !== 'object') continue;
91
+ const t = typeof obj.ts === 'string' ? Date.parse(obj.ts) : (typeof obj.ts === 'number' ? obj.ts : NaN);
92
+ if (!Number.isFinite(t)) continue;
93
+ if (t < cutoff) continue;
94
+
95
+ const ext = (typeof obj.extension === 'string' && obj.extension) ? obj.extension : '<unknown>';
96
+ const tool = (typeof obj.tool === 'string' && obj.tool) ? obj.tool : (typeof obj.action === 'string' ? obj.action : '<unknown>');
97
+ const allowed = obj.allowed !== false; // anything other than explicit false is allowed.
98
+
99
+ // hourly
100
+ const hk = _hourBucket(t);
101
+ hourly[hk] = (hourly[hk] || 0) + 1;
102
+
103
+ // by_extension
104
+ if (!byExt[ext]) byExt[ext] = { allowed: 0, denied: 0 };
105
+ if (allowed) byExt[ext].allowed += 1;
106
+ else byExt[ext].denied += 1;
107
+
108
+ // by_tool_denied
109
+ if (!allowed) {
110
+ byToolDenied[tool] = (byToolDenied[tool] || 0) + 1;
111
+ }
112
+ }
113
+
114
+ const result = { hourly, by_extension: byExt, by_tool_denied: byToolDenied };
115
+ _cache.set(eventsPath, { mtimeMs, builtAt: now, windowMs, result });
116
+ return result;
117
+ }
118
+
119
+ /**
120
+ * ARCH-M-01: compute whether an extension's manifest combines a bash/exec
121
+ * write permission with a strict files/bytes quota.
122
+ *
123
+ * Returns true iff:
124
+ * manifest.permissions.writes includes "tool:bash" or "tool:exec"
125
+ * AND (quotas.max_files_written OR quotas.max_bytes_written is set)
126
+ */
127
+ export function computeWarnBashBypass(manifest) {
128
+ if (!manifest || typeof manifest !== 'object') return false;
129
+ const perms = manifest.permissions || {};
130
+ const writes = Array.isArray(perms.writes) ? perms.writes : [];
131
+ const hasBashOrExec = writes.some((w) => w === 'tool:bash' || w === 'tool:exec');
132
+ if (!hasBashOrExec) return false;
133
+ const q = manifest.quotas || {};
134
+ const hasStrictQuota =
135
+ (typeof q.max_files_written === 'number' && Number.isFinite(q.max_files_written)) ||
136
+ (typeof q.max_bytes_written === 'number' && Number.isFinite(q.max_bytes_written));
137
+ return Boolean(hasStrictQuota);
138
+ }
139
+
140
+ /**
141
+ * Resolve `<scope>/<name>/manifest.json` and read it. Returns the parsed
142
+ * manifest object or `null` if the file is missing/unreadable/malformed.
143
+ *
144
+ * Scope→path map mirrors `active-extension-writer.js`:
145
+ * project: <projectRoot>/.ijfw/extensions/<name>/manifest.json
146
+ * org: <home>/.ijfw/extensions-org/<name>/manifest.json
147
+ * user: <home>/.ijfw/extensions-user/<name>/manifest.json
148
+ */
149
+ export function readActiveManifest({ scope, name, home, projectRoot }) {
150
+ if (!scope || !name) return null;
151
+ let path = null;
152
+ if (scope === 'project' && projectRoot) {
153
+ path = join(projectRoot, '.ijfw', 'extensions', name, 'manifest.json');
154
+ } else if (scope === 'org' && home) {
155
+ path = join(home, '.ijfw', 'extensions-org', name, 'manifest.json');
156
+ } else if (scope === 'user' && home) {
157
+ path = join(home, '.ijfw', 'extensions-user', name, 'manifest.json');
158
+ }
159
+ if (!path) return null;
160
+ try {
161
+ return JSON.parse(readFileSync(path, 'utf8'));
162
+ } catch {
163
+ return null;
164
+ }
165
+ }