phaedo-mcp 0.2.1 → 0.3.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.
Files changed (62) hide show
  1. package/NOTICE +17 -0
  2. package/README.md +51 -9
  3. package/action-binding.js +82 -0
  4. package/agreement.js +38 -0
  5. package/ap2.js +214 -0
  6. package/autonomy-promotion.js +280 -0
  7. package/build-package.mjs +25 -10
  8. package/capture-a11y.js +116 -0
  9. package/capture-local.js +85 -0
  10. package/capture-sync.js +99 -0
  11. package/consult-core.js +81 -19
  12. package/consult-policy.js +427 -33
  13. package/consult.js +25 -3
  14. package/delegation-sync.js +89 -0
  15. package/escalation-policy.sample.json +13 -0
  16. package/escalation.js +383 -0
  17. package/fingerprint-source.js +78 -4
  18. package/health.js +54 -0
  19. package/hooks/session-end.mjs +240 -0
  20. package/install.mjs +164 -21
  21. package/lib/capture-home.js +46 -0
  22. package/lib/domain.js +101 -0
  23. package/lib/is-main.js +40 -0
  24. package/lib/phaedo-crypto.js +17 -1
  25. package/package.json +26 -6
  26. package/pairing.js +5 -1
  27. package/phone-source.js +92 -12
  28. package/projection.js +4 -1
  29. package/pull.js +19 -3
  30. package/receipts-sync.js +197 -0
  31. package/receipts.js +69 -16
  32. package/server.js +305 -15
  33. package/server.json +4 -4
  34. package/signing-audit.js +111 -0
  35. package/signing-request.js +217 -0
  36. package/signing.js +176 -0
  37. package/vendor/authored-source.js +250 -0
  38. package/vendor/capabilities.js +203 -0
  39. package/vendor/capture/capture-core.js +67 -0
  40. package/vendor/capture/detectors.js +287 -0
  41. package/vendor/capture/exchanges.js +77 -0
  42. package/vendor/capture/model-detect.js +195 -0
  43. package/vendor/capture/owner.js +35 -0
  44. package/vendor/capture/package.json +4 -0
  45. package/vendor/capture/source-id.js +56 -0
  46. package/vendor/capture/state.js +148 -0
  47. package/vendor/capture/transcript.js +153 -0
  48. package/vendor/commitments.js +169 -0
  49. package/vendor/context-block.js +251 -18
  50. package/vendor/envelope.js +50 -4
  51. package/vendor/extraction/capture-combine.js +160 -0
  52. package/vendor/extraction/catalog.js +232 -0
  53. package/vendor/extraction/delta-lift.js +109 -0
  54. package/vendor/extraction/fingerprint-update.js +227 -0
  55. package/vendor/extraction/package.json +4 -0
  56. package/vendor/extraction/persist.js +40 -0
  57. package/vendor/extraction/reconcile.js +269 -0
  58. package/vendor/extraction/resolve.js +178 -0
  59. package/vendor/extraction/rule-promotion.js +137 -0
  60. package/vendor/kdf.js +20 -1
  61. package/vendor/protocol.js +7 -0
  62. package/vendored-files.mjs +82 -0
package/server.js CHANGED
@@ -16,12 +16,16 @@ import { z } from 'zod';
16
16
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
17
17
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
18
18
 
19
- import { loadFingerprint, loadFingerprintPreferCache } from './fingerprint-source.js';
20
- import { hasPairingRecord, loadPairingRecord } from './phone-source.js';
19
+ import { loadFingerprint, loadFingerprintPreferCache, sourceOf } from './fingerprint-source.js';
20
+ import { hasPairingRecord, loadPairingRecord, readServingClientId } from './phone-source.js';
21
21
  import { depositDelegationSuggestions } from './delegation-sync.js';
22
22
  import { depositReceiptsDigest, drainAndApplyMarks } from './receipts-sync.js';
23
23
  import { buildDelegationPromotions } from './consult-core.js';
24
+ import { buildAutonomyPromotions } from './autonomy-promotion.js';
24
25
  import { depositEscalation, pollEscalationOnce, shouldEscalate, loadEscalationPolicy } from './escalation.js';
26
+ import { buildIntentMandatePayload } from './ap2.js';
27
+ import { depositSignRequest, pollSignOnce } from './signing-request.js';
28
+ import { buildSigningAuditRecord, appendSigningAudit } from './signing-audit.js';
25
29
  import { buildInjectionResponse, errorResponse, PhaedoError } from './projection.js';
