create-claude-cabinet 0.29.13 → 0.30.0
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/lib/cli.js +16 -0
- package/package.json +1 -1
- package/templates/handoff/capture-and-encrypt.mjs +67 -0
- package/templates/handoff/decrypt-credential.mjs +72 -0
- package/templates/handoff/example-checklist.yaml +81 -0
- package/templates/handoff/generate-keys.mjs +35 -0
- package/templates/handoff/handoff-checklist.mjs +172 -0
- package/templates/handoff/handoff-crypto.mjs +102 -0
- package/templates/handoff/handoff-transport.mjs +105 -0
- package/templates/handoff/schema.md +92 -0
- package/templates/handoff/secure-input.mjs +99 -0
- package/templates/site-audit-runtime/package.json +1 -1
- package/templates/site-audit-runtime/src/checks/axe-core.mjs +13 -6
- package/templates/site-audit-runtime/src/checks/lighthouse.mjs +15 -1
- package/templates/site-audit-runtime/src/checks/pa11y.mjs +2 -2
- package/templates/site-audit-runtime/src/report.mjs +7 -0
- package/templates/skills/cabinet-mantine-quality/SKILL.md +21 -0
- package/templates/skills/handoff/SKILL.md +122 -0
- package/templates/skills/handoff-add/SKILL.md +44 -0
- package/templates/skills/handoff-ask/SKILL.md +45 -0
- package/templates/skills/handoff-create/SKILL.md +161 -0
- package/templates/skills/handoff-progress/SKILL.md +55 -0
- package/templates/skills/handoff-status/SKILL.md +86 -0
package/lib/cli.js
CHANGED
|
@@ -592,6 +592,22 @@ const MODULES = {
|
|
|
592
592
|
],
|
|
593
593
|
postInstall: 'site-audit-setup',
|
|
594
594
|
},
|
|
595
|
+
handoff: {
|
|
596
|
+
name: 'Handoff (secure credential collection)',
|
|
597
|
+
description: 'Skill-based credential handoff for consulting engagements. Encrypted credential capture via OS dialog, pluggable transport (email/MCP/file), bidirectional messaging. Six skills across consultant and client sides.',
|
|
598
|
+
mandatory: false,
|
|
599
|
+
default: false,
|
|
600
|
+
lean: false,
|
|
601
|
+
templates: [
|
|
602
|
+
'skills/handoff',
|
|
603
|
+
'skills/handoff-progress',
|
|
604
|
+
'skills/handoff-ask',
|
|
605
|
+
'skills/handoff-create',
|
|
606
|
+
'skills/handoff-add',
|
|
607
|
+
'skills/handoff-status',
|
|
608
|
+
'handoff',
|
|
609
|
+
],
|
|
610
|
+
},
|
|
595
611
|
};
|
|
596
612
|
|
|
597
613
|
/** Recursively collect all relative file paths under a directory. */
|
package/package.json
CHANGED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
//
|
|
3
|
+
// Single-subprocess credential capture + encryption.
|
|
4
|
+
// Plaintext never exits this process, never enters Claude's context or Anthropic's API.
|
|
5
|
+
// stdout: serialized encrypted envelope (base64) only.
|
|
6
|
+
//
|
|
7
|
+
import { readFile } from 'node:fs/promises';
|
|
8
|
+
import { captureSecureInput, detectPlatform, DialogCancelledError, DialogUnavailableError } from './secure-input.mjs';
|
|
9
|
+
import { encryptCredential, serializeEnvelope } from './handoff-crypto.mjs';
|
|
10
|
+
|
|
11
|
+
const args = process.argv.slice(2);
|
|
12
|
+
|
|
13
|
+
function getArg(name) {
|
|
14
|
+
const i = args.indexOf(name);
|
|
15
|
+
return i >= 0 ? args[i + 1] : null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const prompt = getArg('--prompt');
|
|
19
|
+
const publicKeyPath = getArg('--public-key');
|
|
20
|
+
const testMode = args.includes('--test');
|
|
21
|
+
|
|
22
|
+
if (!testMode && (!prompt || !publicKeyPath)) {
|
|
23
|
+
console.error('Usage: node capture-and-encrypt.mjs --prompt <text> --public-key <path>');
|
|
24
|
+
console.error(' node capture-and-encrypt.mjs --test');
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
if (testMode) {
|
|
30
|
+
const { generateKeypair, decryptCredential, deserializeEnvelope } = await import('./handoff-crypto.mjs');
|
|
31
|
+
const plaintext = 'test-credential-value';
|
|
32
|
+
const { publicKey, privateKey } = await generateKeypair();
|
|
33
|
+
const envelope = await encryptCredential(plaintext, publicKey);
|
|
34
|
+
const serialized = serializeEnvelope(envelope);
|
|
35
|
+
const recovered = deserializeEnvelope(serialized);
|
|
36
|
+
const decrypted = await decryptCredential(recovered, privateKey);
|
|
37
|
+
|
|
38
|
+
if (decrypted !== plaintext) {
|
|
39
|
+
console.error(JSON.stringify({ error: 'test_failed', detail: 'Roundtrip mismatch' }));
|
|
40
|
+
process.exit(4);
|
|
41
|
+
}
|
|
42
|
+
console.log(JSON.stringify({
|
|
43
|
+
ok: true,
|
|
44
|
+
platform: detectPlatform(),
|
|
45
|
+
envelope_id: envelope.envelope_id,
|
|
46
|
+
}));
|
|
47
|
+
process.exit(0);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const publicKeyJWK = JSON.parse(await readFile(publicKeyPath, 'utf-8'));
|
|
51
|
+
const plaintext = await captureSecureInput(prompt);
|
|
52
|
+
const envelope = await encryptCredential(plaintext, publicKeyJWK);
|
|
53
|
+
// Plaintext is now only in local `plaintext` var — GC will collect it.
|
|
54
|
+
// Only the encrypted envelope reaches stdout.
|
|
55
|
+
process.stdout.write(serializeEnvelope(envelope));
|
|
56
|
+
} catch (err) {
|
|
57
|
+
if (err instanceof DialogCancelledError) {
|
|
58
|
+
console.error(JSON.stringify({ error: 'cancelled' }));
|
|
59
|
+
process.exit(2);
|
|
60
|
+
}
|
|
61
|
+
if (err instanceof DialogUnavailableError) {
|
|
62
|
+
console.error(JSON.stringify({ error: 'no_dialog', platform: err.platform }));
|
|
63
|
+
process.exit(3);
|
|
64
|
+
}
|
|
65
|
+
console.error(JSON.stringify({ error: 'encrypt_failed', detail: err.message }));
|
|
66
|
+
process.exit(4);
|
|
67
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
//
|
|
3
|
+
// Decrypt a credential envelope using the consultant's passphrase-protected private key.
|
|
4
|
+
// Passphrase captured via secure OS dialog — never enters Claude's context.
|
|
5
|
+
// stdout: decrypted plaintext (displayed once by the skill, not stored).
|
|
6
|
+
//
|
|
7
|
+
import { readFile } from 'node:fs/promises';
|
|
8
|
+
import { captureSecureInput, DialogCancelledError, DialogUnavailableError } from './secure-input.mjs';
|
|
9
|
+
import { decryptCredential, decryptPrivateKey, deserializeEnvelope } from './handoff-crypto.mjs';
|
|
10
|
+
|
|
11
|
+
const args = process.argv.slice(2);
|
|
12
|
+
|
|
13
|
+
function getArg(name) {
|
|
14
|
+
const i = args.indexOf(name);
|
|
15
|
+
return i >= 0 ? args[i + 1] : null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const envelopeInput = getArg('--envelope');
|
|
19
|
+
const privateKeyPath = getArg('--private-key') || './keys/consultant.priv.jwk.enc';
|
|
20
|
+
const testMode = args.includes('--test');
|
|
21
|
+
|
|
22
|
+
if (!testMode && !envelopeInput) {
|
|
23
|
+
console.error('Usage: node decrypt-credential.mjs --envelope <base64> [--private-key <path>]');
|
|
24
|
+
console.error(' node decrypt-credential.mjs --test');
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
if (testMode) {
|
|
30
|
+
const { generateKeypair, encryptCredential, encryptPrivateKey, serializeEnvelope } = await import('./handoff-crypto.mjs');
|
|
31
|
+
const testPassphrase = 'test-pass-123';
|
|
32
|
+
const testPlaintext = 'test-secret-value';
|
|
33
|
+
|
|
34
|
+
const { publicKey, privateKey } = await generateKeypair();
|
|
35
|
+
const encrypted = await encryptPrivateKey(privateKey, testPassphrase);
|
|
36
|
+
const envelope = await encryptCredential(testPlaintext, publicKey);
|
|
37
|
+
const serialized = serializeEnvelope(envelope);
|
|
38
|
+
|
|
39
|
+
const recovered = decryptPrivateKey(encrypted, testPassphrase);
|
|
40
|
+
const decrypted = await decryptCredential(deserializeEnvelope(serialized), await recovered);
|
|
41
|
+
|
|
42
|
+
console.log(JSON.stringify({
|
|
43
|
+
ok: decrypted === testPlaintext,
|
|
44
|
+
detail: decrypted === testPlaintext ? 'Roundtrip passed' : 'Mismatch',
|
|
45
|
+
}));
|
|
46
|
+
process.exit(decrypted === testPlaintext ? 0 : 4);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const encryptedKey = JSON.parse(await readFile(privateKeyPath, 'utf-8'));
|
|
50
|
+
const envelope = deserializeEnvelope(envelopeInput);
|
|
51
|
+
|
|
52
|
+
const passphrase = await captureSecureInput('Enter your private key passphrase');
|
|
53
|
+
const privateKeyJWK = await decryptPrivateKey(encryptedKey, passphrase);
|
|
54
|
+
const plaintext = await decryptCredential(envelope, privateKeyJWK);
|
|
55
|
+
|
|
56
|
+
process.stdout.write(plaintext);
|
|
57
|
+
} catch (err) {
|
|
58
|
+
if (err instanceof DialogCancelledError) {
|
|
59
|
+
console.error(JSON.stringify({ error: 'cancelled' }));
|
|
60
|
+
process.exit(2);
|
|
61
|
+
}
|
|
62
|
+
if (err instanceof DialogUnavailableError) {
|
|
63
|
+
console.error(JSON.stringify({ error: 'no_dialog', platform: err.platform }));
|
|
64
|
+
process.exit(3);
|
|
65
|
+
}
|
|
66
|
+
if (err.message?.includes('decrypt') || err.message?.includes('OperationError')) {
|
|
67
|
+
console.error(JSON.stringify({ error: 'wrong_passphrase' }));
|
|
68
|
+
process.exit(5);
|
|
69
|
+
}
|
|
70
|
+
console.error(JSON.stringify({ error: 'decrypt_failed', detail: err.message }));
|
|
71
|
+
process.exit(4);
|
|
72
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# Example handoff checklist — customize for your engagement.
|
|
2
|
+
# See schema.md for the full format reference.
|
|
3
|
+
|
|
4
|
+
meta:
|
|
5
|
+
title: "Project Go-Live Credentials"
|
|
6
|
+
consultant: "Your Name"
|
|
7
|
+
public_key: "./keys/consultant.pub.jwk"
|
|
8
|
+
|
|
9
|
+
transport:
|
|
10
|
+
type: email
|
|
11
|
+
consultant: "consultant@example.com"
|
|
12
|
+
client: "client@example.com"
|
|
13
|
+
|
|
14
|
+
sections:
|
|
15
|
+
- key: hosting
|
|
16
|
+
title: "Hosting Setup"
|
|
17
|
+
items:
|
|
18
|
+
- key: hosting_provider
|
|
19
|
+
prompt: "Which hosting provider are you using?"
|
|
20
|
+
kind: decide
|
|
21
|
+
options: [Railway, Vercel, Fly.io, Other]
|
|
22
|
+
|
|
23
|
+
- key: railway_token
|
|
24
|
+
prompt: "Your Railway API token"
|
|
25
|
+
kind: credential
|
|
26
|
+
help: "Find this at railway.app → Account Settings → Tokens"
|
|
27
|
+
visibility:
|
|
28
|
+
depends_on: hosting_provider
|
|
29
|
+
value_in: [Railway]
|
|
30
|
+
|
|
31
|
+
- key: vercel_token
|
|
32
|
+
prompt: "Your Vercel access token"
|
|
33
|
+
kind: credential
|
|
34
|
+
help: "Find this at vercel.com → Settings → Tokens"
|
|
35
|
+
visibility:
|
|
36
|
+
depends_on: hosting_provider
|
|
37
|
+
value_in: [Vercel]
|
|
38
|
+
|
|
39
|
+
- key: email_service
|
|
40
|
+
title: "Email Service"
|
|
41
|
+
items:
|
|
42
|
+
- key: email_provider
|
|
43
|
+
prompt: "Which email service are you using?"
|
|
44
|
+
kind: decide
|
|
45
|
+
options: [Postmark, SendGrid, Resend, None]
|
|
46
|
+
|
|
47
|
+
- key: postmark_token
|
|
48
|
+
prompt: "Your Postmark server API token"
|
|
49
|
+
kind: credential
|
|
50
|
+
help: "Find this in your Postmark server → API Tokens tab"
|
|
51
|
+
visibility:
|
|
52
|
+
depends_on: email_provider
|
|
53
|
+
value_in: [Postmark]
|
|
54
|
+
|
|
55
|
+
- key: sendgrid_key
|
|
56
|
+
prompt: "Your SendGrid API key"
|
|
57
|
+
kind: credential
|
|
58
|
+
visibility:
|
|
59
|
+
depends_on: email_provider
|
|
60
|
+
value_in: [SendGrid]
|
|
61
|
+
|
|
62
|
+
- key: resend_key
|
|
63
|
+
prompt: "Your Resend API key"
|
|
64
|
+
kind: credential
|
|
65
|
+
help: "Find this at resend.com → API Keys"
|
|
66
|
+
visibility:
|
|
67
|
+
depends_on: email_provider
|
|
68
|
+
value_in: [Resend]
|
|
69
|
+
|
|
70
|
+
- key: domain
|
|
71
|
+
title: "Domain & DNS"
|
|
72
|
+
items:
|
|
73
|
+
- key: domain_name
|
|
74
|
+
prompt: "What domain will this project use?"
|
|
75
|
+
kind: provide
|
|
76
|
+
help: "e.g., example.com or app.example.com"
|
|
77
|
+
|
|
78
|
+
- key: dns_access
|
|
79
|
+
prompt: "Do you have access to your domain's DNS settings?"
|
|
80
|
+
kind: confirm
|
|
81
|
+
help: "We'll need to add records for the hosting provider and email service."
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { generateKeypair, encryptPrivateKey } from './handoff-crypto.mjs';
|
|
3
|
+
import { captureSecureInput } from './secure-input.mjs';
|
|
4
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
5
|
+
import { resolve } from 'node:path';
|
|
6
|
+
|
|
7
|
+
const args = process.argv.slice(2);
|
|
8
|
+
|
|
9
|
+
function getArg(name) {
|
|
10
|
+
const i = args.indexOf(name);
|
|
11
|
+
return i >= 0 ? args[i + 1] : null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const outdir = getArg('--outdir') || './keys';
|
|
15
|
+
const testPassphrase = getArg('--test-passphrase');
|
|
16
|
+
|
|
17
|
+
let passphrase;
|
|
18
|
+
if (testPassphrase) {
|
|
19
|
+
passphrase = testPassphrase;
|
|
20
|
+
} else {
|
|
21
|
+
console.error('A passphrase dialog will appear — enter a passphrase to protect your private key.');
|
|
22
|
+
console.error('You will need this passphrase to decrypt credentials via /handoff-status.');
|
|
23
|
+
passphrase = await captureSecureInput('Create a passphrase for your private key');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const { publicKey, privateKey } = await generateKeypair();
|
|
27
|
+
const encrypted = await encryptPrivateKey(privateKey, passphrase);
|
|
28
|
+
|
|
29
|
+
await mkdir(resolve(outdir), { recursive: true });
|
|
30
|
+
await writeFile(resolve(outdir, 'consultant.pub.jwk'), JSON.stringify(publicKey, null, 2));
|
|
31
|
+
await writeFile(resolve(outdir, 'consultant.priv.jwk.enc'), JSON.stringify(encrypted, null, 2));
|
|
32
|
+
|
|
33
|
+
console.log(`Keys written to ${outdir}/`);
|
|
34
|
+
console.log(' consultant.pub.jwk — share with client (ships with plugin)');
|
|
35
|
+
console.log(' consultant.priv.jwk.enc — keep private (encrypted with your passphrase)');
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { readFile, writeFile, rename } from 'node:fs/promises';
|
|
2
|
+
|
|
3
|
+
const VALID_KINDS = ['decide', 'provide', 'confirm', 'credential'];
|
|
4
|
+
const VALID_TRANSPORT_TYPES = ['email', 'mcp', 'file'];
|
|
5
|
+
|
|
6
|
+
export function validateChecklist(checklist) {
|
|
7
|
+
const errors = [];
|
|
8
|
+
|
|
9
|
+
if (!checklist.meta) errors.push('Missing "meta" section');
|
|
10
|
+
else {
|
|
11
|
+
if (!checklist.meta.title) errors.push('meta.title is required');
|
|
12
|
+
if (!checklist.meta.public_key) errors.push('meta.public_key is required');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (!checklist.transport) {
|
|
16
|
+
errors.push('Missing "transport" section');
|
|
17
|
+
} else if (!VALID_TRANSPORT_TYPES.includes(checklist.transport.type)) {
|
|
18
|
+
errors.push(`transport.type must be one of: ${VALID_TRANSPORT_TYPES.join(', ')}`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (!Array.isArray(checklist.sections)) {
|
|
22
|
+
errors.push('Missing or invalid "sections" array');
|
|
23
|
+
return { valid: false, errors };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// First pass: collect all keys for cross-section dependency validation
|
|
27
|
+
const allKeys = new Set();
|
|
28
|
+
for (const section of checklist.sections) {
|
|
29
|
+
for (const item of (section.items || [])) {
|
|
30
|
+
if (item.key) allKeys.add(item.key);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const seenKeys = new Set();
|
|
35
|
+
for (const section of checklist.sections) {
|
|
36
|
+
if (!section.key) errors.push('Section missing "key"');
|
|
37
|
+
if (!section.title) errors.push(`Section ${section.key || '?'} missing "title"`);
|
|
38
|
+
if (!Array.isArray(section.items)) {
|
|
39
|
+
errors.push(`Section ${section.key || '?'} missing "items" array`);
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
for (const item of section.items) {
|
|
43
|
+
if (!item.key) { errors.push('Item missing "key"'); continue; }
|
|
44
|
+
if (!item.prompt) errors.push(`Item ${item.key} missing "prompt"`);
|
|
45
|
+
if (!VALID_KINDS.includes(item.kind)) {
|
|
46
|
+
errors.push(`Item ${item.key}: kind must be one of: ${VALID_KINDS.join(', ')}`);
|
|
47
|
+
}
|
|
48
|
+
if (seenKeys.has(item.key)) errors.push(`Duplicate item key: ${item.key}`);
|
|
49
|
+
seenKeys.add(item.key);
|
|
50
|
+
|
|
51
|
+
if (item.visibility) {
|
|
52
|
+
if (!item.visibility.depends_on) errors.push(`Item ${item.key}: visibility.depends_on is required`);
|
|
53
|
+
if (!Array.isArray(item.visibility.value_in)) errors.push(`Item ${item.key}: visibility.value_in must be an array`);
|
|
54
|
+
if (item.visibility.depends_on && !allKeys.has(item.visibility.depends_on)) {
|
|
55
|
+
errors.push(`Item ${item.key}: depends_on "${item.visibility.depends_on}" references non-existent key`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return { valid: errors.length === 0, errors };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function getAllItems(checklist) {
|
|
65
|
+
return (checklist.sections || []).flatMap(s => s.items || []);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function detectCycles(checklist) {
|
|
69
|
+
const items = getAllItems(checklist);
|
|
70
|
+
const keySet = new Set(items.map(i => i.key));
|
|
71
|
+
const graph = new Map();
|
|
72
|
+
const inDegree = new Map();
|
|
73
|
+
|
|
74
|
+
for (const item of items) {
|
|
75
|
+
graph.set(item.key, []);
|
|
76
|
+
inDegree.set(item.key, 0);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
for (const item of items) {
|
|
80
|
+
const parent = item.visibility?.depends_on;
|
|
81
|
+
if (parent && keySet.has(parent)) {
|
|
82
|
+
graph.get(parent).push(item.key);
|
|
83
|
+
inDegree.set(item.key, inDegree.get(item.key) + 1);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const queue = [...inDegree.entries()].filter(([, d]) => d === 0).map(([k]) => k);
|
|
88
|
+
let processed = 0;
|
|
89
|
+
|
|
90
|
+
while (queue.length > 0) {
|
|
91
|
+
const key = queue.shift();
|
|
92
|
+
processed++;
|
|
93
|
+
for (const child of graph.get(key) || []) {
|
|
94
|
+
const nd = inDegree.get(child) - 1;
|
|
95
|
+
inDegree.set(child, nd);
|
|
96
|
+
if (nd === 0) queue.push(child);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const hasCycle = processed < items.length;
|
|
101
|
+
const cycleKeys = hasCycle
|
|
102
|
+
? [...inDegree.entries()].filter(([, d]) => d > 0).map(([k]) => k)
|
|
103
|
+
: [];
|
|
104
|
+
|
|
105
|
+
return { hasCycle, cycleKeys };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function computeVisibility(checklist, answers) {
|
|
109
|
+
const items = getAllItems(checklist);
|
|
110
|
+
const visible = new Set();
|
|
111
|
+
|
|
112
|
+
for (const item of items) {
|
|
113
|
+
if (!item.visibility) visible.add(item.key);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
let changed = true;
|
|
117
|
+
while (changed) {
|
|
118
|
+
changed = false;
|
|
119
|
+
for (const item of items) {
|
|
120
|
+
if (visible.has(item.key) || !item.visibility) continue;
|
|
121
|
+
const parentKey = item.visibility.depends_on;
|
|
122
|
+
if (!visible.has(parentKey)) continue;
|
|
123
|
+
const parentAnswer = answers[parentKey]?.value;
|
|
124
|
+
if (parentAnswer && item.visibility.value_in.includes(parentAnswer)) {
|
|
125
|
+
visible.add(item.key);
|
|
126
|
+
changed = true;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return visible;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export async function loadState(path) {
|
|
135
|
+
try {
|
|
136
|
+
return JSON.parse(await readFile(path, 'utf-8'));
|
|
137
|
+
} catch (err) {
|
|
138
|
+
if (err.code === 'ENOENT') return null;
|
|
139
|
+
throw err;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function createState(checklistPath) {
|
|
144
|
+
return {
|
|
145
|
+
checklist: checklistPath,
|
|
146
|
+
started_at: new Date().toISOString(),
|
|
147
|
+
updated_at: new Date().toISOString(),
|
|
148
|
+
answers: {},
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export async function saveState(path, state) {
|
|
153
|
+
state.updated_at = new Date().toISOString();
|
|
154
|
+
const tmp = path + '.tmp';
|
|
155
|
+
await writeFile(tmp, JSON.stringify(state, null, 2));
|
|
156
|
+
await rename(tmp, path);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function recordAnswer(state, key, value) {
|
|
160
|
+
state.answers[key] = { value, answered_at: new Date().toISOString() };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function recordCredentialSent(state, key, envelopeId) {
|
|
164
|
+
state.answers[key] = { status: 'sent', envelope_id: envelopeId, sent_at: new Date().toISOString() };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function getProgress(checklist, state) {
|
|
168
|
+
const answers = state?.answers || {};
|
|
169
|
+
const visible = computeVisibility(checklist, answers);
|
|
170
|
+
const completed = [...visible].filter(key => answers[key]).length;
|
|
171
|
+
return { total: visible.size, completed, remaining: visible.size - completed };
|
|
172
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { webcrypto } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
const { subtle } = webcrypto;
|
|
4
|
+
|
|
5
|
+
const RSA_ALGO = {
|
|
6
|
+
name: 'RSA-OAEP',
|
|
7
|
+
modulusLength: 2048,
|
|
8
|
+
publicExponent: new Uint8Array([1, 0, 1]),
|
|
9
|
+
hash: 'SHA-256',
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const AES_ALGO = { name: 'AES-GCM', length: 256 };
|
|
13
|
+
const PBKDF2_ITERATIONS = 600_000;
|
|
14
|
+
|
|
15
|
+
export async function generateKeypair() {
|
|
16
|
+
const pair = await subtle.generateKey(RSA_ALGO, true, ['wrapKey', 'unwrapKey']);
|
|
17
|
+
return {
|
|
18
|
+
publicKey: await subtle.exportKey('jwk', pair.publicKey),
|
|
19
|
+
privateKey: await subtle.exportKey('jwk', pair.privateKey),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function encryptCredential(plaintext, publicKeyJWK) {
|
|
24
|
+
const publicKey = await subtle.importKey('jwk', publicKeyJWK, RSA_ALGO, false, ['wrapKey']);
|
|
25
|
+
const aesKey = await subtle.generateKey(AES_ALGO, true, ['encrypt']);
|
|
26
|
+
const iv = webcrypto.getRandomValues(new Uint8Array(12));
|
|
27
|
+
const ciphertext = await subtle.encrypt({ name: 'AES-GCM', iv }, aesKey, new TextEncoder().encode(plaintext));
|
|
28
|
+
const wrappedKey = await subtle.wrapKey('raw', aesKey, publicKey, RSA_ALGO);
|
|
29
|
+
const idBytes = webcrypto.getRandomValues(new Uint8Array(6));
|
|
30
|
+
const envelope_id = 'env_' + [...idBytes].map(b => b.toString(16).padStart(2, '0')).join('');
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
envelope_id,
|
|
34
|
+
ciphertext: Buffer.from(ciphertext).toString('base64'),
|
|
35
|
+
iv: Buffer.from(iv).toString('base64'),
|
|
36
|
+
wrappedKey: Buffer.from(wrappedKey).toString('base64'),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function decryptCredential(envelope, privateKeyJWK) {
|
|
41
|
+
const privateKey = await subtle.importKey('jwk', privateKeyJWK, RSA_ALGO, false, ['unwrapKey']);
|
|
42
|
+
const aesKey = await subtle.unwrapKey(
|
|
43
|
+
'raw',
|
|
44
|
+
Buffer.from(envelope.wrappedKey, 'base64'),
|
|
45
|
+
privateKey,
|
|
46
|
+
RSA_ALGO,
|
|
47
|
+
AES_ALGO,
|
|
48
|
+
false,
|
|
49
|
+
['decrypt'],
|
|
50
|
+
);
|
|
51
|
+
const decrypted = await subtle.decrypt(
|
|
52
|
+
{ name: 'AES-GCM', iv: Buffer.from(envelope.iv, 'base64') },
|
|
53
|
+
aesKey,
|
|
54
|
+
Buffer.from(envelope.ciphertext, 'base64'),
|
|
55
|
+
);
|
|
56
|
+
return new TextDecoder().decode(decrypted);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function serializeEnvelope(envelope) {
|
|
60
|
+
return Buffer.from(JSON.stringify(envelope)).toString('base64');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function deserializeEnvelope(base64) {
|
|
64
|
+
return JSON.parse(Buffer.from(base64, 'base64').toString('utf-8'));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function encryptPrivateKey(privateKeyJWK, passphrase) {
|
|
68
|
+
const salt = webcrypto.getRandomValues(new Uint8Array(16));
|
|
69
|
+
const iv = webcrypto.getRandomValues(new Uint8Array(12));
|
|
70
|
+
const keyMaterial = await subtle.importKey('raw', new TextEncoder().encode(passphrase), 'PBKDF2', false, ['deriveKey']);
|
|
71
|
+
const derived = await subtle.deriveKey(
|
|
72
|
+
{ name: 'PBKDF2', salt, iterations: PBKDF2_ITERATIONS, hash: 'SHA-256' },
|
|
73
|
+
keyMaterial,
|
|
74
|
+
AES_ALGO,
|
|
75
|
+
false,
|
|
76
|
+
['encrypt'],
|
|
77
|
+
);
|
|
78
|
+
const ciphertext = await subtle.encrypt({ name: 'AES-GCM', iv }, derived, new TextEncoder().encode(JSON.stringify(privateKeyJWK)));
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
salt: Buffer.from(salt).toString('base64'),
|
|
82
|
+
iv: Buffer.from(iv).toString('base64'),
|
|
83
|
+
ciphertext: Buffer.from(ciphertext).toString('base64'),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function decryptPrivateKey(encrypted, passphrase) {
|
|
88
|
+
const keyMaterial = await subtle.importKey('raw', new TextEncoder().encode(passphrase), 'PBKDF2', false, ['deriveKey']);
|
|
89
|
+
const derived = await subtle.deriveKey(
|
|
90
|
+
{ name: 'PBKDF2', salt: Buffer.from(encrypted.salt, 'base64'), iterations: PBKDF2_ITERATIONS, hash: 'SHA-256' },
|
|
91
|
+
keyMaterial,
|
|
92
|
+
AES_ALGO,
|
|
93
|
+
false,
|
|
94
|
+
['decrypt'],
|
|
95
|
+
);
|
|
96
|
+
const decrypted = await subtle.decrypt(
|
|
97
|
+
{ name: 'AES-GCM', iv: Buffer.from(encrypted.iv, 'base64') },
|
|
98
|
+
derived,
|
|
99
|
+
Buffer.from(encrypted.ciphertext, 'base64'),
|
|
100
|
+
);
|
|
101
|
+
return JSON.parse(new TextDecoder().decode(decrypted));
|
|
102
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { writeFile, readFile, readdir, mkdir } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
const EMAIL_MCP_SIGNATURES = [
|
|
5
|
+
{ provider: 'gmail', toolPattern: /gmail/i },
|
|
6
|
+
{ provider: 'outlook', toolPattern: /outlook|microsoft/i },
|
|
7
|
+
];
|
|
8
|
+
|
|
9
|
+
export function detectEmailProvider(availableTools) {
|
|
10
|
+
if (!availableTools?.length) return null;
|
|
11
|
+
for (const sig of EMAIL_MCP_SIGNATURES) {
|
|
12
|
+
if (availableTools.some(t => sig.toolPattern.test(t))) return sig.provider;
|
|
13
|
+
}
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function buildEmailInstruction(envelope, transportConfig, provider, recipient) {
|
|
18
|
+
const envelopeId = typeof envelope === 'object' ? envelope.envelope_id : transportConfig.envelope_id;
|
|
19
|
+
const serialized = typeof envelope === 'string'
|
|
20
|
+
? envelope
|
|
21
|
+
: Buffer.from(JSON.stringify(envelope)).toString('base64');
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
type: 'email',
|
|
25
|
+
provider,
|
|
26
|
+
to: recipient,
|
|
27
|
+
subject: `[Handoff] ${envelopeId || 'delivery'}`,
|
|
28
|
+
body: serialized,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function buildMcpInstruction(envelope, transportConfig) {
|
|
33
|
+
const serialized = typeof envelope === 'string'
|
|
34
|
+
? envelope
|
|
35
|
+
: Buffer.from(JSON.stringify(envelope)).toString('base64');
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
type: 'mcp',
|
|
39
|
+
tool: transportConfig.tool_name,
|
|
40
|
+
params: {
|
|
41
|
+
[transportConfig.payload_param || 'payload']: serialized,
|
|
42
|
+
...(transportConfig.extra_params || {}),
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function sendViaFile(envelope, config) {
|
|
48
|
+
const outdir = config?.outdir || './handoff-out';
|
|
49
|
+
await mkdir(outdir, { recursive: true });
|
|
50
|
+
|
|
51
|
+
const id = envelope.envelope_id || `env_${Date.now().toString(36)}`;
|
|
52
|
+
const filepath = join(outdir, `${id}.enc`);
|
|
53
|
+
const serialized = typeof envelope === 'string'
|
|
54
|
+
? envelope
|
|
55
|
+
: Buffer.from(JSON.stringify(envelope)).toString('base64');
|
|
56
|
+
|
|
57
|
+
await writeFile(filepath, serialized);
|
|
58
|
+
return { type: 'file', delivered: true, ref: id, path: filepath };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function readFromFile(envelopeId, config) {
|
|
62
|
+
const outdir = config?.outdir || './handoff-out';
|
|
63
|
+
return readFile(join(outdir, `${envelopeId}.enc`), 'utf-8');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function listFiles(config) {
|
|
67
|
+
const outdir = config?.outdir || './handoff-out';
|
|
68
|
+
try {
|
|
69
|
+
const files = await readdir(outdir);
|
|
70
|
+
return files.filter(f => f.endsWith('.enc')).map(f => f.replace('.enc', ''));
|
|
71
|
+
} catch (err) {
|
|
72
|
+
if (err.code === 'ENOENT') return [];
|
|
73
|
+
throw err;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function send(envelope, transportConfig, context) {
|
|
78
|
+
const type = transportConfig.type || 'file';
|
|
79
|
+
|
|
80
|
+
switch (type) {
|
|
81
|
+
case 'email': {
|
|
82
|
+
const provider = context?.emailProvider || detectEmailProvider(context?.availableTools);
|
|
83
|
+
if (!provider) {
|
|
84
|
+
process.stderr.write(
|
|
85
|
+
'Warning: No email MCP connected — falling back to file transport.\n' +
|
|
86
|
+
'Transfer files manually or connect an email MCP server.\n',
|
|
87
|
+
);
|
|
88
|
+
return { ...(await sendViaFile(envelope, transportConfig)), fallback: true };
|
|
89
|
+
}
|
|
90
|
+
const recipient = context?.side === 'consultant'
|
|
91
|
+
? transportConfig.client
|
|
92
|
+
: transportConfig.consultant;
|
|
93
|
+
return buildEmailInstruction(envelope, transportConfig, provider, recipient);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
case 'mcp':
|
|
97
|
+
return buildMcpInstruction(envelope, transportConfig);
|
|
98
|
+
|
|
99
|
+
case 'file':
|
|
100
|
+
return sendViaFile(envelope, transportConfig);
|
|
101
|
+
|
|
102
|
+
default:
|
|
103
|
+
throw new Error(`Unknown transport type: ${type}`);
|
|
104
|
+
}
|
|
105
|
+
}
|