@ijfw/memory-server 1.4.1 → 1.4.4
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/cross-orchestrator.js +164 -2
- package/src/dashboard-aggregator.js +165 -0
- package/src/dashboard-charts.js +239 -0
- package/src/dashboard-client-planning.html +273 -0
- package/src/dashboard-client.html +213 -1
- package/src/dashboard-server.js +186 -0
- package/src/dispatch/active-cli.js +141 -0
- package/src/dispatch/extension.js +40 -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/dispatch/wave-cli.js +128 -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/orchestrator/review.js +101 -0
- package/src/orchestrator/status-protocol.js +168 -0
- package/src/orchestrator/verification-gate.js +97 -0
- package/src/orchestrator/wave-state.js +255 -0
- package/src/runtime-mediator.js +31 -0
- package/src/server.js +180 -18
- package/src/swarm-config.js +32 -8
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 };
|
package/src/swarm-config.js
CHANGED
|
@@ -12,6 +12,9 @@ import { join } from 'node:path';
|
|
|
12
12
|
export const SCHEMA = {
|
|
13
13
|
project_type: 'string',
|
|
14
14
|
specialists: [{ id: 'string', role: 'string', agent_type: 'string' }],
|
|
15
|
+
// v1.4.4 N10: auditor roster for phase-e-auto
|
|
16
|
+
auditors: ['string'], // roster IDs to use for Cross-Audit Phase; default ['codex','gemini','claude']
|
|
17
|
+
auditor_count: 'number', // number of lenses; default 3
|
|
15
18
|
};
|
|
16
19
|
|
|
17
20
|
const BASE = [
|
|
@@ -22,13 +25,20 @@ const BASE = [
|
|
|
22
25
|
const TESTS_SPECIALIST = { id: 'tests', role: 'Test coverage', agent_type: 'pr-test-analyzer' };
|
|
23
26
|
const TYPES_SPECIALIST = { id: 'types', role: 'Type invariants', agent_type: 'type-design-analyzer' };
|
|
24
27
|
|
|
28
|
+
// v1.4.4 N6 — 5 specialist agents addressing v1.4.3 build pain points
|
|
29
|
+
const DOC_VERIFIER = { id: 'doc-verifier', role: 'Doc accuracy', agent_type: 'ijfw-doc-verifier' };
|
|
30
|
+
const PATTERN_MAPPER = { id: 'pattern-mapper', role: 'Onboarding patterns', agent_type: 'ijfw-pattern-mapper' };
|
|
31
|
+
const SECURITY_AUDITOR = { id: 'security-auditor', role: 'Security mitigations', agent_type: 'ijfw-security-auditor' };
|
|
32
|
+
const INTEGRATION_CHECKER = { id: 'integration-checker', role: 'E2E flow verification', agent_type: 'ijfw-integration-checker' };
|
|
33
|
+
const NYQUIST_AUDITOR = { id: 'nyquist-auditor', role: 'Coverage gaps', agent_type: 'ijfw-nyquist-auditor' };
|
|
34
|
+
|
|
25
35
|
export const DEFAULT_SPECIALISTS = {
|
|
26
|
-
node: [...BASE, TESTS_SPECIALIST],
|
|
27
|
-
python: [...BASE, TESTS_SPECIALIST],
|
|
28
|
-
typed: [...BASE, TESTS_SPECIALIST, TYPES_SPECIALIST],
|
|
29
|
-
go: [...BASE],
|
|
30
|
-
rust: [...BASE],
|
|
31
|
-
other: [...BASE],
|
|
36
|
+
node: [...BASE, TESTS_SPECIALIST, DOC_VERIFIER, PATTERN_MAPPER, SECURITY_AUDITOR, INTEGRATION_CHECKER, NYQUIST_AUDITOR],
|
|
37
|
+
python: [...BASE, TESTS_SPECIALIST, DOC_VERIFIER, PATTERN_MAPPER, SECURITY_AUDITOR, INTEGRATION_CHECKER, NYQUIST_AUDITOR],
|
|
38
|
+
typed: [...BASE, TESTS_SPECIALIST, TYPES_SPECIALIST, DOC_VERIFIER, PATTERN_MAPPER, SECURITY_AUDITOR, INTEGRATION_CHECKER, NYQUIST_AUDITOR],
|
|
39
|
+
go: [...BASE, DOC_VERIFIER, PATTERN_MAPPER, SECURITY_AUDITOR, INTEGRATION_CHECKER, NYQUIST_AUDITOR],
|
|
40
|
+
rust: [...BASE, DOC_VERIFIER, PATTERN_MAPPER, SECURITY_AUDITOR, INTEGRATION_CHECKER, NYQUIST_AUDITOR],
|
|
41
|
+
other: [...BASE, DOC_VERIFIER, PATTERN_MAPPER, SECURITY_AUDITOR, INTEGRATION_CHECKER, NYQUIST_AUDITOR],
|
|
32
42
|
};
|
|
33
43
|
|
|
34
44
|
// Detects project type from filesystem signals in projectDir.
|
|
@@ -51,10 +61,22 @@ export function detectProjectType(projectDir) {
|
|
|
51
61
|
return 'other';
|
|
52
62
|
}
|
|
53
63
|
|
|
64
|
+
// Default auditor IDs for Cross-Audit Phase (N10).
|
|
65
|
+
export const DEFAULT_AUDITORS = ['codex', 'gemini', 'claude'];
|
|
66
|
+
export const DEFAULT_AUDITOR_COUNT = 3;
|
|
67
|
+
|
|
68
|
+
// Merge v1.4.4 N10 defaults into a raw config object (non-destructive).
|
|
69
|
+
function applySwarmDefaults(config) {
|
|
70
|
+
const out = { ...config };
|
|
71
|
+
if (!Array.isArray(out.auditors)) out.auditors = [...DEFAULT_AUDITORS];
|
|
72
|
+
if (typeof out.auditor_count !== 'number' || out.auditor_count < 1) out.auditor_count = DEFAULT_AUDITOR_COUNT;
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
54
76
|
// Returns a fresh default config object for the given project type.
|
|
55
77
|
function buildDefault(projectType) {
|
|
56
78
|
const specialists = DEFAULT_SPECIALISTS[projectType] ?? DEFAULT_SPECIALISTS.other;
|
|
57
|
-
return { project_type: projectType, specialists: specialists.map(s => ({ ...s })) };
|
|
79
|
+
return applySwarmDefaults({ project_type: projectType, specialists: specialists.map(s => ({ ...s })) });
|
|
58
80
|
}
|
|
59
81
|
|
|
60
82
|
// Reads .ijfw/swarm.json if present, otherwise detects type, generates
|
|
@@ -63,7 +85,9 @@ export function loadSwarmConfig(projectDir) {
|
|
|
63
85
|
const swarmPath = join(projectDir, '.ijfw', 'swarm.json');
|
|
64
86
|
|
|
65
87
|
if (existsSync(swarmPath)) {
|
|
66
|
-
|
|
88
|
+
const raw = JSON.parse(readFileSync(swarmPath, 'utf8'));
|
|
89
|
+
// Merge defaults for v1.4.4 N10 fields so older swarm.json files work.
|
|
90
|
+
return applySwarmDefaults(raw);
|
|
67
91
|
}
|
|
68
92
|
|
|
69
93
|
const projectType = detectProjectType(projectDir);
|