26
30
  import { resolveConsultation, checkAuthorization, consultOptsFromEnv, CONSULTATION_TYPES, consultDrivers, deriveDelegationSignals, withDelegationSignals, authoredDelegationSignals } from './consult.js';
27
31
  import { defaultStateDir } from './cache.js';
@@ -35,11 +39,53 @@ const BASE_DIR = dirname(fileURLToPath(import.meta.url));
35
39
  const RECEIPTS_CAP = receiptsCapFromEnv(process.env);
36
40
  async function emitReceipt(via, args, response, drivers) {
37
41
  try {
38
- const receipt = buildReceipt({ via, agentId: args?.agent_id, request: args, response, drivers });
42
+ // `clientId` is what the audit can actually rely on: the paired client that
43
+ // SERVED this call, versus `agentId` — the vendor string the caller claimed.
44
+ // Recording both is the point; they are believed to different degrees.
45
+ const receipt = buildReceipt({ via, agentId: args?.agent_id, clientId: CONSULT_OPTS.clientId, request: args, response, drivers });
39
46
  await appendReceipt(defaultStateDir(), receipt, { cap: RECEIPTS_CAP });
47
+ // RETURN THE HANDLE. The tool descriptions instruct the agent to record what the subject
48
+ // decided, but no tool gave it anything to name — so `phaedo_record_outcome` defaulted to
49
+ // 'latest' and attributed the decision to whatever receipt happened to be newest. A $12k
50
+ // payment rejection landed on an unrelated writing consult. That feeds deriveDelegationSignals
51
+ // and the §10.7 promotion evidence, so a mis-attributed override both erodes caution on the
52
+ // wrong dimension AND removes the contrary evidence that would have disqualified the right one.
53
+ return receipt.receipt_id;
40
54
  } catch (e) {
41
55
  process.stderr.write(`[phaedo-mcp] receipt write failed (consultation still answered): ${e.message}\n`);
42
56
  }
57
+ return null;
58
+ }
59
+
60
+ // Effects that let an agent act with LESS oversight. Only these are withheld when the read
61
+ // is stale; a restrictive effect keeps applying, because a guardrail the subject may since
62
+ // have removed still fails in the safe direction.
63
+ const EXPANDING_SIGNALS = new Set(['proceed', 'autoproceed', 'bias_bold']);
64
+
65
+ /**
66
+ * Downgrade an authority-EXPANDING answer that was computed from a stale (cached) read.
67
+ * Returns the response unchanged for a live read, or for any answer that already defers.
68
+ * Always annotates provenance so the caller can SAY where the answer came from — the
69
+ * silence is what let a stale "you're authorized" go unnoticed for a day.
70
+ */
71
+ function withStaleAuthorityGuard(response, vd) {
72
+ const src = sourceOf(vd);
73
+ const out = { ...response };
74
+ const hours = src.ageMs != null ? Math.round(src.ageMs / 3600000) : null;
75
+ out.profile_source = src.live
76
+ ? { live: true }
77
+ : { live: false, cached_age_hours: hours, note: 'Answered from a cached profile — the subject\'s device could not be reached.' };
78
+ if (src.live) return out;
79
+ if (!EXPANDING_SIGNALS.has(String(response?.signal))) return out;
80
+
81
+ out.signal = 'escalate';
82
+ out.authorization_matched = false;
83
+ out.stale_downgrade = true;
84
+ out.rationale_hint =
85
+ `Withheld: this would have proceeded automatically, but the decision was computed from a cached copy of the ` +
86
+ `subject's profile${hours != null ? ` about ${hours}h old` : ''} because their device could not be reached. ` +
87
+ `A pre-approval that may since have been revoked must not be acted on unattended — ask the subject.`;
88
+ return out;
43
89
  }
44
90
 
45
91
  // Load the fingerprint for a consult and fold in the act-as-me channel: delegation
