@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 +67 -0
- package/package.json +1 -1
- package/src/.registry-meta-key.pem +3 -0
- package/src/active-extension-writer.js +314 -8
- package/src/dashboard-aggregator.js +165 -0
- package/src/dashboard-charts.js +239 -0
- package/src/dashboard-client.html +411 -1
- package/src/dashboard-server.js +350 -0
- package/src/dispatch/active-cli.js +141 -0
- package/src/dispatch/extension.js +272 -1
- 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-installer.js +39 -0
- package/src/extension-manifest-schema.js +25 -0
- package/src/extension-permission-check.mjs +140 -0
- package/src/extension-quota-tracker.js +305 -0
- package/src/extension-registry-ws.js +347 -0
- package/src/extension-registry.js +1289 -0
- package/src/extension-signer.js +270 -0
- package/src/fs-lock.js +205 -0
- package/src/hardware-signer.js +493 -0
- package/src/ide-detect.js +122 -0
- package/src/memory-feedback.js +194 -10
- package/src/runtime-mediator.js +61 -1
- package/src/server.js +180 -18
package/src/memory-feedback.js
CHANGED
|
@@ -89,20 +89,16 @@ export async function readRecentReceipts(projectRoot, limit = 50) {
|
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
/**
|
|
92
|
-
*
|
|
92
|
+
* detectRepeatedFail(receipts, opts)
|
|
93
93
|
*
|
|
94
94
|
* Examines the last `opts.window` (default 10) receipts for repeated FAIL/FLAG
|
|
95
95
|
* on the same affected_artifacts[].type value.
|
|
96
96
|
*
|
|
97
|
-
* If a single artifact_type appears in >= opts.threshold (default 3) receipts
|
|
98
|
-
* within the window with a FAIL or FLAG verdict, one pattern object is emitted
|
|
99
|
-
* for that artifact_type.
|
|
100
|
-
*
|
|
101
97
|
* @param {object[]} receipts
|
|
102
98
|
* @param {{ threshold?: number, window?: number }} [opts]
|
|
103
99
|
* @returns {Array<{ kind: string, artifact_type: string, count: number, threshold: number, sample: string[] }>}
|
|
104
100
|
*/
|
|
105
|
-
|
|
101
|
+
function detectRepeatedFail(receipts, opts = {}) {
|
|
106
102
|
const threshold = typeof opts.threshold === 'number' ? opts.threshold : 3;
|
|
107
103
|
const window = typeof opts.window === 'number' ? opts.window : 10;
|
|
108
104
|
|
|
@@ -152,6 +148,192 @@ export function detectPatterns(receipts, opts = {}) {
|
|
|
152
148
|
return patterns;
|
|
153
149
|
}
|
|
154
150
|
|
|
151
|
+
/**
|
|
152
|
+
* detectRisingFailRate(receipts, opts)
|
|
153
|
+
*
|
|
154
|
+
* Compares the fail rate in the most recent `window` receipts to the `window`
|
|
155
|
+
* receipts before that. If the rate rose by >= minRise (absolute), emits a
|
|
156
|
+
* rising-fail-rate pattern.
|
|
157
|
+
*
|
|
158
|
+
* @param {object[]} receipts
|
|
159
|
+
* @param {{ window?: number, minRise?: number }} [opts]
|
|
160
|
+
* @returns {Array<{ kind: string, from_rate: number, to_rate: number, window: number, suggestion: string }>}
|
|
161
|
+
*/
|
|
162
|
+
export function detectRisingFailRate(receipts, opts = {}) {
|
|
163
|
+
try {
|
|
164
|
+
const window = typeof opts.window === 'number' && opts.window > 0 ? opts.window : 20;
|
|
165
|
+
const minRise = typeof opts.minRise === 'number' ? opts.minRise : 0.2;
|
|
166
|
+
|
|
167
|
+
if (!Array.isArray(receipts) || receipts.length < 2) return [];
|
|
168
|
+
|
|
169
|
+
const recent = receipts.slice(0, window);
|
|
170
|
+
const prior = receipts.slice(window, window * 2);
|
|
171
|
+
|
|
172
|
+
if (prior.length === 0) return [];
|
|
173
|
+
|
|
174
|
+
const failRate = (arr) => {
|
|
175
|
+
const valid = arr.filter((r) => r && typeof r === 'object' && typeof r.verdict === 'string');
|
|
176
|
+
if (valid.length === 0) return 0;
|
|
177
|
+
return valid.filter((r) => FAIL_VERDICTS.has(r.verdict)).length / valid.length;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const fromRate = failRate(prior);
|
|
181
|
+
const toRate = failRate(recent);
|
|
182
|
+
|
|
183
|
+
if (toRate - fromRate < minRise) return [];
|
|
184
|
+
|
|
185
|
+
const fromPct = Math.round(fromRate * 100);
|
|
186
|
+
const toPct = Math.round(toRate * 100);
|
|
187
|
+
|
|
188
|
+
return [{
|
|
189
|
+
kind: 'rising-fail-rate',
|
|
190
|
+
from_rate: fromRate,
|
|
191
|
+
to_rate: toRate,
|
|
192
|
+
window,
|
|
193
|
+
suggestion: `gate fail rate rose from ${fromPct}% to ${toPct}% in the last ${window} receipts — consider rolling back the most recent changes`,
|
|
194
|
+
}];
|
|
195
|
+
} catch {
|
|
196
|
+
return [];
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* detectCrossSkillCorrelation(receipts, opts)
|
|
202
|
+
*
|
|
203
|
+
* Looks at the last `window` receipts. If >= minDistinctGates distinct gate_id
|
|
204
|
+
* prefixes (split on first `-` or `:`) have a FAIL/FLAG verdict, emits a
|
|
205
|
+
* cross-skill-correlation pattern.
|
|
206
|
+
*
|
|
207
|
+
* @param {object[]} receipts
|
|
208
|
+
* @param {{ window?: number, minDistinctGates?: number }} [opts]
|
|
209
|
+
* @returns {Array<{ kind: string, distinct_gates: number, window: number, suggestion: string }>}
|
|
210
|
+
*/
|
|
211
|
+
export function detectCrossSkillCorrelation(receipts, opts = {}) {
|
|
212
|
+
try {
|
|
213
|
+
const window = typeof opts.window === 'number' && opts.window > 0 ? opts.window : 10;
|
|
214
|
+
const minDistinctGates = typeof opts.minDistinctGates === 'number' ? opts.minDistinctGates : 3;
|
|
215
|
+
|
|
216
|
+
if (!Array.isArray(receipts) || receipts.length === 0) return [];
|
|
217
|
+
|
|
218
|
+
const windowReceipts = receipts.slice(0, window);
|
|
219
|
+
const prefixes = new Set();
|
|
220
|
+
|
|
221
|
+
for (const receipt of windowReceipts) {
|
|
222
|
+
if (!receipt || typeof receipt !== 'object') continue;
|
|
223
|
+
if (!FAIL_VERDICTS.has(receipt.verdict)) continue;
|
|
224
|
+
if (typeof receipt.gate_id !== 'string' || receipt.gate_id.length === 0) continue;
|
|
225
|
+
|
|
226
|
+
// Take the prefix before the first `-` or `:`
|
|
227
|
+
const prefix = receipt.gate_id.split(/[-:]/)[0];
|
|
228
|
+
if (prefix) prefixes.add(prefix);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (prefixes.size < minDistinctGates) return [];
|
|
232
|
+
|
|
233
|
+
return [{
|
|
234
|
+
kind: 'cross-skill-correlation',
|
|
235
|
+
distinct_gates: prefixes.size,
|
|
236
|
+
window,
|
|
237
|
+
suggestion: `${prefixes.size} different gates flagged in the last ${window} receipts — review project state, not individual artifacts`,
|
|
238
|
+
}];
|
|
239
|
+
} catch {
|
|
240
|
+
return [];
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* detectRegression(receipts, opts)
|
|
246
|
+
*
|
|
247
|
+
* For each unique (gate_id, artifact_type) key in receipts: if the most recent
|
|
248
|
+
* `failWindow` receipts were all FAIL/FLAG but the `passWindow` receipts before
|
|
249
|
+
* that were all PASS, emits a regression pattern.
|
|
250
|
+
*
|
|
251
|
+
* artifact_type is the TYPE field (e.g. 'chapter'), never the ID.
|
|
252
|
+
*
|
|
253
|
+
* @param {object[]} receipts
|
|
254
|
+
* @param {{ passWindow?: number, failWindow?: number }} [opts]
|
|
255
|
+
* @returns {Array<{ kind: string, gate_id: string, artifact_type: string, suggestion: string }>}
|
|
256
|
+
*/
|
|
257
|
+
export function detectRegression(receipts, opts = {}) {
|
|
258
|
+
try {
|
|
259
|
+
const passWindow = typeof opts.passWindow === 'number' && opts.passWindow > 0 ? opts.passWindow : 5;
|
|
260
|
+
const failWindow = typeof opts.failWindow === 'number' && opts.failWindow > 0 ? opts.failWindow : 2;
|
|
261
|
+
|
|
262
|
+
if (!Array.isArray(receipts) || receipts.length === 0) return [];
|
|
263
|
+
|
|
264
|
+
// Build per-(gate_id, artifact_type) ordered lists (receipts[0] = most recent).
|
|
265
|
+
// receipts are assumed newest-first (as returned by readRecentReceipts).
|
|
266
|
+
const streams = new Map(); // key -> [receipt, ...]
|
|
267
|
+
|
|
268
|
+
for (const receipt of receipts) {
|
|
269
|
+
if (!receipt || typeof receipt !== 'object') continue;
|
|
270
|
+
if (typeof receipt.gate_id !== 'string' || receipt.gate_id.length === 0) continue;
|
|
271
|
+
if (!Array.isArray(receipt.affected_artifacts)) continue;
|
|
272
|
+
|
|
273
|
+
const seenTypes = new Set();
|
|
274
|
+
for (const artifact of receipt.affected_artifacts) {
|
|
275
|
+
if (!artifact || typeof artifact !== 'object') continue;
|
|
276
|
+
if (typeof artifact.type !== 'string' || artifact.type.length === 0) continue;
|
|
277
|
+
|
|
278
|
+
const t = artifact.type;
|
|
279
|
+
if (seenTypes.has(t)) continue;
|
|
280
|
+
seenTypes.add(t);
|
|
281
|
+
|
|
282
|
+
const key = `${receipt.gate_id}\x00${t}`;
|
|
283
|
+
if (!streams.has(key)) streams.set(key, []);
|
|
284
|
+
streams.get(key).push(receipt);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const patterns = [];
|
|
289
|
+
|
|
290
|
+
for (const [key, stream] of streams.entries()) {
|
|
291
|
+
if (stream.length < failWindow + passWindow) continue;
|
|
292
|
+
|
|
293
|
+
const recentSlice = stream.slice(0, failWindow);
|
|
294
|
+
const priorSlice = stream.slice(failWindow, failWindow + passWindow);
|
|
295
|
+
|
|
296
|
+
const allRecentFail = recentSlice.every((r) => FAIL_VERDICTS.has(r.verdict));
|
|
297
|
+
const allPriorPass = priorSlice.every((r) => r.verdict === 'PASS');
|
|
298
|
+
|
|
299
|
+
if (!allRecentFail || !allPriorPass) continue;
|
|
300
|
+
|
|
301
|
+
const [gate_id, artifact_type] = key.split('\x00');
|
|
302
|
+
patterns.push({
|
|
303
|
+
kind: 'regression',
|
|
304
|
+
gate_id,
|
|
305
|
+
artifact_type,
|
|
306
|
+
suggestion: `gate ${gate_id} on ${artifact_type} was passing last ${passWindow} runs; failing now — likely regression`,
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return patterns;
|
|
311
|
+
} catch {
|
|
312
|
+
return [];
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* detectPatterns(receipts, opts)
|
|
318
|
+
*
|
|
319
|
+
* Dispatcher: runs all four detectors and returns the union in deterministic
|
|
320
|
+
* order: repeated-fail-on-same-artifact, rising-fail-rate, cross-skill-correlation,
|
|
321
|
+
* regression.
|
|
322
|
+
*
|
|
323
|
+
* @param {object[]} receipts
|
|
324
|
+
* @param {{ threshold?: number, window?: number }} [opts]
|
|
325
|
+
* @returns {object[]}
|
|
326
|
+
*/
|
|
327
|
+
export function detectPatterns(receipts, opts = {}) {
|
|
328
|
+
if (!Array.isArray(receipts)) return [];
|
|
329
|
+
return [
|
|
330
|
+
...detectRepeatedFail(receipts, opts),
|
|
331
|
+
...detectRisingFailRate(receipts, opts),
|
|
332
|
+
...detectCrossSkillCorrelation(receipts, opts),
|
|
333
|
+
...detectRegression(receipts, opts),
|
|
334
|
+
];
|
|
335
|
+
}
|
|
336
|
+
|
|
155
337
|
/**
|
|
156
338
|
* getFeedbackSuggestions(projectRoot, opts)
|
|
157
339
|
*
|
|
@@ -178,10 +360,12 @@ export async function getFeedbackSuggestions(projectRoot, opts = {}) {
|
|
|
178
360
|
const receipts = await readRecentReceipts(projectRoot, limit);
|
|
179
361
|
const patterns = detectPatterns(receipts, { threshold, window });
|
|
180
362
|
|
|
181
|
-
return patterns.map(
|
|
182
|
-
(p)
|
|
183
|
-
`Pattern detected: ${p.count}/${window} recent gates flagged on ${p.artifact_type} -- consider reviewing ${p.artifact_type} scope
|
|
184
|
-
|
|
363
|
+
return patterns.map((p) => {
|
|
364
|
+
if (p.kind === 'repeated-fail-on-same-artifact') {
|
|
365
|
+
return `Pattern detected: ${p.count}/${window} recent gates flagged on ${p.artifact_type} -- consider reviewing ${p.artifact_type} scope`;
|
|
366
|
+
}
|
|
367
|
+
return `Pattern detected: ${p.suggestion}`;
|
|
368
|
+
});
|
|
185
369
|
} catch {
|
|
186
370
|
return [];
|
|
187
371
|
}
|
package/src/runtime-mediator.js
CHANGED
|
@@ -17,10 +17,18 @@
|
|
|
17
17
|
* pass -- that would defeat the sandbox.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import { readFile, mkdir, appendFile } from 'node:fs/promises';
|
|
20
|
+
import { readFile, mkdir, appendFile, rename, stat } from 'node:fs/promises';
|
|
21
21
|
import { join } from 'node:path';
|
|
22
22
|
import { homedir } from 'node:os';
|
|
23
23
|
|
|
24
|
+
// B18 — divergence helper imported lazily inside maybeWarnDivergence to keep
|
|
25
|
+
// the module side-effect-light. detectCrossIdeDivergence has its own internal
|
|
26
|
+
// stale-file cleanup + last-seen writer; we just consume the verdict.
|
|
27
|
+
|
|
28
|
+
// Log rotation: when permission-events.jsonl exceeds this many lines, rename
|
|
29
|
+
// to .0 (overwriting any prior .0) and start fresh. Total on disk = 2 * cap.
|
|
30
|
+
const ROTATION_LINE_CAP = 10_000;
|
|
31
|
+
|
|
24
32
|
// Sentinel returned from getActiveExtension when the file exists but is
|
|
25
33
|
// invalid. Callers compare with === to distinguish from null (no file).
|
|
26
34
|
const MALFORMED = Object.freeze({ __malformed: true });
|
|
@@ -123,14 +131,39 @@ export function checkPermission(action, target, activeExt) {
|
|
|
123
131
|
};
|
|
124
132
|
}
|
|
125
133
|
|
|
134
|
+
/**
|
|
135
|
+
* Rotate permission-events.jsonl if it exceeds ROTATION_LINE_CAP lines.
|
|
136
|
+
* Renames current file to .0 (overwriting any prior .0) then the caller
|
|
137
|
+
* appends to the fresh empty file. Best effort -- never throws.
|
|
138
|
+
*/
|
|
139
|
+
async function maybeRotateEventLog(logPath) {
|
|
140
|
+
try {
|
|
141
|
+
let st;
|
|
142
|
+
try { st = await stat(logPath); } catch { return; } // ENOENT = no rotation needed
|
|
143
|
+
if (st.size === 0) return;
|
|
144
|
+
// Count newlines in a single Buffer read. This is the amortised cost.
|
|
145
|
+
const buf = await readFile(logPath);
|
|
146
|
+
let count = 0;
|
|
147
|
+
for (let i = 0; i < buf.length; i++) {
|
|
148
|
+
if (buf[i] === 0x0a) count++; // '\n'
|
|
149
|
+
}
|
|
150
|
+
if (count < ROTATION_LINE_CAP) return;
|
|
151
|
+
await rename(logPath, logPath + '.0');
|
|
152
|
+
} catch {
|
|
153
|
+
// Rotation failure is non-fatal.
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
126
157
|
/**
|
|
127
158
|
* Append one JSON line to ~/.ijfw/state/permission-events.jsonl. Best effort:
|
|
128
159
|
* never throws. The forensic trail is a nice-to-have, not a critical path.
|
|
160
|
+
* Rotation check runs on each append (cost amortised).
|
|
129
161
|
*/
|
|
130
162
|
export async function logPermissionEvent(event, opts = {}) {
|
|
131
163
|
try {
|
|
132
164
|
const home = opts.homeDir || process.env.HOME || homedir();
|
|
133
165
|
await mkdir(stateDir(home), { recursive: true });
|
|
166
|
+
await maybeRotateEventLog(eventLogPath(home));
|
|
134
167
|
const line = JSON.stringify(event) + '\n';
|
|
135
168
|
await appendFile(eventLogPath(home), line, 'utf8');
|
|
136
169
|
} catch {
|
|
@@ -138,6 +171,33 @@ export async function logPermissionEvent(event, opts = {}) {
|
|
|
138
171
|
}
|
|
139
172
|
}
|
|
140
173
|
|
|
174
|
+
/**
|
|
175
|
+
* B18 — surface a cross-IDE divergence warning on stderr (does NOT block).
|
|
176
|
+
* Called by permission-check call sites once per dispatch. Returns the
|
|
177
|
+
* divergence verdict so callers can attach `divergent_ide: true` to event
|
|
178
|
+
* log entries.
|
|
179
|
+
*
|
|
180
|
+
* Best-effort: any error returns `{ divergent: false }` and never throws.
|
|
181
|
+
*
|
|
182
|
+
* @param {{ homeDir?: string }} [opts]
|
|
183
|
+
* @returns {Promise<{ divergent: boolean, last_writer?: string|null, current_ide?: string, age_seconds?: number|null }>}
|
|
184
|
+
*/
|
|
185
|
+
export async function maybeWarnDivergence(opts = {}) {
|
|
186
|
+
try {
|
|
187
|
+
const { detectCrossIdeDivergence } = await import('./active-extension-writer.js');
|
|
188
|
+
const verdict = await detectCrossIdeDivergence({ homeDir: opts.homeDir });
|
|
189
|
+
if (verdict && verdict.divergent) {
|
|
190
|
+
const age = typeof verdict.age_seconds === 'number' ? `${verdict.age_seconds}s ago` : 'unknown time ago';
|
|
191
|
+
process.stderr.write(
|
|
192
|
+
`[ijfw] active extension last activated by '${verdict.last_writer}' ${age}; this IDE is '${verdict.current_ide}'\n`,
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
return verdict || { divergent: false };
|
|
196
|
+
} catch {
|
|
197
|
+
return { divergent: false };
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
141
201
|
/**
|
|
142
202
|
* Map an MCP tool name (+ args) to the (action, target) tuple used for
|
|
143
203
|
* permission checks. Returns null for unrecognised tool names; callers
|
package/src/server.js
CHANGED
|
@@ -55,6 +55,168 @@ import {
|
|
|
55
55
|
logPermissionEvent,
|
|
56
56
|
toolNameToActionTarget,
|
|
57
57
|
} from './runtime-mediator.js';
|
|
58
|
+
// B16/SEC-M-03 — quota tracker. The combined permission+quota gate lives
|
|
59
|
+
// here in server.js (NOT runtime-mediator.js, which is primitives-only).
|
|
60
|
+
import { checkAndIncrement as quotaCheckAndIncrement } from './extension-quota-tracker.js';
|
|
61
|
+
import { findInstalledManifest } from './active-extension-writer.js';
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Combined permission + quota gate (SEC-M-03).
|
|
65
|
+
*
|
|
66
|
+
* Single enforcement site for the MCP tool dispatch. Replaces the inline
|
|
67
|
+
* `checkPermission` block. Exported so `test-server-quota-integration.js` can
|
|
68
|
+
* exercise the gate directly without spinning a full MCP server.
|
|
69
|
+
*
|
|
70
|
+
* Back-compat: when `activeExt === null` (no active extension), returns
|
|
71
|
+
* `{ allowed: true }` immediately — preserves bundled-IJFW behavior.
|
|
72
|
+
*
|
|
73
|
+
* Per-tool quota dimension mapping:
|
|
74
|
+
* - ijfw_memory_store → bytes_written (sum of stored payload string lengths)
|
|
75
|
+
* - ijfw_run with tool:write|edit|bash → files_written + bytes_written
|
|
76
|
+
* - wall_clock_ms: checked-not-incremented on every tool call
|
|
77
|
+
*
|
|
78
|
+
* @returns {Promise<{ allowed: boolean, response?: object, dimension?: string, current?: number, limit?: number, reason?: string }>}
|
|
79
|
+
*/
|
|
80
|
+
export async function gatePermissionAndQuota({ toolName, args, activeExt, home, manifestQuotas }) {
|
|
81
|
+
if (activeExt === null || activeExt === undefined) {
|
|
82
|
+
return { allowed: true };
|
|
83
|
+
}
|
|
84
|
+
const mapping = toolNameToActionTarget(toolName, args || {});
|
|
85
|
+
if (!mapping) {
|
|
86
|
+
return { allowed: true };
|
|
87
|
+
}
|
|
88
|
+
const permCheck = checkPermission(mapping.action, mapping.target, activeExt);
|
|
89
|
+
if (!permCheck.allowed) {
|
|
90
|
+
// Log permission deny — preserves the v1.4.1 audit-trail behavior.
|
|
91
|
+
await logPermissionEvent({
|
|
92
|
+
tool: toolName,
|
|
93
|
+
extension: activeExt && activeExt.name ? activeExt.name : null,
|
|
94
|
+
action: mapping.action,
|
|
95
|
+
target: mapping.target,
|
|
96
|
+
allowed: false,
|
|
97
|
+
reason: permCheck.reason,
|
|
98
|
+
ts: new Date().toISOString(),
|
|
99
|
+
}).catch(() => {});
|
|
100
|
+
return {
|
|
101
|
+
allowed: false,
|
|
102
|
+
reason: permCheck.reason,
|
|
103
|
+
response: {
|
|
104
|
+
content: [{ type: 'text', text: `extension permission denied: ${permCheck.reason}` }],
|
|
105
|
+
isError: true,
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Quota enforcement only kicks in when the active extension declared quotas.
|
|
111
|
+
// Manifest quotas are passed in by the caller (resolved from the installed
|
|
112
|
+
// extension manifest). When manifestQuotas is missing/empty, this short-
|
|
113
|
+
// circuits — back-compat path.
|
|
114
|
+
const quotas = manifestQuotas && typeof manifestQuotas === 'object' ? manifestQuotas : {};
|
|
115
|
+
const limits = {
|
|
116
|
+
files_written: typeof quotas.max_files_written === 'number' ? quotas.max_files_written : null,
|
|
117
|
+
bytes_written: typeof quotas.max_bytes_written === 'number' ? quotas.max_bytes_written : null,
|
|
118
|
+
wall_clock_ms: typeof quotas.max_wall_clock_ms === 'number' ? quotas.max_wall_clock_ms : null,
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
// wall_clock_ms — always checked (no-op if limit is null).
|
|
122
|
+
if (limits.wall_clock_ms !== null) {
|
|
123
|
+
const wc = await quotaCheckAndIncrement(activeExt.name, 'wall_clock_ms', 0, limits.wall_clock_ms, { homeDir: home });
|
|
124
|
+
if (!wc.allowed) {
|
|
125
|
+
const reason = `quota:wall_clock_ms ${wc.current}/${wc.limit}`;
|
|
126
|
+
await logPermissionEvent({
|
|
127
|
+
tool: toolName, extension: activeExt.name, action: mapping.action, target: mapping.target,
|
|
128
|
+
allowed: false, reason, ts: new Date().toISOString(),
|
|
129
|
+
}).catch(() => {});
|
|
130
|
+
return {
|
|
131
|
+
allowed: false, dimension: 'wall_clock_ms', current: wc.current, limit: wc.limit, reason,
|
|
132
|
+
response: {
|
|
133
|
+
content: [{ type: 'text', text: `extension "${activeExt.name}" exceeded quota wall_clock_ms (${wc.current}/${wc.limit})` }],
|
|
134
|
+
isError: true,
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Per-tool counter mapping.
|
|
141
|
+
let inc = null;
|
|
142
|
+
if (toolName === 'ijfw_memory_store') {
|
|
143
|
+
let payloadBytes = 0;
|
|
144
|
+
try {
|
|
145
|
+
if (args && typeof args.content === 'string') payloadBytes += args.content.length;
|
|
146
|
+
if (args && typeof args.context === 'string') payloadBytes += args.context.length;
|
|
147
|
+
} catch { /* defensive */ }
|
|
148
|
+
inc = { dim: 'bytes_written', count: payloadBytes, limit: limits.bytes_written, path: null };
|
|
149
|
+
} else if (toolName === 'ijfw_run') {
|
|
150
|
+
// Only run-with-write-tools consume files/bytes quota. We sniff the
|
|
151
|
+
// command for a `tool:write|edit|bash|notebookedit` leading token and
|
|
152
|
+
// an inline path arg (best-effort; absolute path is used for dedupe).
|
|
153
|
+
const cmd = args && typeof args.command === 'string' ? args.command : '';
|
|
154
|
+
const m = cmd.match(/^\s*tool:(write|edit|bash|notebookedit)\b/i);
|
|
155
|
+
if (m) {
|
|
156
|
+
// Pull first absolute-looking path arg, if any.
|
|
157
|
+
const pm = cmd.match(/\b(\/[^\s'"]+|[A-Za-z]:\\[^\s'"]+)/);
|
|
158
|
+
const absPath = pm ? pm[1] : null;
|
|
159
|
+
const sizeArg = args && typeof args.input === 'string' ? args.input.length : 0;
|
|
160
|
+
inc = { dim: 'files_written', count: 1, limit: limits.files_written, path: absPath, bytesCount: sizeArg, bytesLimit: limits.bytes_written };
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (inc !== null) {
|
|
165
|
+
if (inc.limit !== null) {
|
|
166
|
+
const r = await quotaCheckAndIncrement(activeExt.name, inc.dim, inc.count, inc.limit, { homeDir: home, path: inc.path });
|
|
167
|
+
if (!r.allowed) {
|
|
168
|
+
const reason = `quota:${inc.dim} ${r.current + inc.count}/${r.limit}`;
|
|
169
|
+
await logPermissionEvent({
|
|
170
|
+
tool: toolName, extension: activeExt.name, action: mapping.action, target: mapping.target,
|
|
171
|
+
allowed: false, reason, ts: new Date().toISOString(),
|
|
172
|
+
}).catch(() => {});
|
|
173
|
+
return {
|
|
174
|
+
allowed: false, dimension: inc.dim, current: r.current, limit: r.limit, reason,
|
|
175
|
+
response: {
|
|
176
|
+
content: [{ type: 'text', text: `extension "${activeExt.name}" exceeded quota ${inc.dim} (${r.current + inc.count}/${r.limit})` }],
|
|
177
|
+
isError: true,
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
// Bytes side-counter (paired with files_written for ijfw_run write tools).
|
|
183
|
+
if (inc.bytesLimit !== null && typeof inc.bytesCount === 'number' && inc.bytesCount > 0) {
|
|
184
|
+
const r2 = await quotaCheckAndIncrement(activeExt.name, 'bytes_written', inc.bytesCount, inc.bytesLimit, { homeDir: home });
|
|
185
|
+
if (!r2.allowed) {
|
|
186
|
+
const reason = `quota:bytes_written ${r2.current + inc.bytesCount}/${r2.limit}`;
|
|
187
|
+
await logPermissionEvent({
|
|
188
|
+
tool: toolName, extension: activeExt.name, action: mapping.action, target: mapping.target,
|
|
189
|
+
allowed: false, reason, ts: new Date().toISOString(),
|
|
190
|
+
}).catch(() => {});
|
|
191
|
+
return {
|
|
192
|
+
allowed: false, dimension: 'bytes_written', current: r2.current, limit: r2.limit, reason,
|
|
193
|
+
response: {
|
|
194
|
+
content: [{ type: 'text', text: `extension "${activeExt.name}" exceeded quota bytes_written (${r2.current + inc.bytesCount}/${r2.limit})` }],
|
|
195
|
+
isError: true,
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return { allowed: true };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Resolve `manifest.quotas` for the currently active extension by reading the
|
|
207
|
+
* installed manifest. Best-effort: returns {} if the installed manifest can't
|
|
208
|
+
* be found.
|
|
209
|
+
*/
|
|
210
|
+
async function resolveActiveManifestQuotas(activeExt, projectRoot, home) {
|
|
211
|
+
if (!activeExt || !activeExt.name) return {};
|
|
212
|
+
try {
|
|
213
|
+
const r = await findInstalledManifest(activeExt.name, projectRoot, { homeDir: home });
|
|
214
|
+
if (r && r.ok && r.manifest && r.manifest.quotas && typeof r.manifest.quotas === 'object') {
|
|
215
|
+
return r.manifest.quotas;
|
|
216
|
+
}
|
|
217
|
+
} catch { /* best-effort */ }
|
|
218
|
+
return {};
|
|
219
|
+
}
|
|
58
220
|
const SANDBOX_DIR = join(process.env.HOME || homedir(), '.ijfw', 'session-sandbox');
|
|
59
221
|
|
|
60
222
|
// --- Constants ---
|
|
@@ -1285,24 +1447,22 @@ function handleMessage(msg) {
|
|
|
1285
1447
|
activeExt = { __malformed: true };
|
|
1286
1448
|
}
|
|
1287
1449
|
if (activeExt !== null) {
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
});
|
|
1305
|
-
}
|
|
1450
|
+
// B16/SEC-M-03: combined permission + quota gate via exported helper.
|
|
1451
|
+
const home = process.env.HOME || process.env.USERPROFILE || homedir();
|
|
1452
|
+
const manifestQuotas = await resolveActiveManifestQuotas(
|
|
1453
|
+
activeExt,
|
|
1454
|
+
(args && typeof args.projectRoot === 'string') ? args.projectRoot : undefined,
|
|
1455
|
+
home,
|
|
1456
|
+
);
|
|
1457
|
+
const gate = await gatePermissionAndQuota({
|
|
1458
|
+
toolName: name,
|
|
1459
|
+
args: args || {},
|
|
1460
|
+
activeExt,
|
|
1461
|
+
home,
|
|
1462
|
+
manifestQuotas,
|
|
1463
|
+
});
|
|
1464
|
+
if (!gate.allowed && gate.response) {
|
|
1465
|
+
return createResponse(id, gate.response);
|
|
1306
1466
|
}
|
|
1307
1467
|
}
|
|
1308
1468
|
switch (name) {
|
|
@@ -1503,4 +1663,6 @@ process.on('unhandledRejection', (err) => {
|
|
|
1503
1663
|
});
|
|
1504
1664
|
|
|
1505
1665
|
// Export for tests (Node ESM allows this -- only consumed when imported, not on stdio run)
|
|
1666
|
+
// gatePermissionAndQuota is exported inline at its declaration above (B16/SEC-M-03)
|
|
1667
|
+
// so test-server-quota-integration.js can drive it without spinning a server.
|
|
1506
1668
|
export { sanitizeContent, atomicWrite, readMarkdownFile, PROJECT_HASH };
|