phaedo-mcp 0.2.2 → 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 +50 -6
- package/consult-policy.js +427 -33
- package/consult.js +8 -1
- 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 +25 -5
- package/phone-source.js +79 -11
- package/projection.js +4 -1
- package/pull.js +19 -3
- package/receipts-sync.js +197 -0
- package/receipts.js +33 -2
- package/server.js +305 -15
- package/server.json +3 -3
- 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/receipts.js
CHANGED
|
@@ -25,7 +25,9 @@ import { mkdir, readFile, writeFile, chmod, rename } from 'fs/promises';
|
|
|
25
25
|
import { existsSync } from 'fs';
|
|
26
26
|
import { join } from 'path';
|
|
27
27
|
import { Phaedo, b64uEnc, b64uDec } from './lib/phaedo-crypto.js';
|
|
28
|
+
import { canonicalDomain } from './lib/domain.js';
|
|
28
29
|
import { getOrCreateCacheKey, defaultStateDir } from './cache.js';
|
|
30
|
+
import { normalizeActionBinding } from './action-binding.js';
|
|
29
31
|
|
|
30
32
|
const { Envelope } = Phaedo;
|
|
31
33
|
|
|
@@ -105,11 +107,17 @@ function withStoreLock(stateDir, fn) {
|
|
|
105
107
|
// never the projection (§10.4 holds inside the vault too). `via` records which
|
|
106
108
|
// tool resolved it (phaedo_consult | phaedo_check_authorization) so M5 can weigh
|
|
107
109
|
// full consultations and pre-flight matches separately.
|
|
108
|
-
export function buildReceipt({ via, agentId, request, response, drivers, now = Date.now() }) {
|
|
110
|
+
export function buildReceipt({ via, agentId, clientId, request, response, drivers, now = Date.now() }) {
|
|
109
111
|
const ad = (request && typeof request.action_descriptor === 'object' && request.action_descriptor) || {};
|
|
110
112
|
// Provenance (optional, §10.4-safe — LOCAL audit only): the Decision&Risk cues that
|
|
111
113
|
// drove an inferred consult, each {cue, lean?/value?, confidence, basis}. No layer
|
|
112
114
|
// text; coerced to the known shape; absent for deterministic pre-flights.
|
|
115
|
+
// Defensive: a malformed binding must not fail the receipt write (a receipt-write
|
|
116
|
+
// failure must never break the consultation — §10.6). The resolver has already
|
|
117
|
+
// rejected structurally-bad bindings with `request_malformed`, so anything that
|
|
118
|
+
// throws here is a caller bypassing it; record nothing rather than half of it.
|
|
119
|
+
let bnd = null;
|
|
120
|
+
try { bnd = normalizeActionBinding(request?.action_binding); } catch { bnd = null; }
|
|
113
121
|
const drv = Array.isArray(drivers) ? drivers.slice(0, 16).map((d) => ({
|
|
114
122
|
cue: typeof d?.cue === 'string' ? d.cue : null,
|
|
115
123
|
...(typeof d?.lean === 'number' ? { lean: d.lean } : {}),
|
|
@@ -121,16 +129,33 @@ export function buildReceipt({ via, agentId, request, response, drivers, now = D
|
|
|
121
129
|
receipt_id: crypto.randomUUID(),
|
|
122
130
|
created_at: new Date(now).toISOString(),
|
|
123
131
|
via: via || 'phaedo_consult',
|
|
132
|
+
// WHO CLAIMED to be calling. Self-asserted, unverified, and never a gate — the
|
|
133
|
+
// same standing `action_descriptor` was given in v0.1.48. Audit label only.
|
|
124
134
|
agent_id: typeof agentId === 'string' ? agentId : null,
|
|
135
|
+
// WHO ACTUALLY SERVED the call: this server's own paired-client id, established
|
|
136
|
+
// out-of-band with the phone under a user-verified SAS. This is the identity
|
|
137
|
+
// §10.5 `agents` scoping matches on, so the audit can distinguish which paired
|
|
138
|
+
// client acted from whichever vendor string it chose to claim. Null = unpaired.
|
|
139
|
+
client_id: typeof clientId === 'string' && clientId ? clientId : null,
|
|
125
140
|
request: {
|
|
126
141
|
consultation_type: request?.consultation_type || 'action_approval',
|
|
127
142
|
action_descriptor: {
|
|
128
|
-
|
|
143
|
+
// Canonical form, matching what the resolver reasoned about (lib/domain.js).
|
|
144
|
+
// The receipt is the evidence store the promotion tallies key on, so a raw
|
|
145
|
+
// `"Finance"` here would be a class of its own and split the subject's
|
|
146
|
+
// approvals away from `"finance"`.
|
|
147
|
+
domain: typeof ad.domain === 'string' ? (canonicalDomain(ad.domain) || null) : null,
|
|
129
148
|
reversible: typeof ad.reversible === 'boolean' ? ad.reversible : null,
|
|
130
149
|
magnitude: typeof ad.magnitude === 'string' ? ad.magnitude : null,
|
|
131
150
|
amount: typeof ad.amount === 'number' ? ad.amount : null,
|
|
132
151
|
summary: typeof ad.summary === 'string' ? ad.summary.slice(0, 500) : null,
|
|
133
152
|
},
|
|
153
|
+
// §10.2 binding: the concrete call the descriptor above CLAIMS to describe.
|
|
154
|
+
// This is the field that makes a descriptor/action mismatch provable later —
|
|
155
|
+
// the descriptor is the agent's story, this is what it said it would run.
|
|
156
|
+
// Never the arguments themselves: a digest, the tool name, and at most a
|
|
157
|
+
// short human preview.
|
|
158
|
+
...(bnd ? { action_binding: bnd } : {}),
|
|
134
159
|
},
|
|
135
160
|
response: {
|
|
136
161
|
signal: response?.signal ?? null,
|
|
@@ -164,6 +189,12 @@ export async function updateUserAction(stateDir, receiptId, action, { now = Date
|
|
|
164
189
|
const r = store.receipts.find((x) => x.receipt_id === receiptId);
|
|
165
190
|
if (!r) return false;
|
|
166
191
|
r.user_action = action;
|
|
192
|
+
// WHEN the outcome was recorded, alongside WHAT it was. Not decision latency —
|
|
193
|
+
// the subject may mark in a later sitting — but it does reveal a screenful of
|
|
194
|
+
// receipts stamped in one pass, which §10.7 autonomy promotion treats as
|
|
195
|
+
// bookkeeping rather than deliberate consent. Mutable with `user_action`, and
|
|
196
|
+
// only with it (§10.6: user_action is the sole mutable field; this is part of it).
|
|
197
|
+
r.user_action_at = new Date(now).toISOString();
|
|
167
198
|
await writeStore(stateDir, store, now);
|
|
168
199
|
return true;
|
|
169
200
|
});
|
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,8 +1,8 @@
|
|
|
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: injection, consults, escalation.",
|
|
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
7
|
"url": "https://github.com/phaedo-labs/phaedo-mcp",
|
|
8
8
|
"source": "github",
|
|
@@ -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
|
}
|