@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.
@@ -89,20 +89,16 @@ export async function readRecentReceipts(projectRoot, limit = 50) {
89
89
  }
90
90
 
91
91
  /**
92
- * detectPatterns(receipts, opts)
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
- export function detectPatterns(receipts, opts = {}) {
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
  }
@@ -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
- const mapping = toolNameToActionTarget(name, args || {});
1289
- if (mapping) {
1290
- const check = checkPermission(mapping.action, mapping.target, activeExt);
1291
- if (!check.allowed) {
1292
- await logPermissionEvent({
1293
- tool: name,
1294
- extension: activeExt && activeExt.name ? activeExt.name : null,
1295
- action: mapping.action,
1296
- target: mapping.target,
1297
- allowed: false,
1298
- reason: check.reason,
1299
- ts: new Date().toISOString(),
1300
- }).catch(() => {});
1301
- return createResponse(id, {
1302
- content: [{ type: 'text', text: `extension permission denied: ${check.reason}` }],
1303
- isError: true,
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 };