phaedo-mcp 0.2.2 → 0.3.1
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/NOTICE +17 -0
- package/README.md +182 -19
- package/action-binding.js +82 -0
- package/agreement.js +86 -2
- package/ap2.js +214 -0
- package/autonomy-promotion.js +280 -0
- package/build-package.mjs +25 -10
- package/capture-a11y.js +116 -0
- package/capture-local.js +85 -0
- package/capture-sync.js +99 -0
- package/consult-core.js +126 -8
- package/consult-policy.js +427 -33
- package/consult-runtime.js +60 -0
- package/consult.js +29 -1
- package/delegation-sync.js +95 -0
- package/escalation-policy.sample.json +13 -0
- package/escalation.js +383 -0
- package/fingerprint-source.js +112 -13
- package/health.js +54 -0
- package/hooks/pre-ship.mjs +409 -0
- package/hooks/session-end.mjs +244 -0
- package/hooks/stop-capture.mjs +140 -0
- package/install.mjs +226 -36
- package/lib/capture-home.js +46 -0
- package/lib/domain.js +101 -0
- package/lib/is-main.js +40 -0
- package/lib/phaedo-crypto.js +17 -1
- package/package.json +29 -5
- package/phone-source.js +85 -12
- package/projection.js +4 -1
- package/pull.js +19 -3
- package/receipts-sync.js +197 -0
- package/receipts.js +34 -3
- package/server.js +323 -54
- package/server.json +3 -3
- package/ship-actions.js +164 -0
- package/ship-readout.js +106 -0
- package/signing-audit.js +111 -0
- package/signing-request.js +217 -0
- package/signing.js +176 -0
- package/vendor/authored-source.js +250 -0
- package/vendor/capabilities.js +203 -0
- package/vendor/capture/capture-core.js +67 -0
- package/vendor/capture/detectors.js +287 -0
- package/vendor/capture/exchanges.js +77 -0
- package/vendor/capture/model-detect.js +195 -0
- package/vendor/capture/owner.js +35 -0
- package/vendor/capture/package.json +4 -0
- package/vendor/capture/source-id.js +56 -0
- package/vendor/capture/state.js +148 -0
- package/vendor/capture/transcript.js +184 -0
- package/vendor/commitments.js +169 -0
- package/vendor/context-block.js +251 -18
- package/vendor/envelope.js +50 -4
- package/vendor/extraction/capture-combine.js +160 -0
- package/vendor/extraction/catalog.js +232 -0
- package/vendor/extraction/delta-lift.js +109 -0
- package/vendor/extraction/fingerprint-update.js +227 -0
- package/vendor/extraction/package.json +4 -0
- package/vendor/extraction/persist.js +45 -0
- package/vendor/extraction/reconcile.js +310 -0
- package/vendor/extraction/resolve.js +178 -0
- package/vendor/extraction/rule-promotion.js +103 -0
- package/vendor/kdf.js +20 -1
- package/vendor/protocol.js +7 -0
- package/vendor/signal-ledger.js +402 -0
- package/vendor/trait-phrases.js +656 -0
- package/vendored-files.mjs +86 -0
package/ship-actions.js
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// Ship actions — which shell commands are a decision the subject would want consulted on.
|
|
2
|
+
//
|
|
3
|
+
// The precedent track's Phase 1 (docs/roadmap/precedent-and-sync-design-2026-09-13.md) is
|
|
4
|
+
// starved of outcome data because nothing consults: no receipt has been written since
|
|
5
|
+
// 2026-07-28. The decisions the subject actually delegates, every week, are the ones a
|
|
6
|
+
// coding agent takes on their behalf — merging a PR, deploying the relay, cutting a phone
|
|
7
|
+
// build. This module names those, so the PreToolUse hook (hooks/pre-ship.mjs) can consult
|
|
8
|
+
// before they run. Everything else a shell does is not a decision and is ignored.
|
|
9
|
+
//
|
|
10
|
+
// PURE: (command string) -> classification | null. No I/O.
|
|
11
|
+
|
|
12
|
+
import { createHash } from 'node:crypto';
|
|
13
|
+
|
|
14
|
+
// One entry per class. `match` sees one simple command, already split out of a compound
|
|
15
|
+
// line and stripped of env assignments and `npx`. The descriptor is what the consult
|
|
16
|
+
// reasons about — magnitude is the stakes, reversible is whether it can be walked back.
|
|
17
|
+
const CLASSES = [
|
|
18
|
+
{
|
|
19
|
+
id: 'merge_pr',
|
|
20
|
+
// `gh pr merge` — reversible (a revert), medium. `--admin` skips required checks, which
|
|
21
|
+
// is a different act: high.
|
|
22
|
+
match: (w) => w[0] === 'gh' && w[1] === 'pr' && w[2] === 'merge',
|
|
23
|
+
describe: (w) => {
|
|
24
|
+
const num = w.slice(3).find((x) => /^#?\d+$/.test(x));
|
|
25
|
+
const admin = w.includes('--admin');
|
|
26
|
+
// `-R/--repo` names the repository when the agent is not inside it; the hook passes it
|
|
27
|
+
// through when it asks GitHub for the PR's check state (B6).
|
|
28
|
+
const ri = w.findIndex((x) => x === '-R' || x === '--repo');
|
|
29
|
+
const repo = ri >= 0 ? w[ri + 1] : (w.find((x) => x.startsWith('--repo='))?.split('=')[1]);
|
|
30
|
+
return {
|
|
31
|
+
magnitude: admin ? 'high' : 'medium',
|
|
32
|
+
reversible: true,
|
|
33
|
+
summary: `${admin ? 'admin-merge' : 'merge'} ${num ? `PR #${num.replace('#', '')}` : "this branch's PR"}`,
|
|
34
|
+
pr: num ? num.replace('#', '') : null,
|
|
35
|
+
repo: repo || null,
|
|
36
|
+
};
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
id: 'deploy',
|
|
41
|
+
// `fly deploy` — production for every paired phone. Reversible (`fly releases` rollback)
|
|
42
|
+
// but high: a bad relay breaks sync for everyone until it is rolled back.
|
|
43
|
+
match: (w) => (w[0] === 'fly' || w[0] === 'flyctl') && w[1] === 'deploy',
|
|
44
|
+
describe: (w) => {
|
|
45
|
+
const i = w.findIndex((x) => x === '-a' || x === '--app');
|
|
46
|
+
const app = i >= 0 ? w[i + 1] : null;
|
|
47
|
+
return { magnitude: 'high', reversible: true, summary: `deploy${app ? ` ${app}` : ''} to Fly` };
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
id: 'app_build',
|
|
52
|
+
// `eas build` — a build artifact, nothing reaches a device yet. Low.
|
|
53
|
+
match: (w) => isEas(w[0]) && w[1] === 'build',
|
|
54
|
+
describe: (w) => ({ magnitude: 'low', reversible: true, summary: `phone build${platform(w)}` }),
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
id: 'app_submit',
|
|
58
|
+
// `eas submit` (or `eas build --auto-submit`) — the build goes to TestFlight / the store.
|
|
59
|
+
match: (w) => isEas(w[0]) && (w[1] === 'submit' || (w[1] === 'build' && w.includes('--auto-submit'))),
|
|
60
|
+
describe: (w) => ({ magnitude: 'medium', reversible: true, summary: `submit phone build${platform(w)}` }),
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
id: 'push_main',
|
|
64
|
+
// A direct push to the default branch, skipping review. A force-push there rewrites
|
|
65
|
+
// shared history: high, and not cleanly reversible.
|
|
66
|
+
match: (w) => w[0] === 'git' && w[1] === 'push' && w.slice(2).some((x) => /^\+?(?:[^:]*:)?(?:refs\/heads\/)?(?:main|master)$/.test(x)),
|
|
67
|
+
describe: (w) => {
|
|
68
|
+
const force = w.some((x) => x === '-f' || x === '--force' || x.startsWith('--force-with-lease') || x.startsWith('+'));
|
|
69
|
+
return force
|
|
70
|
+
? { magnitude: 'high', reversible: false, summary: 'force-push to main' }
|
|
71
|
+
: { magnitude: 'medium', reversible: true, summary: 'push directly to main' };
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
// Order matters for one pair: `eas build --auto-submit` is a submit, not a build.
|
|
77
|
+
const ORDER = ['merge_pr', 'deploy', 'app_submit', 'app_build', 'push_main'];
|
|
78
|
+
|
|
79
|
+
function isEas(w0) { return w0 === 'eas' || w0 === 'eas-cli'; }
|
|
80
|
+
function platform(w) {
|
|
81
|
+
const i = w.findIndex((x) => x === '--platform' || x === '-p');
|
|
82
|
+
const p = i >= 0 ? w[i + 1] : (w.find((x) => x.startsWith('--platform='))?.split('=')[1]);
|
|
83
|
+
return p ? ` (${p})` : '';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Split a shell line into simple commands on && || ; | and newlines. Not a shell parser —
|
|
87
|
+
// quotes are honoured only enough that `git commit -m "a && b"` does not split.
|
|
88
|
+
export function splitCommands(line) {
|
|
89
|
+
const out = [];
|
|
90
|
+
let cur = '';
|
|
91
|
+
let q = null;
|
|
92
|
+
for (let i = 0; i < line.length; i++) {
|
|
93
|
+
const c = line[i];
|
|
94
|
+
if (q) { cur += c; if (c === q) q = null; continue; }
|
|
95
|
+
if (c === '"' || c === "'") { q = c; cur += c; continue; }
|
|
96
|
+
if (c === '\n' || c === ';' || c === '|' || (c === '&' && line[i + 1] === '&')) {
|
|
97
|
+
if (c === '&' || (c === '|' && line[i + 1] === '|')) i++;
|
|
98
|
+
out.push(cur); cur = '';
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
cur += c;
|
|
102
|
+
}
|
|
103
|
+
out.push(cur);
|
|
104
|
+
return out.map((s) => s.trim()).filter(Boolean);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Words of one simple command, with leading env assignments, `npx`/`npx --yes`, and a
|
|
108
|
+
// quoted-string's quotes removed.
|
|
109
|
+
export function words(cmd) {
|
|
110
|
+
const w = (cmd.match(/"[^"]*"|'[^']*'|\S+/g) || []).map((x) => x.replace(/^["']|["']$/g, ''));
|
|
111
|
+
while (w.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(w[0])) w.shift();
|
|
112
|
+
if (w[0] === 'npx') { w.shift(); while (w[0] && w[0].startsWith('-')) w.shift(); }
|
|
113
|
+
// A path to the binary (`/opt/homebrew/bin/gh`, `./node_modules/.bin/eas`) is the binary.
|
|
114
|
+
if (w[0]) w[0] = w[0].split('/').pop();
|
|
115
|
+
return w;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Stakes order for a line that chains several ship actions: irreversible first, then by
|
|
119
|
+
// magnitude. A line that merges and then deploys is consulted on the deploy — consulting on
|
|
120
|
+
// the merge would let the riskier half ride on the safer half's answer.
|
|
121
|
+
const MAG_RANK = { low: 0, medium: 1, high: 2 };
|
|
122
|
+
const stakes = (d) => (d.reversible === false ? 10 : 0) + (MAG_RANK[d.magnitude] ?? 1);
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Classify a Bash command. Returns null when no part of it is a ship action, else the
|
|
126
|
+
* highest-stakes ship action in it (ties: the first), with `multiple` set when the line
|
|
127
|
+
* chains more than one.
|
|
128
|
+
*
|
|
129
|
+
* @returns {null | { class, summary, multiple, action_descriptor, action_binding }}
|
|
130
|
+
*/
|
|
131
|
+
export function classifyShipCommand(command) {
|
|
132
|
+
if (typeof command !== 'string' || !command.trim()) return null;
|
|
133
|
+
const hits = [];
|
|
134
|
+
for (const seg of splitCommands(command)) {
|
|
135
|
+
const w = words(seg);
|
|
136
|
+
if (!w.length) continue;
|
|
137
|
+
for (const id of ORDER) {
|
|
138
|
+
const k = CLASSES.find((c) => c.id === id);
|
|
139
|
+
if (k.match(w)) { hits.push({ id, seg, d: k.describe(w) }); break; }
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (!hits.length) return null;
|
|
143
|
+
const first = hits.reduce((best, h) => (stakes(h.d) > stakes(best.d) ? h : best));
|
|
144
|
+
return {
|
|
145
|
+
class: first.id,
|
|
146
|
+
summary: first.d.summary,
|
|
147
|
+
multiple: hits.length > 1,
|
|
148
|
+
// merge_pr only: which PR, and in which repo (null = the current branch's, here).
|
|
149
|
+
...(first.id === 'merge_pr' ? { pr: first.d.pr, repo: first.d.repo } : {}),
|
|
150
|
+
action_descriptor: {
|
|
151
|
+
domain: 'engineering',
|
|
152
|
+
reversible: first.d.reversible,
|
|
153
|
+
magnitude: first.d.magnitude,
|
|
154
|
+
summary: first.d.summary,
|
|
155
|
+
},
|
|
156
|
+
// §10.2 binding: the concrete call this descriptor claims to describe — a digest of the
|
|
157
|
+
// whole line, never the line itself beyond a short preview.
|
|
158
|
+
action_binding: {
|
|
159
|
+
tool_name: 'Bash',
|
|
160
|
+
arguments_hash: createHash('sha256').update(command).digest('hex'),
|
|
161
|
+
arguments_preview: first.seg.slice(0, 120),
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
}
|
package/ship-readout.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Ship-hook readout — what the Claude Code ship hook (hooks/pre-ship.mjs) has seen: how many
|
|
3
|
+
// merges / deploys / phone builds it consulted on, what Phaedo answered, what the subject
|
|
4
|
+
// said, and whether agreement has anything to score yet. The precedent track's Phase 1
|
|
5
|
+
// exit is "receipts gain marked outcomes in normal use"; this is how to see whether they do.
|
|
6
|
+
//
|
|
7
|
+
// npm run ship-readout # last 30 days
|
|
8
|
+
// npm run ship-readout -- --days 90
|
|
9
|
+
//
|
|
10
|
+
// All local: decrypts the receipt store with the local key; prints counts and the summary
|
|
11
|
+
// line each receipt already carries (never layer content).
|
|
12
|
+
|
|
13
|
+
import { computeAgreement } from './agreement.js';
|
|
14
|
+
import { AGENT_ID } from './hooks/pre-ship.mjs';
|
|
15
|
+
|
|
16
|
+
export const DEFAULT_DAYS = 30;
|
|
17
|
+
const PERMISSIVE = new Set(['proceed', 'proceed_with_note']);
|
|
18
|
+
const ASKING = new Set(['escalate', 'clarify', 'decline']);
|
|
19
|
+
|
|
20
|
+
// The action a hook receipt was about, from the summary ship-actions.js wrote.
|
|
21
|
+
export function actionKind(summary) {
|
|
22
|
+
const s = String(summary || '');
|
|
23
|
+
if (/^(admin-)?merge\b/.test(s)) return 'merge';
|
|
24
|
+
if (/^deploy\b/.test(s)) return 'deploy';
|
|
25
|
+
if (/^submit\b/.test(s)) return 'phone submit';
|
|
26
|
+
if (/^phone build\b/.test(s)) return 'phone build';
|
|
27
|
+
if (/push/.test(s)) return 'push to main';
|
|
28
|
+
return 'other';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* (receipts, { now, windowDays }) -> the readout, PURE. Only receipts the ship hook wrote
|
|
33
|
+
* count — the agreement figure here is the hook's own, not the whole store's.
|
|
34
|
+
*/
|
|
35
|
+
export function shipReadout(receipts, { now = Date.now(), windowDays = DEFAULT_DAYS } = {}) {
|
|
36
|
+
const since = now - windowDays * 24 * 60 * 60 * 1000;
|
|
37
|
+
const mine = (Array.isArray(receipts) ? receipts : []).filter((r) => r?.agent_id === AGENT_ID);
|
|
38
|
+
const inWindow = mine.filter((r) => { const t = Date.parse(r?.created_at); return Number.isFinite(t) && t >= since && t <= now; });
|
|
39
|
+
const kinds = {};
|
|
40
|
+
const answers = { go_ahead: 0, flagged: 0, unsure: 0 };
|
|
41
|
+
const outcomes = { approved: 0, rejected: 0, modified: 0, unmarked: 0 };
|
|
42
|
+
for (const r of inWindow) {
|
|
43
|
+
const k = actionKind(r.request?.action_descriptor?.summary);
|
|
44
|
+
kinds[k] = (kinds[k] || 0) + 1;
|
|
45
|
+
const sig = r.response?.signal;
|
|
46
|
+
if (PERMISSIVE.has(sig)) answers.go_ahead++; else if (ASKING.has(sig)) answers.flagged++; else answers.unsure++;
|
|
47
|
+
const a = r.user_action;
|
|
48
|
+
if (a === 'approved' || a === 'rejected' || a === 'modified') outcomes[a]++; else outcomes.unmarked++;
|
|
49
|
+
}
|
|
50
|
+
const agreement = computeAgreement(inWindow, { now, windowDays }).overall;
|
|
51
|
+
const newest = inWindow[inWindow.length - 1] || null;
|
|
52
|
+
return {
|
|
53
|
+
window_days: windowDays,
|
|
54
|
+
total_ever: mine.length,
|
|
55
|
+
consults: inWindow.length,
|
|
56
|
+
kinds,
|
|
57
|
+
answers,
|
|
58
|
+
outcomes,
|
|
59
|
+
agreement: { scored: agreement.scored, agreements: agreement.agreements, rate: agreement.rate },
|
|
60
|
+
newest: newest && {
|
|
61
|
+
at: newest.created_at,
|
|
62
|
+
summary: newest.request?.action_descriptor?.summary || null,
|
|
63
|
+
signal: newest.response?.signal || null,
|
|
64
|
+
user_action: newest.user_action || null,
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
70
|
+
const KIND_PLURAL = { merge: 'merges', deploy: 'deploys', 'phone build': 'phone builds', 'phone submit': 'phone submits', 'push to main': 'pushes to main', other: 'others' };
|
|
71
|
+
const saidIt = (sig) => (PERMISSIVE.has(sig) ? 'go ahead' : ASKING.has(sig) ? 'flagged for you' : "didn't know");
|
|
72
|
+
|
|
73
|
+
// Plain lines for the terminal. No confidences, no protocol terms beyond the summary.
|
|
74
|
+
export function formatReadout(o) {
|
|
75
|
+
const lines = [`Ship hook — last ${o.window_days} days`];
|
|
76
|
+
if (!o.consults) {
|
|
77
|
+
lines.push(o.total_ever
|
|
78
|
+
? ` Nothing in this window (${plural(o.total_ever, 'consult')} before it). Try --days ${o.window_days * 3}.`
|
|
79
|
+
: ' No consults yet. Is the hook installed? (npm run setup:code-hook, then a new Claude Code session)');
|
|
80
|
+
return lines.join('\n');
|
|
81
|
+
}
|
|
82
|
+
const kinds = Object.entries(o.kinds).sort((a, b) => b[1] - a[1]).map(([k, n]) => plural(n, k, KIND_PLURAL[k]));
|
|
83
|
+
lines.push(` ${plural(o.consults, 'consult')}: ${kinds.join(', ')}`);
|
|
84
|
+
const ans = [`go ahead ${o.answers.go_ahead}×`, `flagged for you ${o.answers.flagged}×`];
|
|
85
|
+
if (o.answers.unsure) ans.push(`didn't know ${o.answers.unsure}×`);
|
|
86
|
+
lines.push(` Phaedo said: ${ans.join(', ')}`);
|
|
87
|
+
const { approved, rejected, modified, unmarked } = o.outcomes;
|
|
88
|
+
lines.push(` You said: yes ${approved}, no ${rejected}, changed it ${modified} — ${unmarked} not marked`);
|
|
89
|
+
lines.push(o.agreement.scored
|
|
90
|
+
? ` Phaedo matched you on ${o.agreement.agreements} of ${o.agreement.scored} decisions it could be scored on`
|
|
91
|
+
: ' Nothing to score yet (needs a "go ahead" you then answered)');
|
|
92
|
+
if (o.newest) {
|
|
93
|
+
const day = String(o.newest.at).slice(0, 10);
|
|
94
|
+
lines.push(` Newest: ${day} ${o.newest.summary || '(no summary)'} → Phaedo: ${saidIt(o.newest.signal)}${o.newest.user_action ? ` · you: ${o.newest.user_action}` : ''}`);
|
|
95
|
+
}
|
|
96
|
+
return lines.join('\n');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (process.argv[1] && process.argv[1].endsWith('ship-readout.js')) {
|
|
100
|
+
const i = process.argv.indexOf('--days');
|
|
101
|
+
const days = i > 0 ? Number(process.argv[i + 1]) : DEFAULT_DAYS;
|
|
102
|
+
const { readReceipts } = await import('./receipts.js');
|
|
103
|
+
const { defaultStateDir } = await import('./cache.js');
|
|
104
|
+
const rs = await readReceipts(defaultStateDir());
|
|
105
|
+
console.log(formatReadout(shipReadout(rs, { windowDays: Number.isFinite(days) && days > 0 ? days : DEFAULT_DAYS })));
|
|
106
|
+
}
|
package/signing-audit.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// §8.3 signing audit — "what did I sign, and when."
|
|
2
|
+
//
|
|
3
|
+
// A signed mandate is a CREDENTIAL asserted to a third party, so minting one is at least as
|
|
4
|
+
// audit-worthy as a consultation. But it is NOT a §10.6 consultation receipt: it has no
|
|
5
|
+
// signal, no deference level, and it must not feed the act-as-me learning loop or the
|
|
6
|
+
// agreement-rate metric (which would corrupt both). So this is its OWN small append-only,
|
|
7
|
+
// encrypted-at-rest log — the same atomic-write + domain-separated-key discipline as
|
|
8
|
+
// receipts.js, but a separate artifact.
|
|
9
|
+
//
|
|
10
|
+
// It records a DIGEST of what was signed plus coarse metadata (grant count, expiry,
|
|
11
|
+
// requesting client) — never the full mandate, never the signature bytes. The digest is the
|
|
12
|
+
// stable reference: given the mandate later, an auditor recomputes it and confirms it is the
|
|
13
|
+
// object that was signed.
|
|
14
|
+
|
|
15
|
+
import { mkdir, writeFile, readFile, rename, chmod } from 'fs/promises';
|
|
16
|
+
import { existsSync } from 'fs';
|
|
17
|
+
import { createHash, randomUUID } from 'crypto';
|
|
18
|
+
import { join } from 'path';
|
|
19
|
+
import { Phaedo } from './lib/phaedo-crypto.js';
|
|
20
|
+
import { getOrCreateCacheKey } from './cache.js';
|
|
21
|
+
import { signingInput } from './signing.js';
|
|
22
|
+
|
|
23
|
+
const { Envelope } = Phaedo;
|
|
24
|
+
|
|
25
|
+
export const SIGNING_AUDIT_CONTEXT = 'phaedo:signing-audit:enc:v0.1';
|
|
26
|
+
export const DEFAULT_CAP = 5000;
|
|
27
|
+
const FILE = 'signing-audit.enc';
|
|
28
|
+
|
|
29
|
+
function auditPath(stateDir) { return join(stateDir, FILE); }
|
|
30
|
+
|
|
31
|
+
async function deriveKey(stateDir) {
|
|
32
|
+
const root = await getOrCreateCacheKey(stateDir);
|
|
33
|
+
const ikm = await crypto.subtle.importKey('raw', root, 'HKDF', false, ['deriveBits']);
|
|
34
|
+
const bits = await crypto.subtle.deriveBits(
|
|
35
|
+
{ name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(0), info: new TextEncoder().encode(SIGNING_AUDIT_CONTEXT) },
|
|
36
|
+
ikm, 32 * 8,
|
|
37
|
+
);
|
|
38
|
+
return new Uint8Array(bits);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function readStore(stateDir) {
|
|
42
|
+
const p = auditPath(stateDir);
|
|
43
|
+
if (!existsSync(p)) return { records: [] };
|
|
44
|
+
const key = await deriveKey(stateDir);
|
|
45
|
+
const { envelope } = JSON.parse(await readFile(p, 'utf8'));
|
|
46
|
+
const plaintext = await Envelope.unwrap(envelope, key);
|
|
47
|
+
try {
|
|
48
|
+
const store = JSON.parse(new TextDecoder().decode(plaintext));
|
|
49
|
+
return Array.isArray(store?.records) ? store : { records: [] };
|
|
50
|
+
} finally {
|
|
51
|
+
plaintext.fill(0);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function writeStore(stateDir, store, now) {
|
|
56
|
+
await mkdir(stateDir, { recursive: true, mode: 0o700 });
|
|
57
|
+
const key = await deriveKey(stateDir);
|
|
58
|
+
const plaintext = new TextEncoder().encode(JSON.stringify(store));
|
|
59
|
+
const envelope = await Envelope.wrap(plaintext, key, {
|
|
60
|
+
artifact_kind: 'signing_audit',
|
|
61
|
+
count: store.records.length,
|
|
62
|
+
created_at: new Date(now).toISOString(),
|
|
63
|
+
});
|
|
64
|
+
plaintext.fill(0);
|
|
65
|
+
const p = auditPath(stateDir);
|
|
66
|
+
const tmp = `${p}.tmp`;
|
|
67
|
+
await writeFile(tmp, JSON.stringify({ envelope }), { mode: 0o600 });
|
|
68
|
+
await chmod(tmp, 0o600);
|
|
69
|
+
await rename(tmp, p); // atomic on POSIX
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Build a signing-audit record from a signed mandate. Records the digest of the SIGNED bytes
|
|
74
|
+
* (§6 canonical form, `signature` omitted) — not the mandate content and not the signature.
|
|
75
|
+
*/
|
|
76
|
+
export function buildSigningAuditRecord({ requestId, mandate, clientId, agentId, now = Date.now() }) {
|
|
77
|
+
const digest = createHash('sha256').update(signingInput(mandate, { field: 'signature' })).digest('base64url');
|
|
78
|
+
const grants = Array.isArray(mandate?.grants) ? mandate.grants.length : 0;
|
|
79
|
+
return {
|
|
80
|
+
audit_id: randomUUID(),
|
|
81
|
+
signed_at: typeof mandate?.signed_at === 'string' ? mandate.signed_at : new Date(now).toISOString(),
|
|
82
|
+
request_id: typeof requestId === 'string' ? requestId : null,
|
|
83
|
+
// WHO the credential was minted for: the paired client that served the ceremony.
|
|
84
|
+
client_id: typeof clientId === 'string' && clientId ? clientId : null,
|
|
85
|
+
// WHO claimed to ask (self-asserted, audit label only — mirrors §10.6).
|
|
86
|
+
agent_id: typeof agentId === 'string' && agentId ? agentId : null,
|
|
87
|
+
mandate_digest: digest,
|
|
88
|
+
grant_count: grants,
|
|
89
|
+
mandate_expires_at: typeof mandate?.expires_at === 'string' ? mandate.expires_at : null,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let _chain = Promise.resolve();
|
|
94
|
+
/**
|
|
95
|
+
* Append a signing-audit record. Serialized per process (like receipts) so two concurrent
|
|
96
|
+
* signs can't clobber the store. Best-effort by contract at the call site: a failed audit
|
|
97
|
+
* write must never make a real signature look unsigned — the caller logs and continues.
|
|
98
|
+
*/
|
|
99
|
+
export async function appendSigningAudit(stateDir, record, { cap = DEFAULT_CAP, now = Date.now() } = {}) {
|
|
100
|
+
_chain = _chain.then(async () => {
|
|
101
|
+
const store = await readStore(stateDir).catch(() => ({ records: [] }));
|
|
102
|
+
store.records.push(record);
|
|
103
|
+
if (store.records.length > cap) store.records = store.records.slice(-cap);
|
|
104
|
+
await writeStore(stateDir, store, now);
|
|
105
|
+
});
|
|
106
|
+
return _chain;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function readSigningAudit(stateDir) {
|
|
110
|
+
return (await readStore(stateDir).catch(() => ({ records: [] }))).records;
|
|
111
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// §8.3 signing ceremony — the MCP (server) half of the round-trip.
|
|
2
|
+
//
|
|
3
|
+
// The server holds the mandate; the phone holds the only key that can sign it. This module
|
|
4
|
+
// deposits a SIGN REQUEST for the subject, polls for the signed result, and VERIFIES it —
|
|
5
|
+
// reusing the escalation transport (relay lane, envelope crypto, first-answer-wins,
|
|
6
|
+
// push-to-wake) but NOT its semantics. An escalation answer is approve/deny/modify — a
|
|
7
|
+
// decision internal to the subject's system. A signature is a credential asserted to a
|
|
8
|
+
// third party. Conflating them is the §10.2 action_binding failure mode one layer up, so
|
|
9
|
+
// the request carries a distinct `kind` and the response is a signature or an explicit
|
|
10
|
+
// refusal, never a decision verb.
|
|
11
|
+
//
|
|
12
|
+
// SIGN WHAT YOU SEE. The request carries the mandate OBJECT, not pre-computed bytes. The
|
|
13
|
+
// phone renders that object AND derives the signing input from it, so it cannot be shown
|
|
14
|
+
// one thing and made to sign another. The server re-derives the same bytes here and
|
|
15
|
+
// verifies against them, which independently confirms the phone signed the mandate the
|
|
16
|
+
// server actually built.
|
|
17
|
+
|
|
18
|
+
import { Phaedo, b64uDec } from './lib/phaedo-crypto.js';
|
|
19
|
+
import { signingInput, verifyEs256 } from './signing.js';
|
|
20
|
+
|
|
21
|
+
const { Kdf, Envelope } = Phaedo;
|
|
22
|
+
const DEFAULT_RELAY = 'https://phaedo-relay.fly.dev';
|
|
23
|
+
|
|
24
|
+
const SIGN_REQUEST_KIND = 'sign_request';
|
|
25
|
+
const SIGN_RESPONSE_KIND = 'sign_response';
|
|
26
|
+
// AP2 mandates omit `signature` from the signing input (the field the signature fills).
|
|
27
|
+
const AP2_SIGNATURE_FIELD = 'signature';
|
|
28
|
+
|
|
29
|
+
export function relayBaseFromEnv(env = process.env) {
|
|
30
|
+
// PHAEDO_RELAY is the name every other lane reads (escalation, receipts, capture, delegation).
|
|
31
|
+
// This one read PHAEDO_RELAY_URL alone, so a self-hosted relay silently did NOT cover the §8.3
|
|
32
|
+
// signing ceremony — sign requests kept going to the default host. The old name is still
|
|
33
|
+
// honoured so an existing setup does not break.
|
|
34
|
+
return env.PHAEDO_RELAY || env.PHAEDO_RELAY_URL || DEFAULT_RELAY;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function relayAuthHeader(pairing) {
|
|
38
|
+
const id = pairing && pairing.relay_device_id;
|
|
39
|
+
const secret = pairing && pairing.relay_device_secret;
|
|
40
|
+
return id && secret ? { authorization: `Bearer ${id}:${secret}` } : {};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function genRequestId() {
|
|
44
|
+
if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID();
|
|
45
|
+
return `sig-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Build the encrypted SIGN REQUEST deposit body. Sealed under fp_sync_key, exactly like an
|
|
50
|
+
* escalation request, and posted to the same relay lane — the phone routes on `kind`.
|
|
51
|
+
* `mandate` is the object to be signed (unsigned; the `signature` field stays null/absent).
|
|
52
|
+
*/
|
|
53
|
+
export async function buildSignRequestDeposit(pairKeyB64u, requestId, mandate, { windowMs, now = () => new Date().toISOString() } = {}) {
|
|
54
|
+
const pairKey = b64uDec(pairKeyB64u);
|
|
55
|
+
let fpSyncKey;
|
|
56
|
+
try {
|
|
57
|
+
fpSyncKey = await Kdf.deriveFpSyncKey(pairKey);
|
|
58
|
+
} finally {
|
|
59
|
+
pairKey.fill(0);
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
const inner = { kind: SIGN_REQUEST_KIND, request_id: requestId, data: { mandate } };
|
|
63
|
+
const plaintext = new TextEncoder().encode(JSON.stringify(inner));
|
|
64
|
+
const envelope = await Envelope.wrap(plaintext, fpSyncKey, { artifact_kind: SIGN_REQUEST_KIND, created_at: now() });
|
|
65
|
+
plaintext.fill(0);
|
|
66
|
+
return { request_id: requestId, envelope, ...(windowMs ? { window_ms: windowMs } : {}) };
|
|
67
|
+
} finally {
|
|
68
|
+
if (fpSyncKey && fpSyncKey.fill) fpSyncKey.fill(0);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Decrypt the phone's SIGN RESPONSE. Returns the inner data — either
|
|
74
|
+
* `{ signature, encoding, signed_at }` or `{ refused: true, reason? }` — or null on any
|
|
75
|
+
* failure. A malformed or forged response must never crash the agent's resume path.
|
|
76
|
+
*/
|
|
77
|
+
export async function unwrapSignResponse(pairKeyB64u, body) {
|
|
78
|
+
if (!body || !body.envelope) return null;
|
|
79
|
+
const pairKey = b64uDec(pairKeyB64u);
|
|
80
|
+
let fpSyncKey;
|
|
81
|
+
try {
|
|
82
|
+
fpSyncKey = await Kdf.deriveFpSyncKey(pairKey);
|
|
83
|
+
} catch { pairKey.fill(0); return null; }
|
|
84
|
+
pairKey.fill(0);
|
|
85
|
+
try {
|
|
86
|
+
const plaintext = await Envelope.unwrap(body.envelope, fpSyncKey);
|
|
87
|
+
const inner = JSON.parse(new TextDecoder().decode(plaintext));
|
|
88
|
+
if (inner && inner.kind === SIGN_RESPONSE_KIND && inner.data) return inner.data;
|
|
89
|
+
return null;
|
|
90
|
+
} catch {
|
|
91
|
+
return null;
|
|
92
|
+
} finally {
|
|
93
|
+
if (fpSyncKey && fpSyncKey.fill) fpSyncKey.fill(0);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Verify a signed mandate. The mandate is re-canonicalized here (signature field omitted)
|
|
99
|
+
* and checked against the subject's published signing key — so a valid result means BOTH
|
|
100
|
+
* that the signature is genuine AND that the phone signed the exact mandate the server
|
|
101
|
+
* built. Returns { verified, reason? }.
|
|
102
|
+
*
|
|
103
|
+
* `signingPublicKey` is the base64 X9.63 point from `phaedo_signing_public_key` (published
|
|
104
|
+
* over the trusted pairing, §8.3 step 2). If it is missing, verification cannot proceed —
|
|
105
|
+
* that is distinct from a bad signature and is reported as such.
|
|
106
|
+
*/
|
|
107
|
+
export function verifySignedMandate({ mandate, signature, encoding = 'der', signingPublicKey }) {
|
|
108
|
+
if (!signingPublicKey) return { verified: false, reason: 'no-published-signing-key' };
|
|
109
|
+
if (typeof signature !== 'string' || !signature) return { verified: false, reason: 'no-signature' };
|
|
110
|
+
let sigBytes, keyBytes;
|
|
111
|
+
try {
|
|
112
|
+
sigBytes = Buffer.from(signature, 'base64');
|
|
113
|
+
keyBytes = Buffer.from(signingPublicKey, 'base64');
|
|
114
|
+
} catch {
|
|
115
|
+
return { verified: false, reason: 'undecodable' };
|
|
116
|
+
}
|
|
117
|
+
try {
|
|
118
|
+
const verified = verifyEs256({
|
|
119
|
+
payload: mandate,
|
|
120
|
+
signature: sigBytes,
|
|
121
|
+
publicKey: keyBytes,
|
|
122
|
+
encoding,
|
|
123
|
+
field: AP2_SIGNATURE_FIELD,
|
|
124
|
+
});
|
|
125
|
+
return verified ? { verified: true } : { verified: false, reason: 'signature-mismatch' };
|
|
126
|
+
} catch (e) {
|
|
127
|
+
// A malformed KEY (not a bad signature) throws in the core; surface it as its own
|
|
128
|
+
// reason rather than reporting "not the subject's signature".
|
|
129
|
+
return { verified: false, reason: `key-error:${String(e && e.message || e)}` };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The exact bytes the phone is asked to sign — exported so a caller/test can assert the
|
|
134
|
+
* server and phone agree on them. */
|
|
135
|
+
export function mandateSigningInput(mandate) {
|
|
136
|
+
return signingInput(mandate, { field: AP2_SIGNATURE_FIELD });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Deposit a SIGN REQUEST and wake the device. Returns
|
|
141
|
+
* { deposited, request_id, window_ms, expires_at } or { deposited:false, reason }.
|
|
142
|
+
*/
|
|
143
|
+
export async function depositSignRequest(pairing, mandate, deps = {}) {
|
|
144
|
+
const fetchFn = deps.fetch || globalThis.fetch;
|
|
145
|
+
if (!pairing || !pairing.pair_key) return { deposited: false, reason: 'unpaired' };
|
|
146
|
+
if (!pairing.relay_device_id) return { deposited: false, reason: 'no-relay-device-id' };
|
|
147
|
+
if (!mandate || typeof mandate !== 'object') return { deposited: false, reason: 'no-mandate' };
|
|
148
|
+
// A mandate that already carries a signature must not be re-sent for signing.
|
|
149
|
+
if (mandate.signature != null) return { deposited: false, reason: 'already-signed' };
|
|
150
|
+
|
|
151
|
+
const windowMs = Math.max(1000, Number(deps.windowMs) || 5 * 60 * 1000);
|
|
152
|
+
const relayBase = deps.relayBase || relayBaseFromEnv();
|
|
153
|
+
const requestId = deps.requestId || genRequestId();
|
|
154
|
+
try {
|
|
155
|
+
const body = await buildSignRequestDeposit(pairing.pair_key, requestId, mandate, { windowMs });
|
|
156
|
+
const res = await fetchFn(`${relayBase}/escalation/${encodeURIComponent(pairing.relay_device_id)}`, {
|
|
157
|
+
method: 'POST',
|
|
158
|
+
headers: { 'Content-Type': 'application/json', ...relayAuthHeader(pairing) },
|
|
159
|
+
body: JSON.stringify(body),
|
|
160
|
+
signal: AbortSignal.timeout(deps.depositTimeoutMs || 8000),
|
|
161
|
+
});
|
|
162
|
+
if (!res || !res.ok) return { deposited: false, reason: `deposit-failed-${res && res.status}`, request_id: requestId };
|
|
163
|
+
let info = {};
|
|
164
|
+
try { info = await res.json(); } catch { /* body optional */ }
|
|
165
|
+
return { deposited: true, request_id: requestId, window_ms: windowMs, expires_at: info.expires_at, woke: info.woke };
|
|
166
|
+
} catch (e) {
|
|
167
|
+
return { deposited: false, reason: `deposit-error:${String(e && e.message || e)}`, request_id: requestId };
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Poll ONCE for a signed mandate. Returns:
|
|
173
|
+
* { resolved:true, signed:true, mandate } — signature verified; mandate now carries it
|
|
174
|
+
* { resolved:true, signed:false, reason:'refused' } — the subject declined to sign
|
|
175
|
+
* { resolved:true, signed:false, reason:'verify-failed', detail } — a response arrived but did not verify
|
|
176
|
+
* { resolved:false, status:'pending' | 'expired' }
|
|
177
|
+
* Never throws. A verify failure is resolved-but-unsigned, never silently treated as signed.
|
|
178
|
+
*/
|
|
179
|
+
export async function pollSignOnce(pairing, requestId, mandate, deps = {}) {
|
|
180
|
+
const fetchFn = deps.fetch || globalThis.fetch;
|
|
181
|
+
if (!pairing || !pairing.pair_key || !requestId) return { resolved: false, status: 'expired', reason: 'bad-args' };
|
|
182
|
+
if (deps.expiresAt && (deps.now ? deps.now() : Date.now()) > deps.expiresAt) return { resolved: false, status: 'expired' };
|
|
183
|
+
const relayBase = deps.relayBase || relayBaseFromEnv();
|
|
184
|
+
let body = null;
|
|
185
|
+
try {
|
|
186
|
+
const res = await fetchFn(`${relayBase}/escalation/response/${encodeURIComponent(requestId)}`, {
|
|
187
|
+
headers: relayAuthHeader(pairing),
|
|
188
|
+
signal: AbortSignal.timeout(deps.pollTimeoutMs || 8000),
|
|
189
|
+
});
|
|
190
|
+
if (res && res.ok) body = await res.json();
|
|
191
|
+
} catch { /* transient */ }
|
|
192
|
+
if (!body) return { resolved: false, status: 'pending' };
|
|
193
|
+
|
|
194
|
+
const data = await unwrapSignResponse(pairing.pair_key, body);
|
|
195
|
+
if (!data) return { resolved: false, status: 'pending' }; // undecryptable → keep waiting, don't crash
|
|
196
|
+
|
|
197
|
+
if (data.refused === true) {
|
|
198
|
+
return { resolved: true, signed: false, reason: 'refused', request_id: requestId };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const check = verifySignedMandate({
|
|
202
|
+
mandate,
|
|
203
|
+
signature: data.signature,
|
|
204
|
+
encoding: data.encoding || 'der',
|
|
205
|
+
signingPublicKey: deps.signingPublicKey,
|
|
206
|
+
});
|
|
207
|
+
if (!check.verified) {
|
|
208
|
+
return { resolved: true, signed: false, reason: 'verify-failed', detail: check.reason, request_id: requestId };
|
|
209
|
+
}
|
|
210
|
+
return {
|
|
211
|
+
resolved: true,
|
|
212
|
+
signed: true,
|
|
213
|
+
request_id: requestId,
|
|
214
|
+
mandate: { ...mandate, signature: data.signature, signature_encoding: data.encoding || 'der',
|
|
215
|
+
signed_at: typeof data.signed_at === 'string' ? data.signed_at : undefined },
|
|
216
|
+
};
|
|
217
|
+
}
|