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.
Files changed (61) 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 +50 -6
  12. package/consult-policy.js +427 -33
  13. package/consult.js +8 -1
  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 +25 -5
  26. package/phone-source.js +79 -11
  27. package/projection.js +4 -1
  28. package/pull.js +19 -3
  29. package/receipts-sync.js +197 -0
  30. package/receipts.js +33 -2
  31. package/server.js +305 -15
  32. package/server.json +3 -3
  33. package/signing-audit.js +111 -0
  34. package/signing-request.js +217 -0
  35. package/signing.js +176 -0
  36. package/vendor/authored-source.js +250 -0
  37. package/vendor/capabilities.js +203 -0
  38. package/vendor/capture/capture-core.js +67 -0
  39. package/vendor/capture/detectors.js +287 -0
  40. package/vendor/capture/exchanges.js +77 -0
  41. package/vendor/capture/model-detect.js +195 -0
  42. package/vendor/capture/owner.js +35 -0
  43. package/vendor/capture/package.json +4 -0
  44. package/vendor/capture/source-id.js +56 -0
  45. package/vendor/capture/state.js +148 -0
  46. package/vendor/capture/transcript.js +153 -0
  47. package/vendor/commitments.js +169 -0
  48. package/vendor/context-block.js +251 -18
  49. package/vendor/envelope.js +50 -4
  50. package/vendor/extraction/capture-combine.js +160 -0
  51. package/vendor/extraction/catalog.js +232 -0
  52. package/vendor/extraction/delta-lift.js +109 -0
  53. package/vendor/extraction/fingerprint-update.js +227 -0
  54. package/vendor/extraction/package.json +4 -0
  55. package/vendor/extraction/persist.js +40 -0
  56. package/vendor/extraction/reconcile.js +269 -0
  57. package/vendor/extraction/resolve.js +178 -0
  58. package/vendor/extraction/rule-promotion.js +137 -0
  59. package/vendor/kdf.js +20 -1
  60. package/vendor/protocol.js +7 -0
  61. package/vendored-files.mjs +82 -0
