@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/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 };