@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
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dispatch/signer-cli.js — IJFW v1.4.3 W9-A2 / B15
|
|
3
|
+
*
|
|
4
|
+
* CLI handlers for signing-key management. Exported as the frozen
|
|
5
|
+
* `{ handlers, subcommandHelp }` shape so the Phase D orchestrator can wire
|
|
6
|
+
* them into the top-level dispatch table without per-area editing.
|
|
7
|
+
*
|
|
8
|
+
* Subcommands:
|
|
9
|
+
*
|
|
10
|
+
* keygen <author> [--backend software|ssh-agent] [--ssh-key-comment <c>]
|
|
11
|
+
* - Default backend: software (existing v1.4.0 behavior — generates a
|
|
12
|
+
* fresh Ed25519 keypair on disk).
|
|
13
|
+
* - --backend ssh-agent: NO private-key generation in IJFW. Connects
|
|
14
|
+
* to the running SSH agent, enumerates Ed25519 identities, selects
|
|
15
|
+
* one to enroll. Writes only the public material:
|
|
16
|
+
* ~/.ijfw/keys/<keyId>/public.pem
|
|
17
|
+
* ~/.ijfw/keys/<keyId>/backend.json
|
|
18
|
+
* { backend, pubkey_blob_hex, keyId, ssh_key_comment }
|
|
19
|
+
* The comment is recorded for display only; sign-time identity
|
|
20
|
+
* selection matches on pubkey_blob_hex (SEC-H-03).
|
|
21
|
+
*
|
|
22
|
+
* keygen-fido2 <author>
|
|
23
|
+
* - Deferred stub. Prints a message routing users to ssh-agent or the
|
|
24
|
+
* default software backend. Exits 0 (deferred, not failed).
|
|
25
|
+
*
|
|
26
|
+
* Spec: .planning/1.4.3/HANDOFF-1.4.3.md §B15
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { mkdir, writeFile, chmod } from 'node:fs/promises';
|
|
30
|
+
import { homedir } from 'node:os';
|
|
31
|
+
import { join } from 'node:path';
|
|
32
|
+
|
|
33
|
+
import {
|
|
34
|
+
generatePublisherKeypair,
|
|
35
|
+
} from '../extension-signer.js';
|
|
36
|
+
import {
|
|
37
|
+
listAgentIdentities,
|
|
38
|
+
pubkeyBlobFromPem,
|
|
39
|
+
ed25519PemFromRaw,
|
|
40
|
+
publicKeyFingerprint,
|
|
41
|
+
_testInternals,
|
|
42
|
+
} from '../hardware-signer.js';
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Parse argv-style array (or whitespace-split string) into `{ positional, flags }`.
|
|
46
|
+
* Supports `--flag` (boolean) and `--flag value` (string value).
|
|
47
|
+
*
|
|
48
|
+
* @param {string|string[]} input
|
|
49
|
+
* @returns {{ positional: string[], flags: Record<string, string|boolean> }}
|
|
50
|
+
*/
|
|
51
|
+
function parseArgs(input) {
|
|
52
|
+
const tokens = Array.isArray(input)
|
|
53
|
+
? input.slice()
|
|
54
|
+
: String(input || '').trim().split(/\s+/).filter(Boolean);
|
|
55
|
+
const positional = [];
|
|
56
|
+
const flags = {};
|
|
57
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
58
|
+
const tok = tokens[i];
|
|
59
|
+
if (tok.startsWith('--')) {
|
|
60
|
+
const name = tok.slice(2);
|
|
61
|
+
const next = tokens[i + 1];
|
|
62
|
+
if (next !== undefined && !next.startsWith('--')) {
|
|
63
|
+
flags[name] = next;
|
|
64
|
+
i += 1;
|
|
65
|
+
} else {
|
|
66
|
+
flags[name] = true;
|
|
67
|
+
}
|
|
68
|
+
} else {
|
|
69
|
+
positional.push(tok);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return { positional, flags };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Extract the SSH wire ssh-ed25519 alg prefix used for filtering Ed25519
|
|
77
|
+
* identities returned by the agent.
|
|
78
|
+
*/
|
|
79
|
+
const ED25519_ALG_PREFIX = _testInternals.sshWireString(_testInternals.SSH_ED25519_ALG);
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Build the per-key directory path under the (possibly overridden) home.
|
|
83
|
+
*/
|
|
84
|
+
function keysDir(home, keyId) {
|
|
85
|
+
return join(home, '.ijfw', 'keys', keyId);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Convert an SSH-wire Ed25519 pubkey blob back into PEM. Useful when
|
|
90
|
+
* enrolling — the agent gives us the wire blob, but downstream verify
|
|
91
|
+
* paths want PEM.
|
|
92
|
+
*
|
|
93
|
+
* @param {Buffer} blob
|
|
94
|
+
* @returns {string} PEM
|
|
95
|
+
*/
|
|
96
|
+
function ed25519PemFromBlob(blob) {
|
|
97
|
+
// Blob shape: ssh-string("ssh-ed25519") || ssh-string(raw32).
|
|
98
|
+
// Skip the alg prefix; the trailing string is the raw key.
|
|
99
|
+
const algLen = ED25519_ALG_PREFIX.length;
|
|
100
|
+
// The raw key follows; the leading 4 bytes are the length (always 32).
|
|
101
|
+
const raw = blob.slice(algLen + 4);
|
|
102
|
+
if (raw.length !== 32) {
|
|
103
|
+
throw new Error(`Expected 32-byte Ed25519 raw key, got ${raw.length}`);
|
|
104
|
+
}
|
|
105
|
+
return ed25519PemFromRaw(raw);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Filter agent identities to Ed25519 only.
|
|
110
|
+
*
|
|
111
|
+
* @param {Array<{blob: Buffer, comment: string}>} identities
|
|
112
|
+
* @returns {Array<{blob: Buffer, comment: string}>}
|
|
113
|
+
*/
|
|
114
|
+
function ed25519Only(identities) {
|
|
115
|
+
return identities.filter(
|
|
116
|
+
i => i.blob.length >= ED25519_ALG_PREFIX.length
|
|
117
|
+
&& i.blob.slice(0, ED25519_ALG_PREFIX.length).equals(ED25519_ALG_PREFIX),
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Enrol an existing SSH-agent identity as an IJFW publisher key.
|
|
123
|
+
*
|
|
124
|
+
* Workflow:
|
|
125
|
+
* 1. Connect to SSH_AUTH_SOCK (errors clearly if unavailable).
|
|
126
|
+
* 2. List identities; filter to Ed25519.
|
|
127
|
+
* 3. Resolve a single candidate. Selection precedence:
|
|
128
|
+
* a. If --ssh-key-comment is provided, prefer that comment.
|
|
129
|
+
* b. Otherwise: exactly 1 Ed25519 identity → auto-pick. Multiple
|
|
130
|
+
* identities → fail with usage hint (interactive picker not yet
|
|
131
|
+
* wired into MCP transport).
|
|
132
|
+
* 4. Compute keyId = sha256(SPKI-DER of the agent-key's PEM).
|
|
133
|
+
* 5. Write public.pem + backend.json to ~/.ijfw/keys/<keyId>/.
|
|
134
|
+
*
|
|
135
|
+
* @param {object} args
|
|
136
|
+
* @param {string} args.author informational author label
|
|
137
|
+
* @param {string} [args.sshKeyComment] disambiguator
|
|
138
|
+
* @param {string} [args.home] override ~/.ijfw root (test isolation)
|
|
139
|
+
* @param {string} [args.socketPath] override SSH_AUTH_SOCK (test isolation)
|
|
140
|
+
* @returns {Promise<{ ok: true, keyId: string, dir: string, ssh_key_comment: string, backend: 'ssh-agent' } | { ok: false, error: string }>}
|
|
141
|
+
*/
|
|
142
|
+
async function enrolSshAgentKey(args) {
|
|
143
|
+
const home = args.home || homedir();
|
|
144
|
+
let identities;
|
|
145
|
+
try {
|
|
146
|
+
identities = await listAgentIdentities(args.socketPath);
|
|
147
|
+
} catch (err) {
|
|
148
|
+
return { ok: false, error: err.message };
|
|
149
|
+
}
|
|
150
|
+
const candidates = ed25519Only(identities);
|
|
151
|
+
if (candidates.length === 0) {
|
|
152
|
+
return {
|
|
153
|
+
ok: false,
|
|
154
|
+
error: 'SSH agent has no Ed25519 identities; add one with `ssh-keygen -t ed25519` and `ssh-add`',
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
let chosen;
|
|
158
|
+
if (args.sshKeyComment) {
|
|
159
|
+
const matches = candidates.filter(c => c.comment === args.sshKeyComment);
|
|
160
|
+
if (matches.length === 0) {
|
|
161
|
+
return {
|
|
162
|
+
ok: false,
|
|
163
|
+
error: `SSH agent has no Ed25519 identity with comment ${JSON.stringify(args.sshKeyComment)}`,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
if (matches.length > 1) {
|
|
167
|
+
return {
|
|
168
|
+
ok: false,
|
|
169
|
+
error: `Multiple SSH agent identities share comment ${JSON.stringify(args.sshKeyComment)}; comments are not unique — disambiguate by re-running ssh-add or removing duplicates`,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
chosen = matches[0];
|
|
173
|
+
} else if (candidates.length === 1) {
|
|
174
|
+
chosen = candidates[0];
|
|
175
|
+
} else {
|
|
176
|
+
return {
|
|
177
|
+
ok: false,
|
|
178
|
+
error: `Multiple Ed25519 identities in SSH agent (${candidates.length}); pass --ssh-key-comment <c> to disambiguate. Comments seen: ${candidates.map(c => JSON.stringify(c.comment)).join(', ')}`,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
let pem;
|
|
183
|
+
try {
|
|
184
|
+
pem = ed25519PemFromBlob(chosen.blob);
|
|
185
|
+
} catch (err) {
|
|
186
|
+
return { ok: false, error: `failed to convert agent blob to PEM: ${err.message}` };
|
|
187
|
+
}
|
|
188
|
+
const keyId = publicKeyFingerprint(pem);
|
|
189
|
+
// Belt-and-braces self-consistency check: the blob we just stored should
|
|
190
|
+
// round-trip back through PEM and yield the same blob.
|
|
191
|
+
const roundTripBlob = pubkeyBlobFromPem(pem);
|
|
192
|
+
if (!roundTripBlob.equals(chosen.blob)) {
|
|
193
|
+
return {
|
|
194
|
+
ok: false,
|
|
195
|
+
error: 'internal: pubkey blob round-trip via PEM differed; refusing to enrol',
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const dir = keysDir(home, keyId);
|
|
200
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
201
|
+
try { await chmod(dir, 0o700); } catch { /* best-effort */ }
|
|
202
|
+
await writeFile(join(dir, 'public.pem'), pem, 'utf8');
|
|
203
|
+
try { await chmod(join(dir, 'public.pem'), 0o644); } catch { /* best-effort */ }
|
|
204
|
+
const backendJson = {
|
|
205
|
+
backend: 'ssh-agent',
|
|
206
|
+
pubkey_blob_hex: chosen.blob.toString('hex'),
|
|
207
|
+
keyId,
|
|
208
|
+
ssh_key_comment: chosen.comment, // display only — never used for matching
|
|
209
|
+
};
|
|
210
|
+
await writeFile(
|
|
211
|
+
join(dir, 'backend.json'),
|
|
212
|
+
JSON.stringify(backendJson, null, 2) + '\n',
|
|
213
|
+
'utf8',
|
|
214
|
+
);
|
|
215
|
+
if (typeof args.author === 'string' && args.author.length > 0) {
|
|
216
|
+
try {
|
|
217
|
+
await writeFile(
|
|
218
|
+
join(dir, 'author.txt'),
|
|
219
|
+
`${args.author}\n${new Date().toISOString()}\n`,
|
|
220
|
+
'utf8',
|
|
221
|
+
);
|
|
222
|
+
} catch { /* non-fatal */ }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
ok: true,
|
|
227
|
+
keyId,
|
|
228
|
+
dir,
|
|
229
|
+
ssh_key_comment: chosen.comment,
|
|
230
|
+
backend: 'ssh-agent',
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* keygen handler. Dispatches to software (default) or ssh-agent backend
|
|
236
|
+
* per --backend.
|
|
237
|
+
*
|
|
238
|
+
* @param {string|string[]} args
|
|
239
|
+
* @param {object} [ctx]
|
|
240
|
+
* @returns {Promise<object>}
|
|
241
|
+
*/
|
|
242
|
+
async function keygenHandler(args, ctx = {}) {
|
|
243
|
+
const { positional, flags } = parseArgs(args);
|
|
244
|
+
const author = positional[0] || '';
|
|
245
|
+
const backend = flags.backend === true ? undefined : flags.backend;
|
|
246
|
+
|
|
247
|
+
if (backend === undefined || backend === 'software') {
|
|
248
|
+
const kp = await generatePublisherKeypair(author);
|
|
249
|
+
return {
|
|
250
|
+
ok: true,
|
|
251
|
+
backend: 'software',
|
|
252
|
+
keyId: kp.keyId,
|
|
253
|
+
dir: kp.dir,
|
|
254
|
+
publicKey: kp.publicKey,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (backend === 'ssh-agent') {
|
|
259
|
+
const sshKeyComment = flags['ssh-key-comment'] === true
|
|
260
|
+
? undefined
|
|
261
|
+
: flags['ssh-key-comment'];
|
|
262
|
+
return enrolSshAgentKey({
|
|
263
|
+
author,
|
|
264
|
+
sshKeyComment,
|
|
265
|
+
home: ctx.home,
|
|
266
|
+
socketPath: ctx.socketPath,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Fail-closed per SEC-L-02. Unknown backend names must not silently fall
|
|
271
|
+
// through to software.
|
|
272
|
+
return {
|
|
273
|
+
ok: false,
|
|
274
|
+
error: `Unsupported --backend value ${JSON.stringify(backend)}; valid: software, ssh-agent`,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* keygen-fido2 handler — deferred stub.
|
|
280
|
+
*
|
|
281
|
+
* Native libfido2 bindings would be IJFW's first native prod dep; that's
|
|
282
|
+
* a v1.5.0+ architecture decision. For v1.4.3, FIDO2-backed signing is
|
|
283
|
+
* available transitively via ssh-agent (modern YubiKey/Solokey speak
|
|
284
|
+
* SSH agent natively).
|
|
285
|
+
*
|
|
286
|
+
* @returns {Promise<{ ok: true, deferred: true, message: string }>}
|
|
287
|
+
*/
|
|
288
|
+
async function keygenFido2Handler(_args, ctx = {}) {
|
|
289
|
+
const msg = 'FIDO2/libfido2 path deferred to v1.5.0; use --backend ssh-agent or default software backend';
|
|
290
|
+
// Write to stderr for CLI visibility without disturbing JSON-stdout
|
|
291
|
+
// consumers. Optionally inject a writer via ctx for tests.
|
|
292
|
+
const stderr = ctx.stderr || process.stderr;
|
|
293
|
+
try { stderr.write(`${msg}\n`); } catch { /* ignore */ }
|
|
294
|
+
return { ok: true, deferred: true, message: msg };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export const handlers = Object.freeze({
|
|
298
|
+
keygen: keygenHandler,
|
|
299
|
+
'keygen-fido2': keygenFido2Handler,
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
export const subcommandHelp = Object.freeze({
|
|
303
|
+
keygen: 'keygen <author> [--backend software|ssh-agent] [--ssh-key-comment <c>] — generate or enrol a publisher signing key',
|
|
304
|
+
'keygen-fido2': 'keygen-fido2 <author> — deferred to v1.5.0; use --backend ssh-agent instead',
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
// Test-only exports.
|
|
308
|
+
export const _testOnly = Object.freeze({
|
|
309
|
+
parseArgs,
|
|
310
|
+
enrolSshAgentKey,
|
|
311
|
+
});
|
|
@@ -36,6 +36,7 @@ import { randomBytes } from 'node:crypto';
|
|
|
36
36
|
import { spawn } from 'node:child_process';
|
|
37
37
|
import { get as httpsGet } from 'node:https';
|
|
38
38
|
import { pipeline } from 'node:stream/promises';
|
|
39
|
+
import { createInterface } from 'node:readline';
|
|
39
40
|
|
|
40
41
|
import {
|
|
41
42
|
validateExtensionManifest,
|
|
@@ -79,6 +80,35 @@ const EXTENSION_NAME_PATTERN = /^(@[a-z0-9-]+\/)?[a-z][a-z0-9-]*$/;
|
|
|
79
80
|
// Verdicts considered acceptable for a normal install (3/3 lenses).
|
|
80
81
|
const ACCEPTABLE_VERDICTS = new Set(['PASS', 'CONDITIONAL']);
|
|
81
82
|
|
|
83
|
+
// --- helpers: TTY confirmation ---------------------------------------------
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Prompt the user to confirm an untrusted publisher by typing the last 8 chars
|
|
87
|
+
* of the keyId. Returns true on match, false on mismatch or EOF.
|
|
88
|
+
* Only called when process.stdin.isTTY === true.
|
|
89
|
+
*
|
|
90
|
+
* @param {string} keyId
|
|
91
|
+
* @returns {Promise<boolean>}
|
|
92
|
+
*/
|
|
93
|
+
export async function promptUntrustedConfirmation(keyId) {
|
|
94
|
+
const expected = keyId.slice(-8);
|
|
95
|
+
return new Promise((resolve) => {
|
|
96
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
|
|
97
|
+
const prompt =
|
|
98
|
+
`⚠️ Extension is signed by publisher keyId ${keyId} but ${keyId} is not in your trusted publishers store.\n` +
|
|
99
|
+
` Type the LAST 8 CHARS of the keyId (lowercase hex) to confirm: `;
|
|
100
|
+
let answered = false;
|
|
101
|
+
rl.question(prompt, (line) => {
|
|
102
|
+
answered = true;
|
|
103
|
+
rl.close();
|
|
104
|
+
resolve(line.trim() === expected);
|
|
105
|
+
});
|
|
106
|
+
rl.once('close', () => {
|
|
107
|
+
if (!answered) resolve(false); // EOF / ctrl-D
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
82
112
|
// --- helpers: source resolution -------------------------------------------
|
|
83
113
|
|
|
84
114
|
/**
|
|
@@ -763,6 +793,15 @@ export async function installExtension(source, opts = {}) {
|
|
|
763
793
|
errors: [`signature: verify failed: ${sigCheck.reason}${kidHint}`],
|
|
764
794
|
};
|
|
765
795
|
}
|
|
796
|
+
// B11: when stdin is a TTY, require the user to confirm by typing the
|
|
797
|
+
// last 8 chars of the keyId. Non-TTY (CI / scripted) falls through to
|
|
798
|
+
// the stderr-warn path unchanged — no regression for automation.
|
|
799
|
+
if (process.stdin.isTTY === true && sigCheck.publisherKeyId) {
|
|
800
|
+
const confirmed = await promptUntrustedConfirmation(sigCheck.publisherKeyId);
|
|
801
|
+
if (!confirmed) {
|
|
802
|
+
return { ok: false, errors: ['signature: untrusted confirmation cancelled'] };
|
|
803
|
+
}
|
|
804
|
+
}
|
|
766
805
|
process.stderr.write(
|
|
767
806
|
`[ijfw] extension-installer: signature unverified for ${manifest.name}: ${sigCheck.reason}\n`,
|
|
768
807
|
);
|
|
@@ -297,5 +297,30 @@ export function validateExtensionManifest(obj) {
|
|
|
297
297
|
}
|
|
298
298
|
}
|
|
299
299
|
|
|
300
|
+
// === B15: publisher_key_backend ===
|
|
301
|
+
if (obj.publisher_key_backend !== undefined) {
|
|
302
|
+
if (obj.publisher_key_backend !== 'software' && obj.publisher_key_backend !== 'ssh-agent') {
|
|
303
|
+
errors.push("publisher_key_backend: must be 'software' or 'ssh-agent'");
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// === B16: quotas ===
|
|
308
|
+
if (obj.quotas !== undefined) {
|
|
309
|
+
if (obj.quotas === null || typeof obj.quotas !== 'object' || Array.isArray(obj.quotas)) {
|
|
310
|
+
errors.push('quotas: must be an object');
|
|
311
|
+
} else {
|
|
312
|
+
const ALLOWED_DIMS = ['max_files_written', 'max_bytes_written', 'max_wall_clock_ms'];
|
|
313
|
+
for (const [k, v] of Object.entries(obj.quotas)) {
|
|
314
|
+
if (!ALLOWED_DIMS.includes(k)) {
|
|
315
|
+
// forward-compat: unknown quota dimensions ignored with warning (no error push)
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
if (!Number.isInteger(v) || v <= 0) {
|
|
319
|
+
errors.push(`quotas.${k}: must be a positive integer`);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
300
325
|
return { valid: errors.length === 0, errors };
|
|
301
326
|
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// IJFW v1.4.1 B7 -- shared extension permission checker.
|
|
3
|
+
//
|
|
4
|
+
// Reads ~/.ijfw/state/active-extension.json. Accepts a JSON payload on stdin
|
|
5
|
+
// with shape { hook_event_name, tool_name, tool_input } (Claude/Codex shape --
|
|
6
|
+
// callers reshape platform-specific payloads before piping here).
|
|
7
|
+
//
|
|
8
|
+
// Exit 0 = allow. Exit 1 = deny (stderr message emitted).
|
|
9
|
+
// With no active-extension.json: always exit 0 (backwards-compat invariant).
|
|
10
|
+
//
|
|
11
|
+
// Also appends one JSON line per check to ~/.ijfw/state/permission-events.jsonl
|
|
12
|
+
// (best-effort: failures are swallowed so logging never breaks tool dispatch).
|
|
13
|
+
|
|
14
|
+
import { readFile, appendFile, mkdir } from 'node:fs/promises';
|
|
15
|
+
import { homedir } from 'node:os';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
// B16/SEC-H-01 — quota enforcement on tier-2 hook side. Mirrors the tier-1
|
|
18
|
+
// gate in server.js so both paths converge on the same counters.
|
|
19
|
+
import { checkAndIncrement as quotaCheckAndIncrement } from './extension-quota-tracker.js';
|
|
20
|
+
|
|
21
|
+
async function emitEvent(home, extensionName, toolName, allowed, reason) {
|
|
22
|
+
try {
|
|
23
|
+
const stateDir = join(home, '.ijfw', 'state');
|
|
24
|
+
await mkdir(stateDir, { recursive: true });
|
|
25
|
+
const event = { ts: new Date().toISOString(), extension: extensionName, tool: toolName, allowed };
|
|
26
|
+
if (reason) event.reason = reason;
|
|
27
|
+
await appendFile(join(stateDir, 'permission-events.jsonl'), JSON.stringify(event) + '\n', 'utf8');
|
|
28
|
+
} catch {
|
|
29
|
+
// Best-effort: swallow all errors.
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const home = process.env.HOME || process.env.USERPROFILE || homedir();
|
|
34
|
+
const stateFile = join(home, '.ijfw', 'state', 'active-extension.json');
|
|
35
|
+
|
|
36
|
+
let active;
|
|
37
|
+
try {
|
|
38
|
+
const raw = await readFile(stateFile, 'utf8');
|
|
39
|
+
active = JSON.parse(raw);
|
|
40
|
+
if (!active || typeof active !== 'object' || !active.name || !active.permissions) {
|
|
41
|
+
process.stderr.write('ijfw extension permission check: malformed active-extension state\n');
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
} catch (err) {
|
|
45
|
+
if (err.code === 'ENOENT') process.exit(0); // no active extension -- allow
|
|
46
|
+
process.stderr.write(`ijfw extension permission check: ${err.message}\n`);
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// B18 — surface cross-IDE divergence as a non-blocking stderr warning.
|
|
51
|
+
// Mirrors runtime-mediator.maybeWarnDivergence on the tier-2 hook side.
|
|
52
|
+
try {
|
|
53
|
+
const { detectCrossIdeDivergence } = await import('./active-extension-writer.js');
|
|
54
|
+
const verdict = await detectCrossIdeDivergence({ homeDir: home });
|
|
55
|
+
if (verdict && verdict.divergent) {
|
|
56
|
+
const age = typeof verdict.age_seconds === 'number' ? `${verdict.age_seconds}s ago` : 'unknown time ago';
|
|
57
|
+
process.stderr.write(
|
|
58
|
+
`[ijfw] active extension last activated by '${verdict.last_writer}' ${age}; this IDE is '${verdict.current_ide}'\n`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
} catch {
|
|
62
|
+
// Best-effort: divergence detection never blocks the hook.
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const chunks = [];
|
|
66
|
+
for await (const c of process.stdin) chunks.push(c);
|
|
67
|
+
const payload_str = chunks.join('');
|
|
68
|
+
if (!payload_str.trim()) process.exit(0);
|
|
69
|
+
|
|
70
|
+
let req;
|
|
71
|
+
try { req = JSON.parse(payload_str); } catch { process.exit(0); }
|
|
72
|
+
|
|
73
|
+
const tool = req.tool_name || '';
|
|
74
|
+
const writes = new Set(active.permissions.writes || []);
|
|
75
|
+
const reads = new Set(active.permissions.reads || []);
|
|
76
|
+
const writeTools = new Set(['Edit', 'Write', 'NotebookEdit', 'Bash']);
|
|
77
|
+
const readTools = new Set(['Read', 'Glob', 'Grep', 'LS', 'NotebookRead', 'WebFetch', 'WebSearch']);
|
|
78
|
+
|
|
79
|
+
const has = (set, want) =>
|
|
80
|
+
set.has(want) ||
|
|
81
|
+
set.has('*') ||
|
|
82
|
+
[...set].some((p) => p.endsWith(':*') && want.startsWith(p.slice(0, -1)));
|
|
83
|
+
|
|
84
|
+
if (writeTools.has(tool) && !has(writes, `tool:${tool.toLowerCase()}`) && !has(writes, 'tool:*')) {
|
|
85
|
+
const reason = `not in permissions.writes`;
|
|
86
|
+
process.stderr.write(`extension "${active.name}" not permitted to use ${tool} (declare tool:${tool.toLowerCase()} in permissions.writes)\n`);
|
|
87
|
+
await emitEvent(home, active.name, tool, false, reason);
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
if (readTools.has(tool) && !has(reads, `tool:${tool.toLowerCase()}`) && !has(reads, 'tool:*')) {
|
|
91
|
+
const reason = `not in permissions.reads`;
|
|
92
|
+
process.stderr.write(`extension "${active.name}" not permitted to use ${tool} (declare tool:${tool.toLowerCase()} in permissions.reads)\n`);
|
|
93
|
+
await emitEvent(home, active.name, tool, false, reason);
|
|
94
|
+
process.exit(1);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// B16: quota enforcement on tier-2 hook side. Permission has passed; if the
|
|
98
|
+
// active extension declared quotas, check the relevant dimension.
|
|
99
|
+
const quotas = (active && typeof active.quotas === 'object' && active.quotas) ? active.quotas : null;
|
|
100
|
+
if (quotas && writeTools.has(tool)) {
|
|
101
|
+
const lc = tool.toLowerCase();
|
|
102
|
+
// files_written dimension for Edit/Write/NotebookEdit/Bash.
|
|
103
|
+
if (typeof quotas.max_files_written === 'number' && quotas.max_files_written > 0) {
|
|
104
|
+
const filePath = (req.tool_input && (req.tool_input.file_path || req.tool_input.path || req.tool_input.notebook_path)) || null;
|
|
105
|
+
const r = await quotaCheckAndIncrement(active.name, 'files_written', 1, quotas.max_files_written, { homeDir: home, path: typeof filePath === 'string' ? filePath : null });
|
|
106
|
+
if (!r.allowed) {
|
|
107
|
+
process.stderr.write(`[ijfw] extension '${active.name}' exceeded quota files_written (${r.current + 1}/${r.limit})\n`);
|
|
108
|
+
await emitEvent(home, active.name, tool, false, `quota:files_written ${r.current + 1}/${r.limit}`);
|
|
109
|
+
process.exit(1);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (typeof quotas.max_bytes_written === 'number' && quotas.max_bytes_written > 0) {
|
|
113
|
+
let bytes = 0;
|
|
114
|
+
try {
|
|
115
|
+
const ti = req.tool_input || {};
|
|
116
|
+
if (typeof ti.content === 'string') bytes += ti.content.length;
|
|
117
|
+
if (typeof ti.new_string === 'string') bytes += ti.new_string.length;
|
|
118
|
+
if (typeof ti.command === 'string' && lc === 'bash') bytes += ti.command.length;
|
|
119
|
+
} catch { /* defensive */ }
|
|
120
|
+
if (bytes > 0) {
|
|
121
|
+
const r = await quotaCheckAndIncrement(active.name, 'bytes_written', bytes, quotas.max_bytes_written, { homeDir: home });
|
|
122
|
+
if (!r.allowed) {
|
|
123
|
+
process.stderr.write(`[ijfw] extension '${active.name}' exceeded quota bytes_written (${r.current + bytes}/${r.limit})\n`);
|
|
124
|
+
await emitEvent(home, active.name, tool, false, `quota:bytes_written ${r.current + bytes}/${r.limit}`);
|
|
125
|
+
process.exit(1);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (quotas && typeof quotas.max_wall_clock_ms === 'number' && quotas.max_wall_clock_ms > 0) {
|
|
131
|
+
const r = await quotaCheckAndIncrement(active.name, 'wall_clock_ms', 0, quotas.max_wall_clock_ms, { homeDir: home });
|
|
132
|
+
if (!r.allowed) {
|
|
133
|
+
process.stderr.write(`[ijfw] extension '${active.name}' exceeded quota wall_clock_ms (${r.current}/${r.limit})\n`);
|
|
134
|
+
await emitEvent(home, active.name, tool, false, `quota:wall_clock_ms ${r.current}/${r.limit}`);
|
|
135
|
+
process.exit(1);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
await emitEvent(home, active.name, tool, true);
|
|
140
|
+
process.exit(0);
|