@@ -0,0 +1,89 @@
1
+ // Delegation-promotion transport (MCP → relay → extension).
2
+ //
3
+ // Deposits act-as-me delegation SUGGESTIONS (built by consult-core.js
4
+ // buildDelegationPromotions from the consult override history) into the relay's
5
+ // `agent_to_ext` mailbox slot, encrypted under fp_sync_key.
6
+ //
7
+ // ⚠️ KEY DEPENDENCY (not merely historical — see
8
+ // docs/roadmap/paired-client-identity-2026-07-22.md). fp_sync_key is derived from
9
+ // pair_key, and the EXTENSION drains this lane with its OWN single fp_sync_key — no
10
+ // multi-client key loop, unlike the phone's drains. So this deposit is readable only
11
+ // when the MCP and the extension share a pair_key (the original "Option B": the
12
+ // extension's pairing exported to mcp/pairing.json). An MCP paired independently via
13
+ // `pair.js` derives a DIFFERENT key, and the extension logs "likely key mismatch" and
14
+ // drops the suggestion silently. Independent pairing is what makes the MCP a distinct
15
+ // principal for §10.5 `agents` scoping, so these two properties are in tension until
16
+ // the extension's drain gains the phone's try-each-verified-client loop.
17
+ //
18
+ // The extension union-merges
19
+ // the suggestions (sync-merge.js mergeAgentSuggestions) into suggested_rules, where the
20
+ // existing popup review card surfaces them for endorsement.
21
+ //
22
+ // Trust posture: the MCP only ever proposes SUGGESTIONS — never an authored rule. The
23
+ // subject still endorses (the authored-only invariant holds). Best-effort and isolated:
24
+ // a deposit failure never affects the consultation that triggered it. The relay only
25
+ // ever holds ciphertext (the agent_to_ext slot is separate from the phone's to_ext, so
26
+ // a deposit can neither clobber nor be clobbered by the phone's fingerprint sync).
27
+
28
+ import { Phaedo, b64uDec } from './lib/phaedo-crypto.js';
29
+
30
+ const { Kdf, Envelope } = Phaedo;
31
+ const DEFAULT_RELAY = 'https://phaedo-relay.fly.dev';
32
+
33
+ export function relayBaseFromEnv(env = process.env) {
34
+ return env.PHAEDO_RELAY || DEFAULT_RELAY;
35
+ }
36
+
37
+ // Wrap delegation suggestions into the same envelope shape the extension's fp-mailbox
38
+ // drain expects: ciphertext over `{ data: { suggested_rules: [...] } }`. Returns the
39
+ // deposit body `{ envelope }`. Pure crypto (no network) so the round-trip is testable.
40
+ export async function buildDelegationDeposit(pairKeyB64u, suggestions, { now = () => new Date().toISOString() } = {}) {
41
+ const pairKey = b64uDec(pairKeyB64u);
42
+ let fpSyncKey;
43
+ try {
44
+ fpSyncKey = await Kdf.deriveFpSyncKey(pairKey);
45
+ } finally {
46
+ pairKey.fill(0);
47
+ }
48
+ try {
49
+ const inner = { kind: 'delegation_suggestions', data: { suggested_rules: suggestions } };
50
+ const plaintext = new TextEncoder().encode(JSON.stringify(inner));
51
+ const envelope = await Envelope.wrap(plaintext, fpSyncKey, { artifact_kind: 'delegation_suggestions', created_at: now() });
52
+ plaintext.fill(0);
53
+ return { envelope };
54
+ } finally {
55
+ if (fpSyncKey && fpSyncKey.fill) fpSyncKey.fill(0);
56
+ }
57
+ }
58
+
59
+ // Deposit the suggestions to the relay's agent_to_ext slot. Best-effort: returns
60
+ // { deposited, ... } and never throws (a transport failure must not break the consult).
61
+ // `deps.fetch` is injectable for tests.
62
+ export async function depositDelegationSuggestions(pairing, suggestions, deps = {}) {
63
+ const fetchFn = deps.fetch || globalThis.fetch;
64
+ const list = Array.isArray(suggestions) ? suggestions : [];
65
+ // Precise reasons so the caller can surface WHY a deposit didn't happen (a missing
66
+ // relay_device_id means a local-only pairing — the deposit silently no-ops otherwise).
67
+ if (!list.length) return { deposited: false, reason: 'no-suggestions' };
68
+ if (!pairing || !pairing.pair_key) return { deposited: false, reason: 'unpaired' };
69
+ if (!pairing.relay_device_id) return { deposited: false, reason: 'no-relay-device-id' };
70
+ try {
71
+ const body = await buildDelegationDeposit(pairing.pair_key, list, deps);
72
+ const base = `${deps.relayBase || relayBaseFromEnv()}/fp-mailbox/${encodeURIComponent(pairing.relay_device_id)}/agent_to_ext`;
73
+ // Per-device auth header (relay/auth.js). Without it the relay 401s. A pairing.json
74
+ // from an older mobile build that predates the auth layer won't have relay_device_secret
75
+ // → no header → 401 → the deposit no-ops, which is the correct failure mode (user re-pairs).
76
+ const authHeader = pairing.relay_device_secret
77
+ ? { Authorization: `Bearer ${pairing.relay_device_id}:${pairing.relay_device_secret}` }
78
+ : {};
79
+ const res = await fetchFn(base, {
80
+ method: 'POST',
81
+ headers: { 'Content-Type': 'application/json', ...authHeader },
82
+ body: JSON.stringify(body),
83
+ signal: AbortSignal.timeout(deps.timeoutMs || 8000),
84
+ });
85
+ return { deposited: !!(res && res.ok), status: res && res.status, count: list.length };
86
+ } catch (e) {
87
+ return { deposited: false, reason: String(e && e.message || e) };
88
+ }
89
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "version": "0.1",
3
+ "_comment": "Subject-configurable PUSH policy for phaedo_escalate (docs/roadmap/escalation-and-push.md). Controls which consult signals WAKE your phone for a live decision. `escalate` ALWAYS pushes (it means a human is needed) and is not configurable here. Only `clarify` is tunable: mode is \"always\" | \"consequential\" (the default: irreversible OR high-magnitude actions only) | \"never\". A per-domain override (by_domain, substring match, case-insensitive) beats the default. Portable: copy into the vault as phaedo_fingerprint.escalation_policy to sync across devices, or point PHAEDO_ESCALATION_POLICY at this file to try it. The file overrides the vault policy.",
4
+ "clarify": {
5
+ "default": "consequential",
6
+ "by_domain": {
7
+ "financial": "always",
8
+ "legal": "always",
9
+ "writing": "never",
10
+ "internal note": "never"
11
+ }
12
+ }
13
+ }
package/escalation.js ADDED
@@ -0,0 +1,383 @@
1
+ // Real-time escalation client (MCP → relay → subject's device → back).
2
+ //
3
+ // The delivery channel the act-as-me endgame needs once agents act AUTONOMOUSLY for
4
+ // the subject (docs/roadmap/escalation-and-push.md). When a consult returns a
5
+ // process-BLOCKING signal — `escalate` (or `clarify`) — and the agent is running
6
+ // unattended, there is no one at the agent's conversation to answer. This routes the
7
+ // decision to the subject's phone in real time, waits within a bounded window, and
8
+ // returns approve / deny / modify so the agent can resume — or applies the SAFE
9
+ // DEFAULT when the window lapses (an `escalate` means "hold for a human", so the
10
+ // no-response default is do-not-proceed, never a guess).
11
+ //
12
+ // §10.4 boundary on the wire: the escalation payload carries the ACTION under
13
+ // question (the agent's own descriptor) + the consult's `signal`/`rationale_hint` —
14
+ // NEVER fingerprint layer content. And even that is sealed under fp_sync_key (the
15
+ // same HKDF(pair_key) key the delegation deposit uses), so the relay stays
16
+ // zero-knowledge: it forwards ciphertext and wakes the device, nothing more.
17
+ //
18
+ // Honesty rail (roadmap Q7): a real-time approve/deny IS an outcome — the strongest
19
+ // possible act-as-me signal. The orchestrator returns a `record_outcome` mapping
20
+ // (approve→approved, deny→rejected, modify→modified) so the SAME §10.6 receipt +
21
+ // override-learning loop picks it up automatically. A TIMEOUT is "no answer", not an
22
+ // override — it maps to `unknown` and teaches nothing.
23
+
24
+ import { Phaedo, b64uDec } from './lib/phaedo-crypto.js';
25
+ import { normalizeActionBinding } from './action-binding.js';
26
+
27
+ const { Kdf, Envelope } = Phaedo;
28
+ const DEFAULT_RELAY = 'https://phaedo-relay.fly.dev';
29
+
30
+ export function relayBaseFromEnv(env = process.env) {
31
+ return env.PHAEDO_RELAY || DEFAULT_RELAY;
32
+ }
33
+
34
+ // The Authorization header for relay HTTP routes (relay/auth.js per-device bearer).
35
+ // Without this, deposit/poll return 401. A pairing without a relay_device_secret (e.g.
36
+ // from an older mobile build that predates the auth layer) gets no header — relay 401s,
37
+ // which surfaces as deposit-failed-401 from the orchestrator.
38
+ function relayAuthHeader(pairing) {
39
+ if (!pairing || !pairing.relay_device_id || !pairing.relay_device_secret) return {};
40
+ return { Authorization: `Bearer ${pairing.relay_device_id}:${pairing.relay_device_secret}` };
41
+ }
42
+
43
+ // Which consult signals are BLOCKING enough to WAKE the subject (roadmap Q1). The two
44
+ // blocking signals are not equal:
45
+ // - `escalate` = "hold for a live human go/no-go on a consequential call." Blocking by
46
+ // definition → ALWAYS push.
47
+ // - `clarify` = "I need more info before acting." This is the agent's default reflex
48
+ // when uncertain, so it fires often, including on low-stakes calls. Waking the phone
49
+ // for every clarify would cry wolf — so push a clarify ONLY when the action is also
50
+ // CONSEQUENTIAL (irreversible OR high magnitude). Below that bar an autonomous agent
51
+ // should resolve the clarify itself (ask in-flow / state a reasonable assumption) and
52
+ // it surfaces in the deferred review queue — no interruption.
53
+ // Unknown stakes (no descriptor) reads as low-stakes for clarify: the agent can always
54
+ // raise an explicit `escalate` when it really needs the subject. Ultimately this whole
55
+ // policy is subject-configurable via a standing rule; this is the sensible default.
56
+ export function isConsequential(actionDescriptor = {}) {
57
+ const ad = actionDescriptor || {};
58
+ return ad.reversible === false || ad.magnitude === 'high';
59
+ }
60
+
61
+ // ── Subject-configurable push policy (the standing knob) ───────────────────────
62
+ // The stakes-gated default above is just that — a default. A subject can override how
63
+ // `clarify` is pushed, globally and per-domain, via an escalation policy that rides the
64
+ // vault (portable: `phaedo_fingerprint.escalation_policy`, syncs across devices) and/or a
65
+ // local file (`PHAEDO_ESCALATION_POLICY`, for power users / testing) — the same dual home
66
+ // as consult policies (`consult-policy.js`). Shape:
67
+ // { "clarify": { "default": "always" | "consequential" | "never",
68
+ // "by_domain": { "financial": "always", "writing": "never" } } }
69
+ // `escalate` is deliberately NOT configurable: it means "a human is needed", so suppressing
70
+ // the wake would strand a blocking decision — it always pushes. Only `clarify` is tunable.
71
+ const CLARIFY_MODES = new Set(['always', 'consequential', 'never']);
72
+
73
+ // Merge policy sources (later wins; `by_domain` deep-merged, `default` shallow-overridden).
74
+ // Defensive: ignores malformed entries and unknown modes. Never throws.
75
+ export function mergeEscalationPolicy(...sources) {
76
+ const clarify = { default: undefined, by_domain: {} };
77
+ for (const s of sources) {
78
+ const c = s && typeof s === 'object' ? s.clarify : null;
79
+ if (!c || typeof c !== 'object') continue;
80
+ if (typeof c.default === 'string' && CLARIFY_MODES.has(c.default.toLowerCase())) clarify.default = c.default.toLowerCase();
81
+ if (c.by_domain && typeof c.by_domain === 'object') {
82
+ for (const [k, v] of Object.entries(c.by_domain)) {
83
+ if (typeof v === 'string' && CLARIFY_MODES.has(v.toLowerCase())) clarify.by_domain[String(k).toLowerCase()] = v.toLowerCase();
84
+ }
85
+ }
86
+ }
87
+ return { clarify };
88
+ }
89
+
90
+ // Resolve the effective policy for a consult: the vault-embedded policy (portable) merged
91
+ // under the local-file override (`opts.escalationPolicy`). Pure; defensive.
92
+ export function loadEscalationPolicy(vd, opts = {}) {
93
+ return mergeEscalationPolicy(vd?.phaedo_fingerprint?.escalation_policy, opts.escalationPolicy);
94
+ }
95
+
96
+ // Does a `by_domain` KEY govern this action domain? EXACT match, or a dot-hierarchy
97
+ // PARENT match (`finance` governs `finance.crypto_trading`).
98
+ //
99
+ // This deliberately replaces the old bidirectional substring test
100
+ // (`domain.includes(k) || k.includes(domain)`), which was both wrong and unsafe:
101
+ // - `legal` spuriously governed `paralegal`, and `care` governed `healthcare` —
102
+ // accidental substrings, not subdomains. So a subject who set `legal: never` to
103
+ // silence legal noise ALSO silenced every paralegal action, and a suppressed
104
+ // clarify never reaches the phone: the failure is invisible by construction.
105
+ // - the reverse direction (`k.includes(domain)`) let a narrow key govern a broad
106
+ // action — a `finance.crypto_trading` rule firing on a bare `finance` action.
107
+ // A real subdomain rides a `.` boundary; a coincidental letter overlap does not.
108
+ // Parent→child only (a `finance` rule covers its subdomains; a crypto rule does NOT
109
+ // reach up to govern general finance). Anything unmatched falls to `default`, which
110
+ // the subject controls — never to a wrong, more-specific rule.
111
+ function domainRuleApplies(key, domain) {
112
+ return domain === key || domain.startsWith(key + '.');
113
+ }
114
+
115
+ // The clarify push mode for an action under a policy: the MOST SPECIFIC per-domain
116
+ // override wins over the default, which falls back to the built-in 'consequential'.
117
+ //
118
+ // "Most specific" = the longest applicable key, so `finance.crypto_trading` beats a
119
+ // bare `finance` for a crypto action. This is also what makes the result
120
+ // DETERMINISTIC: the old first-match-wins loop returned different modes for the same
121
+ // action depending on JSON key order, so a subject's specific rule could lose to a
122
+ // general one purely by where it sat in the object.
123
+ export function resolveClarifyMode(actionDescriptor = {}, policy = null) {
124
+ const c = policy && policy.clarify;
125
+ if (!c) return 'consequential';
126
+ const domain = String(actionDescriptor?.domain || '').toLowerCase();
127
+ if (domain && c.by_domain) {
128
+ let best = null;
129
+ for (const [k, mode] of Object.entries(c.by_domain)) {
130
+ if (!CLARIFY_MODES.has(mode)) continue;
131
+ if (domainRuleApplies(k, domain) && (best === null || k.length > best.k.length)) best = { k, mode };
132
+ }
133
+ if (best) return best.mode;
134
+ }
135
+ return CLARIFY_MODES.has(c.default) ? c.default : 'consequential';
136
+ }
137
+
138
+ // Does this signal WAKE the subject? `escalate` always; `clarify` per the policy
139
+ // (`always` | `never` | `consequential` = irreversible/high-magnitude). Backward
140
+ // compatible: with no policy, clarify falls back to the stakes-gated default.
141
+ export function shouldEscalate(signal, actionDescriptor = {}, policy = null) {
142
+ const s = String(signal);
143
+ if (s === 'escalate') return true;
144
+ if (s === 'clarify') {
145
+ const mode = resolveClarifyMode(actionDescriptor, policy);
146
+ return mode === 'always' ? true : mode === 'never' ? false : isConsequential(actionDescriptor);
147
+ }
148
+ return false;
149
+ }
150
+
151
+ // The decision vocabulary the phone returns, mapped to the §10.6 receipt user_action.
152
+ const DECISION_OUTCOME = { approve: 'approved', deny: 'rejected', modify: 'modified' };
153
+
154
+ // Build the §10.4-safe escalation payload from the consult request + its response.
155
+ // PURE. Pulls ONLY the agent's own action descriptor + the constrained consult
156
+ // signal/rationale — no vault data is in scope here at all. Truncates free text so a
157
+ // runaway descriptor can't bloat the (encrypted) wire payload.
158
+ export function buildEscalationPayload(consultRequest = {}, consultResponse = {}) {
159
+ const ad = (consultRequest.action_descriptor && typeof consultRequest.action_descriptor === 'object') ? consultRequest.action_descriptor : {};
160
+ const clip = (s, n) => (typeof s === 'string' ? s.replace(/\s+/g, ' ').trim().slice(0, n) : undefined);
161
+ // The §10.2 binding, when the agent sent one. `summary` above is the agent's
162
+ // PROSE — its account of what it is about to do. This is the call itself. The
163
+ // subject approving an escalation should see both, because the whole class of
164
+ // approval-gate bypasses works by making the two differ.
165
+ let bnd = null;
166
+ try { bnd = normalizeActionBinding(consultRequest.action_binding); } catch { bnd = null; }
167
+ return {
168
+ consultation_type: typeof consultRequest.consultation_type === 'string' ? consultRequest.consultation_type : undefined,
169
+ agent_id: clip(consultRequest.agent_id, 120),
170
+ action: {
171
+ domain: clip(ad.domain, 80),
172
+ summary: clip(ad.summary, 280),
173
+ magnitude: ['low', 'medium', 'high'].includes(ad.magnitude) ? ad.magnitude : undefined,
174
+ reversible: typeof ad.reversible === 'boolean' ? ad.reversible : undefined,
175
+ amount: typeof ad.amount === 'number' && isFinite(ad.amount) ? ad.amount : undefined,
176
+ ...(bnd ? {
177
+ tool_name: bnd.tool_name,
178
+ arguments_hash: bnd.arguments_hash,
179
+ ...(bnd.arguments_preview ? { arguments_preview: bnd.arguments_preview } : {}),
180
+ } : {}),
181
+ },
182
+ signal: clip(consultResponse.signal, 40),
183
+ rationale_hint: clip(consultResponse.rationale_hint, 280),
184
+ };
185
+ }
186
+
187
+ // Seal the payload under fp_sync_key into the deposit body { request_id, envelope,
188
+ // window_ms }. PURE crypto (no network) so the round-trip is testable in isolation,
189
+ // mirroring delegation-sync.js buildDelegationDeposit.
190
+ export async function buildEscalationDeposit(pairKeyB64u, requestId, payload, { windowMs, now = () => new Date().toISOString() } = {}) {
191
+ const pairKey = b64uDec(pairKeyB64u);
192
+ let fpSyncKey;
193
+ try {
194
+ fpSyncKey = await Kdf.deriveFpSyncKey(pairKey);
195
+ } finally {
196
+ pairKey.fill(0);
197
+ }
198
+ try {
199
+ const inner = { kind: 'escalation_request', request_id: requestId, data: payload };
200
+ const plaintext = new TextEncoder().encode(JSON.stringify(inner));
201
+ const envelope = await Envelope.wrap(plaintext, fpSyncKey, { artifact_kind: 'escalation_request', created_at: now() });
202
+ plaintext.fill(0);
203
+ return { request_id: requestId, envelope, ...(windowMs ? { window_ms: windowMs } : {}) };
204
+ } finally {
205
+ if (fpSyncKey && fpSyncKey.fill) fpSyncKey.fill(0);
206
+ }
207
+ }
208
+
209
+ // Decrypt the subject's decision (the phone's response body { envelope }) under
210
+ // fp_sync_key. Returns the inner { decision, note?, modified? } or null on any
211
+ // failure (a malformed/forged response must never crash the agent's resume path).
212
+ export async function unwrapEscalationResponse(pairKeyB64u, body) {
213
+ if (!body || !body.envelope) return null;
214
+ const pairKey = b64uDec(pairKeyB64u);
215
+ let fpSyncKey;
216
+ try {
217
+ fpSyncKey = await Kdf.deriveFpSyncKey(pairKey);
218
+ } catch { pairKey.fill(0); return null; }
219
+ pairKey.fill(0);
220
+ try {
221
+ const plaintext = await Envelope.unwrap(body.envelope, fpSyncKey);
222
+ const inner = JSON.parse(new TextDecoder().decode(plaintext));
223
+ if (inner && inner.kind === 'escalation_response' && inner.data) return inner.data;
224
+ return null;
225
+ } catch {
226
+ return null;
227
+ } finally {
228
+ if (fpSyncKey && fpSyncKey.fill) fpSyncKey.fill(0);
229
+ }
230
+ }
231
+
232
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
233
+ function genRequestId() {
234
+ if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID();
235
+ return `esc-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
236
+ }
237
+
238
+ // ── Non-blocking deposit + single-poll (for the MCP tools) ─────────────────────
239
+ // A multi-minute BLOCKING tool call is hostile to MCP clients (many time out), so the
240
+ // agent-facing tools use a two-call shape: phaedo_escalate deposits + wakes the device
241
+ // and returns immediately; phaedo_escalation_status polls once. The agent decides when
242
+ // to stop waiting; the safe default once it gives up is HOLD (do-not-proceed).
243
+
244
+ // Deposit the (encrypted) decision request and wake the device. Returns
245
+ // { deposited, request_id, window_ms, expires_at } or { deposited:false, reason }.
246
+ // Never throws.
247
+ export async function depositEscalation(pairing, consultRequest, consultResponse, deps = {}) {
248
+ const fetchFn = deps.fetch || globalThis.fetch;
249
+ const signal = consultResponse && consultResponse.signal;
250
+ if (!pairing || !pairing.pair_key) return { deposited: false, reason: 'unpaired', signal };
251
+ if (!pairing.relay_device_id) return { deposited: false, reason: 'no-relay-device-id', signal };
252
+ const windowMs = Math.max(1000, Number(deps.windowMs) || 3 * 60 * 1000);
253
+ const relayBase = deps.relayBase || relayBaseFromEnv();
254
+ const requestId = deps.requestId || genRequestId();
255
+ try {
256
+ const body = await buildEscalationDeposit(pairing.pair_key, requestId, buildEscalationPayload(consultRequest, consultResponse), { windowMs });
257
+ const res = await fetchFn(`${relayBase}/escalation/${encodeURIComponent(pairing.relay_device_id)}`, {
258
+ method: 'POST',
259
+ headers: { 'Content-Type': 'application/json', ...relayAuthHeader(pairing) },
260
+ body: JSON.stringify(body),
261
+ signal: AbortSignal.timeout(deps.depositTimeoutMs || 8000),
262
+ });
263
+ if (!res || !res.ok) return { deposited: false, reason: `deposit-failed-${res && res.status}`, request_id: requestId, signal };
264
+ let info = {};
265
+ try { info = await res.json(); } catch { /* body optional */ }
266
+ return { deposited: true, request_id: requestId, window_ms: windowMs, expires_at: info.expires_at, woke: info.woke, signal };
267
+ } catch (e) {
268
+ return { deposited: false, reason: `deposit-error:${String(e && e.message || e)}`, request_id: requestId, signal };
269
+ }
270
+ }
271
+
272
+ // Poll ONCE for a decision. Returns { resolved:true, decision, proceed, outcome, note?,
273
+ // modified? } when answered, { resolved:false, status:'pending' } while waiting, or
274
+ // { resolved:false, status:'expired' } once the agent's deadline has passed (the caller
275
+ // then applies the safe-default hold). Never throws.
276
+ export async function pollEscalationOnce(pairing, requestId, deps = {}) {
277
+ const fetchFn = deps.fetch || globalThis.fetch;
278
+ if (!pairing || !pairing.pair_key || !requestId) return { resolved: false, status: 'expired', reason: 'bad-args' };
279
+ if (deps.expiresAt && (deps.now ? deps.now() : Date.now()) > deps.expiresAt) return { resolved: false, status: 'expired' };
280
+ const relayBase = deps.relayBase || relayBaseFromEnv();
281
+ let body = null;
282
+ try {
283
+ const res = await fetchFn(`${relayBase}/escalation/response/${encodeURIComponent(requestId)}`, {
284
+ headers: relayAuthHeader(pairing),
285
+ signal: AbortSignal.timeout(deps.pollTimeoutMs || 8000),
286
+ });
287
+ if (res && res.ok) body = await res.json();
288
+ } catch { /* transient */ }
289
+ if (!body) return { resolved: false, status: 'pending' };
290
+ const data = await unwrapEscalationResponse(pairing.pair_key, body);
291
+ if (!data || !DECISION_OUTCOME[data.decision]) return { resolved: false, status: 'pending' };
292
+ return {
293
+ resolved: true,
294
+ decision: data.decision,
295
+ proceed: data.decision !== 'deny',
296
+ note: typeof data.note === 'string' ? data.note : undefined,
297
+ modified: data.decision === 'modify' ? (data.modified ?? null) : undefined,
298
+ outcome: DECISION_OUTCOME[data.decision],
299
+ request_id: requestId,
300
+ };
301
+ }
302
+
303
+ // The SAFE DEFAULT applied when the window lapses with no answer (roadmap §crux).
304
+ // `escalate` already means "hold for a human", so absent a decision the agent must
305
+ // NOT proceed — it holds. A timeout is not an override, so it teaches nothing
306
+ // (outcome 'unknown'). Returned in the same shape as a real decision so the caller
307
+ // has one code path.
308
+ function safeDefault(signal) {
309
+ return {
310
+ decision: 'hold',
311
+ resolved: false,
312
+ reason: 'timeout',
313
+ proceed: false, // do-not-proceed is the safe default for a blocking escalate/clarify
314
+ outcome: 'unknown', // no answer ⇒ no act-as-me signal
315
+ signal,
316
+ };
317
+ }
318
+
319
+ // Orchestrate one real-time escalation: deposit → poll within the window → decision
320
+ // or safe-default. Best-effort and self-contained — every failure path resolves to a
321
+ // safe-default rather than throwing into the agent. `deps.fetch`, `deps.sleep`,
322
+ // `deps.now`, `deps.pollMs`, `deps.windowMs`, `deps.relayBase` are injectable for tests.
323
+ //
324
+ // pairing : { pair_key, relay_device_id } (loadPairingRecord)
325
+ // consultRequest : the args passed to phaedo_consult
326
+ // consultResponse : the §10.3 response it returned (must be escalate/clarify)
327
+ // Returns:
328
+ // { resolved, decision, proceed, note?, outcome, request_id, signal, reason? }
329
+ export async function requestEscalationDecision(pairing, consultRequest, consultResponse, deps = {}) {
330
+ const fetchFn = deps.fetch || globalThis.fetch;
331
+ const wait = deps.sleep || sleep;
332
+ const now = deps.now || (() => Date.now());
333
+ const signal = consultResponse && consultResponse.signal;
334
+
335
+ if (!pairing || !pairing.pair_key) return { ...safeDefault(signal), reason: 'unpaired' };
336
+ if (!pairing.relay_device_id) return { ...safeDefault(signal), reason: 'no-relay-device-id' };
337
+
338
+ const windowMs = Math.max(1000, Number(deps.windowMs) || 3 * 60 * 1000);
339
+ const pollMs = Math.max(250, Number(deps.pollMs) || 3000);
340
+ const relayBase = deps.relayBase || relayBaseFromEnv();
341
+ const requestId = deps.requestId || genRequestId();
342
+
343
+ // 1. Deposit the (encrypted) decision request; the relay wakes the device.
344
+ try {
345
+ const body = await buildEscalationDeposit(pairing.pair_key, requestId, buildEscalationPayload(consultRequest, consultResponse), { windowMs });
346
+ const res = await fetchFn(`${relayBase}/escalation/${encodeURIComponent(pairing.relay_device_id)}`, {
347
+ method: 'POST',
348
+ headers: { 'Content-Type': 'application/json', ...relayAuthHeader(pairing) },
349
+ body: JSON.stringify(body),
350
+ signal: AbortSignal.timeout(deps.depositTimeoutMs || 8000),
351
+ });
352
+ if (!res || !res.ok) return { ...safeDefault(signal), reason: `deposit-failed-${res && res.status}`, request_id: requestId };
353
+ } catch (e) {
354
+ return { ...safeDefault(signal), reason: `deposit-error:${String(e && e.message || e)}`, request_id: requestId };
355
+ }
356
+
357
+ // 2. Poll for the decision until the window lapses.
358
+ const deadline = now() + windowMs;
359
+ const pollUrl = `${relayBase}/escalation/response/${encodeURIComponent(requestId)}`;
360
+ while (now() < deadline) {
361
+ await wait(pollMs);
362
+ let body = null;
363
+ try {
364
+ const res = await fetchFn(pollUrl, { headers: relayAuthHeader(pairing), signal: AbortSignal.timeout(deps.pollTimeoutMs || 8000) });
365
+ if (res && res.ok) body = await res.json();
366
+ } catch { /* transient — keep polling */ }
367
+ if (!body) continue;
368
+ const data = await unwrapEscalationResponse(pairing.pair_key, body);
369
+ if (!data || !DECISION_OUTCOME[data.decision]) continue; // ignore unparseable/unknown decisions; keep waiting
370
+ return {
371
+ resolved: true,
372
+ decision: data.decision,
373
+ proceed: data.decision !== 'deny',
374
+ note: typeof data.note === 'string' ? data.note : undefined,
375
+ modified: data.decision === 'modify' ? (data.modified ?? null) : undefined,
376
+ outcome: DECISION_OUTCOME[data.decision],
377
+ request_id: requestId,
378
+ signal,
379
+ };
380
+ }
381
+ // 3. Window lapsed — safe default (hold; no learning).
382
+ return { ...safeDefault(signal), request_id: requestId };
383
+ }
@@ -12,6 +12,8 @@ import { readFile } from 'fs/promises';
12
12
  import { resolve } from 'path';
13
13
  import { hasPairingRecord, loadPairingRecord, loadFingerprintFromPhone, PhoneError } from './phone-source.js';
14
14
  import { defaultStateDir, writeCache, readCache, clearCache } from './cache.js';
15
+ import { foldLocalCapture, isLocalCaptureOwner } from './capture-local.js';
16
+ import { Capabilities, AuthoredSource } from './lib/phaedo-crypto.js';
15
17
 
16
18
  // §4.1 protocol-identity normalization (consumer defense-in-depth, 2026-06-16).
17
19
  // A stored fingerprint from a producer that predates identity stamping (or whose
@@ -38,6 +40,39 @@ function normalizeIdentity(vd) {
38
40
  return { ...vd, phaedo_fingerprint: { ...fp, ...patch } };
39
41
  }
40
42
 
43
+
44
+ // §5 CANONICAL ON READ — the step-3 read path, applied ONCE at the boundary.
45
+ //
46
+ // The agent's authored-content readers (ap2.js buildIntentMandatePayload, autonomy-promotion.js,
47
+ // consult-core.js delegation authoring) all take a FINGERPRINT, not a vault. Threading the §5
48
+ // configuration through every one of them would mean changing four signatures and every caller,
49
+ // and would leave four places that must each remember to check §5.4.
50
+ //
51
+ // Folding here instead makes every downstream reader correct by construction, and means step 3
52
+ // (deleting fp.standing_rules) changes nothing about what any of them see. One fold, one §5.4
53
+ // check, one place to be wrong.
54
+ //
55
+ // §5.4 matters more on this path than anywhere else: these entries become AUTHORIZATIONS — what
56
+ // the agent may do without asking — so a configuration belonging to another identity, or to a
57
+ // fingerprint since replaced by a restore or a cross-device merge, must not govern. Falls back
58
+ // to the A-home rather than throwing; an agent that cannot resolve policy must still run the
59
+ // safe path.
60
+ export function foldConfigHome(vd) {
61
+ if (!vd || typeof vd !== 'object') return vd;
62
+ const fp = vd.phaedo_fingerprint;
63
+ const cfg = vd.phaedo_user_configuration;
64
+ if (!fp || typeof fp !== 'object' || !cfg || typeof cfg !== 'object') return vd;
65
+ if (!Capabilities || !AuthoredSource) return vd; // vendored module missing → leave as-is
66
+ if (!AuthoredSource.configGoverns(cfg, fp)) return vd; // §5.4 — not this subject's
67
+ if (!Array.isArray(cfg.standing_instructions)) return vd; // not a statement about rules
68
+ const next = { ...fp };
69
+ // An EMPTY standing_instructions is a statement ("no rules"), not an absence — leaving the
70
+ // A-home in place there would resurrect rules the subject deleted (#247's lesson).
71
+ if (cfg.standing_instructions.length) next.standing_rules = cfg.standing_instructions;
72
+ else delete next.standing_rules;
73
+ return { ...vd, phaedo_fingerprint: next };
74
+ }
75
+
41
76
  // Resolution order for the local fixture (1a):
42
77
  // 1. PHAEDO_FINGERPRINT env var (absolute or cwd-relative path)
43
78
  // 2. ./fingerprint.json next to the server
@@ -52,6 +87,35 @@ function resolveSourcePath(baseDir) {
52
87
  // phaedo_session_metrics }
53
88
  // Throws on unreadable/invalid source — the tool layer maps that to
54
89
  // decryption_failed (the §9.5 "couldn't obtain plaintext fingerprint" case).
90
+ // PROVENANCE. A cache fallback used to be indistinguishable from a live phone read — same
91
+ // object, same shape, no marker anywhere — so an agent answered "yes, you're pre-approved"
92
+ // from a 22-hour-old snapshot with total confidence, and `pull.js` printed "Read your
93
+ // profile from the phone" either way. There was no signal to notice. Callers that make
94
+ // AUTHORITY decisions must be able to tell the difference.
95
+ //
96
+ // Carried on a SYMBOL deliberately: it never serializes, never reaches a schema, never rides
97
+ // a sync, and cannot be mistaken for spec'd fingerprint content. It is a fact about THIS
98
+ // read, not about the artifact.
99
+ export const SOURCE = Symbol('phaedo.fingerprint.source');
100
+
101
+ function stamp(vd, cached) {
102
+ if (!vd || typeof vd !== 'object') return vd;
103
+ const cachedAt = Number(cached?.cachedAt) || null; // readCache returns { vault, cachedAt }
104
+ try {
105
+ Object.defineProperty(vd, SOURCE, {
106
+ value: { live: false, cachedAt, ageMs: cachedAt ? Date.now() - cachedAt : null },
107
+ enumerable: false, configurable: true, writable: true,
108
+ });
109
+ } catch { /* frozen object — provenance is best-effort, never fatal */ }
110
+ return vd;
111
+ }
112
+
113
+ /** Provenance of a loaded fingerprint. Absent marker → a live read. */
114
+ export function sourceOf(vd) {
115
+ const s = vd && vd[SOURCE];
116
+ return s || { live: true, cachedAt: null, ageMs: 0 };
117
+ }
118
+
55
119
  export async function loadFingerprint(baseDir) {
56
120
  // Explicit local source wins over phone pairing: if PHAEDO_FINGERPRINT names a
57
121
  // file, honor it and skip the phone entirely. Pointing at a concrete fingerprint
@@ -69,7 +133,7 @@ export async function loadFingerprint(baseDir) {
69
133
  try {
70
134
  const vd = await loadFingerprintFromPhone(rec);
71
135
  await writeCache(stateDir, vd);
72
- return normalizeIdentity(vd);
136
+ return foldConfigHome(normalizeIdentity(vd));
73
137
  } catch (err) {
74
138
  // A 4xx means the phone REACHED us and rejected this client (revoked /
75
139
  // invalid credentials / failed handshake). Revocation must be effective:
@@ -81,7 +145,7 @@ export async function loadFingerprint(baseDir) {
81
145
  throw err;
82
146
  }
83
147
  const cached = await readCache(stateDir);
84
- if (cached) return normalizeIdentity(cached.vault); // phone unreachable → encrypted cache (may be stale)
148
+ if (cached) return stamp(foldConfigHome(normalizeIdentity(cached.vault)), cached); // phone unreachable → encrypted cache (may be stale)
85
149
  throw err; // no phone + no cache → cannot serve
86
150
  }
87
151
  }
@@ -98,7 +162,17 @@ export async function loadFingerprint(baseDir) {
98
162
  if (!vd || typeof vd !== 'object' || !vd.phaedo_fingerprint) {
99
163
  throw new Error(`Fingerprint source at ${path} is missing phaedo_fingerprint.`);
100
164
  }
101
- return normalizeIdentity(vd);
165
+ // mcp-local capture tier (desktop-capture-deposit.md §4 tier 3): unpaired,
166
+ // phone-less serving is the one configuration where NOBODY else drains what the
167
+ // capture hook accumulated on this machine — fold its local state into the
168
+ // served fingerprint at read time (capture-local.js; read-time overlay, no
169
+ // persistence, idempotent). Owner-gated: a paired setup never reaches this
170
+ // branch, and an explicit PHAEDO_FINGERPRINT ("use THIS file") stays verbatim —
171
+ // it is the hermetic-test escape hatch.
172
+ if (!process.env.PHAEDO_FINGERPRINT && await isLocalCaptureOwner(hasPairingRecord(baseDir))) {
173
+ return foldConfigHome(normalizeIdentity(await foldLocalCapture(vd)));
174
+ }
175
+ return foldConfigHome(normalizeIdentity(vd));
102
176
  }
103
177
 
104
178
  // Like loadFingerprint, but PREFERS the at-rest encrypted cache over a live phone pull.
@@ -129,7 +203,7 @@ export async function loadFingerprint(baseDir) {
129
203
  export async function loadFingerprintPreferCache(baseDir) {
130
204
  if (!process.env.PHAEDO_FINGERPRINT && hasPairingRecord(baseDir)) {
131
205
  const cached = await readCache(defaultStateDir());
132
- if (cached) return normalizeIdentity(cached.vault);
206
+ if (cached) return stamp(foldConfigHome(normalizeIdentity(cached.vault)), cached);
133
207
  }
134
208
  return loadFingerprint(baseDir);
135
209
  }