@ijfw/memory-server 1.4.1 → 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/package.json +1 -1
- package/src/active-extension-writer.js +284 -4
- package/src/dashboard-aggregator.js +165 -0
- package/src/dashboard-charts.js +239 -0
- package/src/dashboard-client.html +201 -0
- package/src/dashboard-server.js +107 -0
- package/src/dispatch/active-cli.js +141 -0
- package/src/dispatch/extension.js +38 -0
- package/src/dispatch/quota-cli.js +42 -0
- package/src/dispatch/registry-cli.js +339 -0
- package/src/dispatch/signer-cli.js +311 -0
- package/src/extension-manifest-schema.js +25 -0
- package/src/extension-permission-check.mjs +61 -0
- package/src/extension-quota-tracker.js +305 -0
- package/src/extension-registry-ws.js +347 -0
- package/src/extension-registry.js +819 -149
- package/src/extension-signer.js +105 -0
- package/src/fs-lock.js +205 -0
- package/src/hardware-signer.js +493 -0
- package/src/ide-detect.js +122 -0
- package/src/runtime-mediator.js +31 -0
- package/src/server.js +180 -18
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ijfw/memory-server",
|
|
3
|
-
"version": "1.4.
|
|
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",
|
|
@@ -7,24 +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
13
|
import { randomBytes } from 'node:crypto';
|
|
14
14
|
|
|
15
|
+
import { resetExtensionQuotas } from './extension-quota-tracker.js';
|
|
16
|
+
|
|
15
17
|
const STATE_PATH_REL = ['.ijfw', 'state', 'active-extension.json'];
|
|
16
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
|
+
|
|
17
25
|
function statePath(home) {
|
|
18
26
|
return join(home || homedir(), ...STATE_PATH_REL);
|
|
19
27
|
}
|
|
20
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
|
+
|
|
21
37
|
/**
|
|
22
38
|
* Write the active-extension state file from a manifest + scope.
|
|
23
39
|
* Validates required fields before write. Atomic write via tmp+rename.
|
|
24
40
|
*
|
|
25
41
|
* @param {{ name: string, permissions: { reads: string[], writes: string[] } }} manifest
|
|
26
42
|
* @param {'project'|'org'|'user'} scope
|
|
27
|
-
* @param {{ homeDir?: string }} [opts]
|
|
43
|
+
* @param {{ homeDir?: string, ideId?: string|null }} [opts]
|
|
28
44
|
* @returns {Promise<{ ok: boolean, path?: string, error?: string }>}
|
|
29
45
|
*/
|
|
30
46
|
export async function writeActiveExtension(manifest, scope, opts = {}) {
|
|
@@ -42,12 +58,48 @@ export async function writeActiveExtension(manifest, scope, opts = {}) {
|
|
|
42
58
|
}
|
|
43
59
|
const reads = Array.isArray(manifest.permissions.reads) ? manifest.permissions.reads : [];
|
|
44
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;
|
|
45
69
|
const out = {
|
|
46
70
|
name: manifest.name,
|
|
47
71
|
scope,
|
|
48
72
|
permissions: { reads, writes },
|
|
49
|
-
activated_at:
|
|
73
|
+
activated_at: activatedAt,
|
|
50
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
|
+
}
|
|
51
103
|
const home = opts && opts.homeDir ? opts.homeDir : (process.env.HOME || homedir());
|
|
52
104
|
const path = statePath(home);
|
|
53
105
|
await mkdir(dirname(path), { recursive: true });
|
|
@@ -55,6 +107,14 @@ export async function writeActiveExtension(manifest, scope, opts = {}) {
|
|
|
55
107
|
await writeFile(tmp, JSON.stringify(out, null, 2) + '\n', 'utf8');
|
|
56
108
|
const { rename } = await import('node:fs/promises');
|
|
57
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
|
+
}
|
|
58
118
|
return { ok: true, path };
|
|
59
119
|
}
|
|
60
120
|
|
|
@@ -66,11 +126,38 @@ export async function writeActiveExtension(manifest, scope, opts = {}) {
|
|
|
66
126
|
*/
|
|
67
127
|
export async function clearActiveExtension(opts = {}) {
|
|
68
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
|
+
}
|
|
69
142
|
try {
|
|
70
143
|
await unlink(statePath(home));
|
|
144
|
+
if (extName) {
|
|
145
|
+
try {
|
|
146
|
+
await resetExtensionQuotas(extName, { homeDir: home });
|
|
147
|
+
} catch {
|
|
148
|
+
// best-effort
|
|
149
|
+
}
|
|
150
|
+
}
|
|
71
151
|
return { ok: true, removed: true };
|
|
72
152
|
} catch (err) {
|
|
73
|
-
if (err && err.code === 'ENOENT')
|
|
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
|
+
}
|
|
74
161
|
return { ok: false, removed: false };
|
|
75
162
|
}
|
|
76
163
|
}
|
|
@@ -140,3 +227,196 @@ export async function findInstalledManifest(name, projectRoot, opts = {}) {
|
|
|
140
227
|
|
|
141
228
|
return { ok: true, manifest: winner.manifest, scope: winner.scope, path: winner.path };
|
|
142
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
|
+
}
|
|
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
|
+
}
|