@@ -115,16 +161,26 @@ async function maybePullPhoneMarks() {
115
161
  async function maybeDepositDelegationPromotions(vd) {
116
162
  try {
117
163
  if (!hasPairingRecord(BASE_DIR)) return; // no extension to deposit to
118
- const promos = buildDelegationPromotions(await readReceipts(defaultStateDir()), (vd && vd.phaedo_fingerprint) || {});
164
+ const receipts = await readReceipts(defaultStateDir());
165
+ const fp = (vd && vd.phaedo_fingerprint) || {};
166
+ // Two channels over one deposit. Delegation promotions learn from the subject's
167
+ // DISAGREEMENTS and can only make the agent more deferential; autonomy promotions
168
+ // (§10.7) learn from deliberate, repeated AGREEMENT and are the only channel that
169
+ // can propose less friction — bounded, binding-gated and expiring, and still only
170
+ // a proposal the subject endorses.
171
+ const promos = buildDelegationPromotions(receipts, fp)
172
+ .concat(buildAutonomyPromotions(receipts, fp));
119
173
  if (!promos.length) return;
120
- const sig = promos.map(p => `${p.decision?.dimension}|${p.decision?.domain || ''}|${p.decision?.value}`).sort().join(',');
174
+ const sig = promos.map(p => p.source === 'autonomy_promotion'
175
+ ? `auto|${(p.match?.domains || []).join('+')}|${p.match?.magnitude_max}`
176
+ : `${p.decision?.dimension}|${p.decision?.domain || ''}|${p.decision?.value}`).sort().join(',');
121
177
  if (sig === _lastDepositSig) return; // identical set already deposited — skip the relay round-trip
122
178
  const res = await depositDelegationSuggestions(await loadPairingRecord(BASE_DIR), promos);
123
179
  if (res && res.deposited) _lastDepositSig = sig; // latch only on success, so a failed deposit retries next consult
124
180
  else if (res && !res.deposited) // promotions existed but didn't land — say why (don't fail silently)
125
- process.stderr.write(`[phaedo-mcp] ${promos.length} delegation promotion(s) ready but deposit skipped: ${res.reason || 'http ' + res.status}${res.reason === 'no-relay-device-id' ? ' — pairing has no relay lane (local-only pairing)' : ''}\n`);
181
+ process.stderr.write(`[phaedo-mcp] ${promos.length} rule promotion(s) ready but deposit skipped: ${res.reason || 'http ' + res.status}${res.reason === 'no-relay-device-id' ? ' — pairing has no relay lane (local-only pairing)' : ''}\n`);
126
182
  } catch (e) {
127
- process.stderr.write(`[phaedo-mcp] delegation promotion deposit skipped: ${e.message}\n`);
183
+ process.stderr.write(`[phaedo-mcp] rule promotion deposit skipped: ${e.message}\n`);
128
184
  }
129
185
  }
130
186
 
@@ -145,6 +201,11 @@ if (existsSync(POLICIES_PATH)) {
145
201
  try { CONSULT_OPTS.policies = JSON.parse(readFileSync(POLICIES_PATH, 'utf8')); }
146
202
  catch (e) { process.stderr.write(`[phaedo-mcp] ignoring malformed consult policies (${POLICIES_PATH}): ${e.message}\n`); }
147
203
  }
204
+ // §10.5 `agents`: this process's OWN paired-client identity, resolved once at startup.
205
+ // One server serves one paired client, so it never asks a caller who is calling — it
206
+ // reads who it IS. Null (unpaired / unreadable) makes `agents`-scoped RELAXING rules
207
+ // inert, which is the fail-safe direction; the server still answers everything else.
208
+ CONSULT_OPTS.clientId = readServingClientId(BASE_DIR);
148
209
 
149
210
  // Escalation PUSH policy (which signals wake the subject): the subject-configurable knob
150
211
  // over the stakes-gated default. Local file (PHAEDO_ESCALATION_POLICY, else
@@ -157,7 +218,12 @@ if (existsSync(ESCALATION_POLICY_PATH)) {
157
218
  catch (e) { process.stderr.write(`[phaedo-mcp] ignoring malformed escalation policy (${ESCALATION_POLICY_PATH}): ${e.message}\n`); }
158
219
  }
159
220
 
160
- const server = new McpServer({ name: 'phaedo', version: '0.2.1' });
221
+ // Read from package.json rather than a literal: this string was pinned at '0.2.2'
222
+ // while package.json moved on, so the handshake advertised a version that had not
223
+ // been the package's for two months. One source of truth; test-package-closure.mjs
224
+ // asserts the two agree.
225
+ const PKG_VERSION = JSON.parse(readFileSync(join(BASE_DIR, 'package.json'), 'utf8')).version;
226
+ const server = new McpServer({ name: 'phaedo', version: PKG_VERSION });
161
227
 
162
228
  // ── Tool: phaedo_request_injection (binding §3.1) ─────────────────────────────
163
229
  server.registerTool(
@@ -172,6 +238,7 @@ server.registerTool(
172
238
  session_context: z
173
239
  .object({
174
240
  platform: z.string().optional(),
241
+ domain: z.string().optional(),
175
242
  task_hint: z.string().optional(),
176
243
  audience_hint: z.string().optional(),
177
244
  max_tokens: z.number().optional(),
@@ -205,6 +272,24 @@ server.registerTool(
205
272
  }
206
273
  );
207
274
 
275
+ // §10.2 `action_binding` — the concrete call the action_descriptor claims to
276
+ // describe. Optional and back-compatible: an unbound request resolves exactly as
277
+ // before. Shared by phaedo_consult / phaedo_check_authorization / phaedo_escalate
278
+ // so the three can't drift. See mcp/action-binding.js for why this exists.
279
+ const ACTION_BINDING_SCHEMA = z
280
+ .object({
281
+ tool_name: z.string().describe('The tool/function the agent is about to invoke.'),
282
+ arguments_hash: z.string().describe('Hex or base64url digest over the RFC 8785 canonical form of the arguments. Never the arguments themselves.'),
283
+ arguments_preview: z.string().optional().describe('Optional short human-readable summary of the arguments, shown to the subject on an escalation.'),
284
+ })
285
+ .optional()
286
+ .describe(
287
+ 'Binds this consultation to the concrete call it describes. The action_descriptor is your ACCOUNT of the action; ' +
288
+ 'this is the action. Recorded on the audit receipt so a later mismatch is provable, shown to the subject when an ' +
289
+ 'escalation reaches their phone, and REQUIRED by any standing rule the subject marked require_binding before it ' +
290
+ 'will auto-proceed.',
291
+ );
292
+
208
293
  // ── Tool: phaedo_consult (binding §3.2, spec §10) ─────────────────────────────
209
294
  // Agent consultation: query the fingerprint at a decision point and get back a
210
295
  // constrained SIGNAL (§10.3) — never layer content (§10.4 boundary). One tool,
@@ -235,6 +320,7 @@ server.registerTool(
235
320
  summary: z.string().optional(),
236
321
  })
237
322
  .optional(),
323
+ action_binding: ACTION_BINDING_SCHEMA,
238
324
  context: z.record(z.any()).optional(),
239
325
  },
240
326
  // Not read-only: appends a local §10.6 audit receipt and may best-effort deposit a
@@ -244,12 +330,18 @@ server.registerTool(
244
330
  async (args) => {
245
331
  try {
246
332
  const vd = await loadConsultVd(BASE_DIR); // fingerprint + act-as-me delegation overlay
247
- const response = await resolveConsultation(vd, args || {}, CONSULT_OPTS);
333
+ // STALE-AUTHORITY GUARD. This used to be applied to phaedo_check_authorization ALONE, so
334
+ // the two tools an autonomous agent actually uses answered from a possibly-revoked cached
335
+ // profile with no downgrade and no provenance. That is the 2026-07-25 device defect (a
336
+ // revoked $500 grant honoured from a 22h cache) still live on the paths that matter most.
337
+ const response = withStaleAuthorityGuard(
338
+ await resolveConsultation(vd, args || {}, CONSULT_OPTS), vd);
248
339
  // Provenance for the local audit receipt only — the cues that drove this
249
340
  // (never in `response`; §10.4). Best-effort: a failure must not block the answer.
250
341
  let drivers = [];
251
342
  try { drivers = consultDrivers(vd, args || {}); } catch { /* provenance is non-critical */ }
252
- await emitReceipt('phaedo_consult', args, response, drivers); // every resolution, abstains included
343
+ const receiptId = await emitReceipt('phaedo_consult', args, response, drivers); // every resolution, abstains included
344
+ if (receiptId) response.receipt_id = receiptId; // the handle for phaedo_record_outcome
253
345
  await maybeDepositDelegationPromotions(vd); // act-as-me promotion (best-effort)
254
346
  await maybeDepositReceiptsDigest(); // G1+G2 audit channel — refresh the phone's view
255
347
  return {
@@ -298,6 +390,7 @@ server.registerTool(
298
390
  summary: z.string().optional(),
299
391
  })
300
392
  .optional(),
393
+ action_binding: ACTION_BINDING_SCHEMA,
301
394
  },
302
395
  // Deterministic pre-flight; appends a local audit receipt. Non-destructive, device-local.
303
396
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
@@ -306,12 +399,26 @@ server.registerTool(
306
399
  try {
307
400
  const req = { consultation_type: 'action_approval', ...(args || {}) };
308
401
  const vd = await loadFingerprint(BASE_DIR);
309
- const response = checkAuthorization(vd, req, CONSULT_OPTS);
402
+ // STALENESS MAY NOT EXPAND AUTONOMY. When the live pull failed we are answering from
403
+ // an at-rest cache that predates anything the subject has changed since — including a
404
+ // REVOCATION. Observed on device 2026-07-25: a revoked $500 payment grant kept
405
+ // returning "authorized" from a 22-hour-old copy. A stale read may still make an agent
406
+ // MORE deferential (that direction is safe and useful phone-absent); it may never be
407
+ // the basis for proceeding unattended. Same asymmetry as `expires_at`, `require_binding`
408
+ // and the §10.2 domain aliases: narrow freely, widen never.
409
+ // The receipt is gated on `authorization_matched`, which the guard CLEARS — so the one
410
+ // case that most needs an audit trail (an authorization fired against a possibly-revoked
411
+ // cached grant and had to be withheld) used to leave no record at all. §10.6: "a matched
412
+ // deterministic pre-flight is a resolution and produces a receipt."
413
+ const preGuard = checkAuthorization(vd, req, CONSULT_OPTS);
414
+ const matchedPreGuard = preGuard.authorization_matched === true;
415
+ const response = withStaleAuthorityGuard(preGuard, vd);
310
416
  // A matched pre-flight IS a resolved decision (the authorization fired) →
311
417
  // receipt. A no-match is not a decision — no receipt (keeps the store from
312
418
  // filling with probes; the follow-up phaedo_consult writes its own).
313
- if (response.authorization_matched) {
314
- await emitReceipt('phaedo_check_authorization', req, { signal: response.signal, confidence: 1.0, deference_level: 'high' });
419
+ if (matchedPreGuard) {
420
+ const rid = await emitReceipt('phaedo_check_authorization', req, { signal: response.signal, confidence: 1.0, deference_level: 'high' });
421
+ if (rid) response.receipt_id = rid;
315
422
  await maybeDepositReceiptsDigest(); // G1+G2 audit channel — only on a matched preflight (no-match writes no receipt)
316
423
  }
317
424
  return {
@@ -423,6 +530,7 @@ server.registerTool(
423
530
  summary: z.string().optional(),
424
531
  })
425
532
  .optional(),
533
+ action_binding: ACTION_BINDING_SCHEMA,
426
534
  context: z.record(z.any()).optional(),
427
535
  },
428
536
  // Opens a channel to an EXTERNAL entity (the subject's device, via the relay) and
@@ -435,10 +543,15 @@ server.registerTool(
435
543
  // Cache-preferred read: escalation shouldn't trigger a fingerprint-read authorize
436
544
  // prompt on top of the decision card the subject already answers (one prompt, not two).
437
545
  const vd = await loadConsultVd(BASE_DIR, { preferCache: true });
438
- const response = await resolveConsultation(vd, req, CONSULT_OPTS);
546
+ // Guarded like the others, and it needs it MOST: this path reads the cache
547
+ // UNCONDITIONALLY, so without the guard it always reasoned from a possibly-stale profile
548
+ // and then told the agent the signal was not blocking — i.e. "just proceed", and never
549
+ // wake the subject, on authority that may have been revoked hours earlier.
550
+ const response = withStaleAuthorityGuard(
551
+ await resolveConsultation(vd, req, CONSULT_OPTS), vd);
439
552
  let drivers = [];
440
553
  try { drivers = consultDrivers(vd, req); } catch { /* non-critical */ }
441
- await emitReceipt('phaedo_escalate', req, response, drivers); // the decision being escalated
554
+ const escReceiptId = await emitReceipt('phaedo_escalate', req, response, drivers); // the decision being escalated
442
555
  await maybeDepositReceiptsDigest(); // G1+G2 audit channel
443
556
 
444
557
  // Self-gate: only wake the subject for a genuinely blocking signal. `escalate`
@@ -451,6 +564,7 @@ server.registerTool(
451
564
  const body = {
452
565
  request_type: 'escalation', escalated: false, signal: response.signal,
453
566
  rationale_hint: response.rationale_hint, confidence: response.confidence,
567
+ ...(escReceiptId ? { receipt_id: escReceiptId } : {}),
454
568
  ...(subThresholdClarify ? { reason: 'clarify_below_threshold' } : {}),
455
569
  message: subThresholdClarify
456
570
  ? 'Low-stakes clarification — handle it in your own flow (ask your clarifying question, or make a reasonable assumption and state it). It will surface in the user\'s review queue; no need to wake them. Raise an explicit escalate if you truly need a live decision.'
@@ -464,6 +578,11 @@ server.registerTool(
464
578
  const body = dep.deposited
465
579
  ? {
466
580
  request_type: 'escalation', escalated: true, status: 'pending',
581
+ // Both handles. `request_id` correlates the live decision; `receipt_id` names the
582
+ // §10.6 record to mark afterwards. Returning only the first meant
583
+ // phaedo_escalation_status could not mark correctly and a real-time approve/deny was
584
+ // auto-recorded onto whatever receipt happened to be newest.
585
+ ...(escReceiptId ? { receipt_id: escReceiptId } : {}),
467
586
  request_id: dep.request_id, window_ms: dep.window_ms, expires_at: dep.expires_at,
468
587
  signal: response.signal, rationale_hint: response.rationale_hint,
469
588
  // The relay's push-to-wake result for a BACKGROUNDED phone: { sent, reason }.
@@ -555,6 +674,177 @@ server.registerTool(
555
674
  }
556
675
  );
557
676
 
677
+ // ── Tool: phaedo_sign_mandate (§8.3 signing ceremony) ─────────────────────────
678
+ // Ask the subject to SIGN an AP2 Intent Mandate built from their standing authorizations.
679
+ // Two-call shape like escalation: this deposits the request and returns immediately with a
680
+ // request_id + the mandate; the agent then polls phaedo_sign_status. The subject signs on
681
+ // their phone inside the Secure Enclave (the server never holds the key). Until a signature
682
+ // comes back the mandate is a PROPOSAL, never a credential — do not present it as authorized.
683
+ const SIGN_WINDOW_MS = 5 * 60 * 1000;
684
+
685
+ server.registerTool(
686
+ 'phaedo_sign_mandate',
687
+ {
688
+ title: 'Ask the subject to sign a payment authorization (AP2 Intent Mandate)',
689
+ description:
690
+ 'Request the subject\'s cryptographic signature on an AP2 Intent Mandate built from their ' +
691
+ 'standing authorizations — a credential a merchant or payments network can verify. Returns a ' +
692
+ 'request_id and the unsigned mandate immediately; then poll phaedo_sign_status with BOTH, and ' +
693
+ 'DO NOT treat the mandate as authorized until it returns signed. The subject signs on their ' +
694
+ 'phone (Face ID, key in the Secure Enclave); they may also decline. Use only when you need a ' +
695
+ 'verifiable payment credential — not for ordinary consultations.',
696
+ inputSchema: {
697
+ agent_id: z.string().optional(),
698
+ prompt_playback: z.string().optional(), // your stated understanding of the request (AP2)
699
+ },
700
+ // Reaches the subject's device via the relay; read-only w.r.t. local state (no receipt yet).
701
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
702
+ },
703
+ async (args) => {
704
+ try {
705
+ const vd = await loadConsultVd(BASE_DIR, { preferCache: true });
706
+ const fp = (vd && vd.phaedo_fingerprint) || {};
707
+ const mandate = buildIntentMandatePayload(fp, {
708
+ agentId: args?.agent_id ?? null,
709
+ promptPlayback: args?.prompt_playback ?? null,
710
+ });
711
+ if (!mandate) {
712
+ const body = {
713
+ request_type: 'sign_mandate', status: 'nothing_to_sign',
714
+ message: 'The subject has granted no standing authorizations, so there is nothing to sign. ' +
715
+ 'An empty mandate must never be synthesized — it would read as unlimited authority.',
716
+ };
717
+ return { content: [{ type: 'text', text: JSON.stringify(body, null, 2) }], structuredContent: body };
718
+ }
719
+
720
+ // Make sure we hold the subject's signing key BEFORE the sheet goes up.
721
+ //
722
+ // Verification needs the published key. Fetching it lazily at poll time meant a live
723
+ // fingerprint pull fired WHILE the signing sheet was on screen — which puts an
724
+ // authorize card up right as that sheet dismisses, and iOS kills the app when two of
725
+ // these sheets overlap ("already presenting"; the nav layer warns about exactly this).
726
+ // Observed on device: the ceremony completed, then the app crashed and the pull that
727
+ // would have supplied the key died with it, so a genuine signature came back
728
+ // unverifiable. Pulling here is free of that hazard — no sheet exists yet — and it
729
+ // also fails fast, before the subject is asked to sign something we could not check.
730
+ if (!(vd && vd.phaedo_signing_public_key)) {
731
+ try { await loadConsultVd(BASE_DIR); } catch { /* best-effort; poll still retries */ }
732
+ }
733
+
734
+ const pairing = await loadPairingRecord(BASE_DIR);
735
+ const dep = await depositSignRequest(pairing, mandate, { windowMs: SIGN_WINDOW_MS });
736
+ const body = dep.deposited
737
+ ? {
738
+ request_type: 'sign_mandate', status: 'pending',
739
+ request_id: dep.request_id, window_ms: dep.window_ms, expires_at: dep.expires_at, woke: dep.woke,
740
+ mandate,
741
+ message: 'Sent to the subject to sign. Poll phaedo_sign_status with this request_id AND this ' +
742
+ 'mandate; do not treat it as authorized until it returns signed. If it expires or the ' +
743
+ 'subject declines, the mandate stays an unsigned proposal.',
744
+ }
745
+ : {
746
+ request_type: 'sign_mandate', status: 'unreachable', reason: dep.reason,
747
+ message: dep.reason === 'no-relay-device-id' || dep.reason === 'unpaired'
748
+ ? 'No real-time channel to the subject (no paired device with a relay lane). Cannot obtain a signature.'
749
+ : 'Could not reach the subject right now. The mandate remains an unsigned proposal.',
750
+ };
751
+ return { content: [{ type: 'text', text: JSON.stringify(body, null, 2) }], structuredContent: body };
752
+ } catch (err) {
753
+ const code = err instanceof PhaedoError ? err.code : 'internal_error';
754
+ return { content: [{ type: 'text', text: `Phaedo sign-mandate error [${code}]: ${err.message}` }], isError: true };
755
+ }
756
+ }
757
+ );
758
+
759
+ // ── Tool: phaedo_sign_status ──────────────────────────────────────────────────
760
+ // Poll a signing request opened with phaedo_sign_mandate. The `mandate` returned by that
761
+ // call is passed back here to verify against — a tampered mandate simply fails verification
762
+ // (the phone signed only what it displayed), so round-tripping it is safe and restart-proof.
763
+ server.registerTool(
764
+ 'phaedo_sign_status',
765
+ {
766
+ title: 'Check whether the subject has signed the mandate',
767
+ description:
768
+ 'Poll a request from phaedo_sign_mandate. Pass the request_id AND the mandate that call returned. ' +
769
+ 'Returns signed + the mandate carrying the verified signature once the subject signs, refused if ' +
770
+ 'they declined, pending while you should keep waiting, or expired once the window lapsed. A signed ' +
771
+ 'result is verified against the subject\'s published signing key before it is returned.',
772
+ inputSchema: {
773
+ request_id: z.string(),
774
+ mandate: z.record(z.any()),
775
+ expires_at: z.number().optional(),
776
+ },
777
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
778
+ },
779
+ async (args) => {
780
+ try {
781
+ const vd = await loadConsultVd(BASE_DIR, { preferCache: true });
782
+ let signingPublicKey = vd && vd.phaedo_signing_public_key;
783
+ // NOTE: no live pull here, deliberately. A live pull raises an authorize card on the
784
+ // phone, and this runs while the signing sheet may still be dismissing — overlapping
785
+ // sheets crash iOS ("already presenting"), which is exactly how a good signature was
786
+ // lost on device. The key is fetched at DEPOSIT time instead (see phaedo_sign_mandate),
787
+ // where no sheet exists. If it is still missing here, report that honestly rather than
788
+ // interrupting the subject mid-ceremony to fix it.
789
+ const pairing = await loadPairingRecord(BASE_DIR);
790
+ const r = await pollSignOnce(pairing, args.request_id, args.mandate, {
791
+ expiresAt: args.expires_at, signingPublicKey,
792
+ });
793
+
794
+ // A stale key (the subject rotated their signing identity) would also present as a
795
+ // plain mismatch. That is NOT retried with a live pull here, on purpose: the pull
796
+ // raises an authorize card while the signing sheet may still be dismissing, and
797
+ // overlapping sheets crash iOS. Fetching at DEPOSIT time makes staleness effectively
798
+ // impossible anyway — the key is read moments before the sheet appears, and rotation
799
+ // requires a deliberate device action. A genuine mismatch is reported as such.
800
+
801
+ if (r.resolved && r.signed) {
802
+ // Audit the minted credential — its own log, NOT a §10.6 consult receipt (a
803
+ // signature has no signal and must not feed act-as-me learning). Records a digest
804
+ // of the signed bytes + coarse metadata, never the mandate content or the signature.
805
+ // Best-effort: a failed audit write must never make a real signature look unsigned.
806
+ let recorded = false;
807
+ try {
808
+ await appendSigningAudit(defaultStateDir(), buildSigningAuditRecord({
809
+ requestId: args.request_id, mandate: r.mandate, clientId: CONSULT_OPTS.clientId, agentId: r.mandate?.agent_id,
810
+ }));
811
+ recorded = true;
812
+ } catch (e) {
813
+ process.stderr.write(`[phaedo-mcp] signing-audit write failed (mandate still signed): ${e.message}\n`);
814
+ }
815
+ const body = {
816
+ request_type: 'sign_status', status: 'signed', mandate: r.mandate, recorded,
817
+ message: 'The subject signed. The mandate now carries a verified signature and may be presented ' +
818
+ 'as a credential.',
819
+ };
820
+ return { content: [{ type: 'text', text: JSON.stringify(body, null, 2) }], structuredContent: body };
821
+ }
822
+ if (r.resolved && !r.signed) {
823
+ const refused = r.reason === 'refused';
824
+ const body = {
825
+ request_type: 'sign_status', status: refused ? 'refused' : 'not_signed', reason: r.reason,
826
+ ...(r.detail ? { detail: r.detail } : {}),
827
+ message: refused
828
+ ? 'The subject declined to sign. Do not proceed with an action that needs this credential.'
829
+ : 'A response arrived but did not verify (' + (r.detail || 'unknown') + '). Treat as unsigned; do not proceed.',
830
+ };
831
+ return { content: [{ type: 'text', text: JSON.stringify(body, null, 2) }], structuredContent: body };
832
+ }
833
+ const expired = r.status === 'expired';
834
+ const body = {
835
+ request_type: 'sign_status', status: r.status,
836
+ message: expired
837
+ ? 'The signing window lapsed with no signature. The mandate remains an unsigned proposal.'
838
+ : 'Not signed yet. Keep waiting; poll again shortly.',
839
+ };
840
+ return { content: [{ type: 'text', text: JSON.stringify(body, null, 2) }], structuredContent: body };
841
+ } catch (err) {
842
+ const code = err instanceof PhaedoError ? err.code : 'request_malformed';
843
+ return { content: [{ type: 'text', text: `Phaedo sign-status error [${code}]: ${err.message}` }], isError: true };
844
+ }
845
+ }
846
+ );
847
+
558
848
  // ── Resource: phaedo://fingerprint/projection (binding §3.1) ──────────────────
559
849
  // Default projection (all layers, standard mode) for resource-only clients.
560
850
  server.registerResource(
package/server.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
3
  "name": "io.github.phaedo-labs/phaedo-mcp",
4
- "description": "Carry your Phaedo cognitive fingerprint into any MCP client: style injection, decision consults + real-time escalation.",
5
- "version": "0.2.1",
4
+ "description": "Carry your Phaedo cognitive fingerprint into any MCP client: injection, consults, escalation, mandate signing.",
5
+ "version": "0.3.0",
6
6
  "repository": {
7
- "url": "https://github.com/phaedo-labs/phaedo",
7
+ "url": "https://github.com/phaedo-labs/phaedo-mcp",
8
8
  "source": "github",
9
9
  "subfolder": "mcp"
10
10
  },
@@ -13,7 +13,7 @@
13
13
  "registryType": "npm",
14
14
  "registryBaseUrl": "https://registry.npmjs.org",
15
15
  "identifier": "phaedo-mcp",
16
- "version": "0.2.1",
16
+ "version": "0.3.0",
17
17
  "transport": {
18
18
  "type": "stdio"
19
19
  }
@@ -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
+ }