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.
- package/NOTICE +17 -0
- package/README.md +51 -9
- package/action-binding.js +82 -0
- package/agreement.js +38 -0
- 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 +81 -19
- package/consult-policy.js +427 -33
- package/consult.js +25 -3
- package/delegation-sync.js +89 -0
- package/escalation-policy.sample.json +13 -0
- package/escalation.js +383 -0
- package/fingerprint-source.js +78 -4
- package/health.js +54 -0
- package/hooks/session-end.mjs +240 -0
- package/install.mjs +164 -21
- 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 +26 -6
- package/pairing.js +5 -1
- package/phone-source.js +92 -12
- package/projection.js +4 -1
- package/pull.js +19 -3
- package/receipts-sync.js +197 -0
- package/receipts.js +69 -16
- package/server.js +305 -15
- package/server.json +4 -4
- 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 +153 -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 +40 -0
- package/vendor/extraction/reconcile.js +269 -0
- package/vendor/extraction/resolve.js +178 -0
- package/vendor/extraction/rule-promotion.js +137 -0
- package/vendor/kdf.js +20 -1
- package/vendor/protocol.js +7 -0
- 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
|
-
|
|
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
|
|
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 =>
|
|
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}
|
|
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]
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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:
|
|
5
|
-
"version": "0.
|
|
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.
|
|
16
|
+
"version": "0.3.0",
|
|
17
17
|
"transport": {
|
|
18
18
|
"type": "stdio"
|
|
19
19
|
}
|
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
|
+
}
|