@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.
@@ -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
+ });
@@ -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
  }
@@ -14,6 +14,9 @@
14
14
  import { readFile, appendFile, mkdir } from 'node:fs/promises';
15
15
  import { homedir } from 'node:os';
16
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';
17
20
 
18
21
  async function emitEvent(home, extensionName, toolName, allowed, reason) {
19
22
  try {
@@ -44,6 +47,21 @@ try {
44
47
  process.exit(1);
45
48
  }
46
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
+
47
65
  const chunks = [];
48
66
  for await (const c of process.stdin) chunks.push(c);
49
67
  const payload_str = chunks.join('');
@@ -75,5 +93,48 @@ if (readTools.has(tool) && !has(reads, `tool:${tool.toLowerCase()}`) && !has(rea
75
93
  await emitEvent(home, active.name, tool, false, reason);
76
94
  process.exit(1);
77
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
+
78
139
  await emitEvent(home, active.name, tool, true);
79
140
  process.exit(0);