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/NOTICE
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
Phaedo
|
|
2
|
+
Copyright 2026 Phaedo Labs, Inc.
|
|
3
|
+
|
|
4
|
+
This product includes software developed by Phaedo Labs, Inc. (https://phaedo.so),
|
|
5
|
+
licensed under the Apache License, Version 2.0.
|
|
6
|
+
|
|
7
|
+
"Phaedo" and the Phaedo logo are trademarks of Phaedo Labs, Inc. The Apache License,
|
|
8
|
+
Version 2.0 grants no trademark rights (Apache-2.0 §6); see TRADEMARKS for the brand
|
|
9
|
+
policy and permitted nominative use.
|
|
10
|
+
|
|
11
|
+
----------------------------------------------------------------------
|
|
12
|
+
Third-party components (not redistributed in this package)
|
|
13
|
+
----------------------------------------------------------------------
|
|
14
|
+
The reference browser extension vendors @mlc-ai/web-llm (Apache-2.0). That bundle
|
|
15
|
+
is NOT included in this npm package or the public repository (it is a gitignored
|
|
16
|
+
build artifact). If/when the extension is distributed, the @mlc-ai/web-llm
|
|
17
|
+
LICENSE and NOTICE travel with it.
|
package/README.md
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
# Phaedo MCP server
|
|
1
|
+
# Phaedo MCP server (`phaedo-mcp`)
|
|
2
2
|
|
|
3
|
-
A local [MCP](https://modelcontextprotocol.io) server that
|
|
4
|
-
cognitive
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
A local [MCP](https://modelcontextprotocol.io) server that brings your Phaedo
|
|
4
|
+
cognitive fingerprint to any MCP client (Claude Desktop, Claude Code, Cursor,
|
|
5
|
+
agent frameworks) — no Chrome extension needed. It injects your fingerprint
|
|
6
|
+
**projection** so the assistant responds in your style, and adds **consultation**
|
|
7
|
+
(decision/risk signals), **real-time escalation** to your paired phone for a live
|
|
8
|
+
approve / deny / modify on a blocking decision, and **act-as-me** learning from
|
|
9
|
+
your outcome marks. One integration, every MCP client.
|
|
7
10
|
|
|
8
11
|
Implements `docs/protocol/mcp-binding.md` §3.1:
|
|
9
12
|
|
|
@@ -17,12 +20,24 @@ Implements `docs/protocol/mcp-binding.md` §3.1:
|
|
|
17
20
|
| tool | `phaedo_escalate` | run the consult and, on a blocking signal (`escalate`, or a consequential `clarify`), wake the subject's paired device for a live **approve / deny / modify**; returns a `request_id` (non-blocking). Safe default on no answer: **hold**. |
|
|
18
21
|
| tool | `phaedo_escalation_status` | poll an escalation by `request_id`; on resolution records the subject's live decision as the §10.6 outcome automatically (a real-time approve/deny is the strongest act-as-me signal). |
|
|
19
22
|
| tool | `phaedo_record_outcome` | §10.6 — record what the SUBJECT decided about a consulted action (`approved`/`rejected`/`modified`/`unknown`). A `rejected`/`modified` outcome teaches the **act-as-me** channel (how the user wants delegated work handled). |
|
|
23
|
+
| tool | `phaedo_sign_mandate` | §8.3 — ask the subject to **sign** an AP2 Intent Mandate built from their standing authorizations (a verifiable payment credential). Deposits the request and returns a `request_id` + the unsigned mandate (non-blocking). The subject signs on their phone in the Secure Enclave, or declines. |
|
|
24
|
+
| tool | `phaedo_sign_status` | poll a `phaedo_sign_mandate` request by `request_id` (+ the returned mandate); returns the mandate carrying a **verified** signature once signed, `refused` if declined, or pending/expired. The signature is checked against the subject's published signing key before it is returned. |
|
|
20
25
|
|
|
21
26
|
The projection is rendered by the **same** `context-block.js` the extension
|
|
22
27
|
injects, so MCP injection is byte-identical to web injection.
|
|
23
28
|
|
|
24
29
|
## Status
|
|
25
30
|
|
|
31
|
+
The phases below track the **fingerprint source** (how the server obtains the
|
|
32
|
+
fingerprint). The consultation, real-time escalation, act-as-me / delegation, and
|
|
33
|
+
receipts-audit surfaces (the tools table above) shipped on top of these across the
|
|
34
|
+
**0.2.x** line, and the §8.3 signing pair in **0.3.0** — see `CHANGELOG.md`.
|
|
35
|
+
|
|
36
|
+
> **Install 0.3.0 or later.** 0.2.0, 0.2.1 and 0.2.2 were published with three
|
|
37
|
+
> modules missing from the package, so they exit with `ERR_MODULE_NOT_FOUND`
|
|
38
|
+
> instead of starting. 0.3.0 is the first working release since 0.1.1, and
|
|
39
|
+
> `test-package-closure.mjs` now boots the packed tarball in CI.
|
|
40
|
+
|
|
26
41
|
- **Phase 1a (this cut):** reads the fingerprint from a local JSON file (see
|
|
27
42
|
*Fingerprint source*). Lets injection work in a real client today, no crypto.
|
|
28
43
|
- **Phase 1b-i (done):** the spec-faithful key boundary — authorize → pull
|
|
@@ -50,8 +65,10 @@ injects, so MCP injection is byte-identical to web injection.
|
|
|
50
65
|
`../docs/roadmap/mcp-revocable-approval-plan.md`. Phone-gated unlock stays a
|
|
51
66
|
deferred enterprise mode behind a config flag.
|
|
52
67
|
|
|
53
|
-
**
|
|
54
|
-
|
|
68
|
+
**No conversation capture.** MCP never returns the conversation, so your
|
|
69
|
+
fingerprint does not learn from MCP sessions (binding §5). (Consultation,
|
|
70
|
+
escalation, and `phaedo_record_outcome` shape and record *decisions*, but none
|
|
71
|
+
capture conversation text into the fingerprint.)
|
|
55
72
|
Local **stdio** only; **standard** mode only (enhanced-privacy/Stained-Glass and
|
|
56
73
|
remote HTTP/SSE are out of this cut).
|
|
57
74
|
|
|
@@ -181,6 +198,31 @@ fully automatic depending on the client:
|
|
|
181
198
|
|
|
182
199
|
Every chat in that project then injects automatically.
|
|
183
200
|
|
|
201
|
+
### Capture from Claude Code / Cowork (optional)
|
|
202
|
+
|
|
203
|
+
`npm run setup:code-hook` also installs a **SessionEnd** hook that reads the just-ended
|
|
204
|
+
transcript locally, derives behavioral signals (deterministic pattern detectors), and
|
|
205
|
+
deposits an encrypted **signal delta** to the owner — so your Claude Code / Cowork usage
|
|
206
|
+
feeds the fingerprint, not just the browser. The transcript is counted and **discarded**
|
|
207
|
+
(nothing but signal names + integers leaves the machine); a paired phone/extension drains
|
|
208
|
+
and merges it.
|
|
209
|
+
|
|
210
|
+
Optionally enrich the deterministic detectors with a **local model** (opt-in) — it
|
|
211
|
+
classifies the messages the regexes miss, against the same taxonomy, and feeds the same
|
|
212
|
+
counters (so re-fires stay idempotent). Any **Ollama / OpenAI-compatible** endpoint works:
|
|
213
|
+
|
|
214
|
+
```
|
|
215
|
+
ollama pull llama3.1:8b # or any local model
|
|
216
|
+
npm run setup:capture-model -- llama3.1:8b # writes ~/.phaedo-mcp/capture-model.json
|
|
217
|
+
npm run setup:capture-model -- off # turn it back off
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
Point it elsewhere with `--capture-url <url>` (default `http://localhost:11434`). It's
|
|
221
|
+
fail-safe: endpoint down/slow/garbage → the hook silently falls back to the deterministic
|
|
222
|
+
detectors, and a model call can never break or stall a session (30s budget, hook always
|
|
223
|
+
exits 0). Prefer the `setup:capture-model` config file over an env var — the file reaches
|
|
224
|
+
Dock-launched Claude Desktop, a shell `export` does not.
|
|
225
|
+
|
|
184
226
|
## Consult at decision points (`phaedo_consult`, §10)
|
|
185
227
|
|
|
186
228
|
Beyond injecting style, an agent can **ask the fingerprint how the user's pattern
|
|
@@ -303,7 +345,7 @@ The installer writes exactly this. The minimal **paired** entry needs no `env`
|
|
|
303
345
|
"mcpServers": {
|
|
304
346
|
"phaedo": {
|
|
305
347
|
"command": "/absolute/path/to/node",
|
|
306
|
-
"args": ["/
|
|
348
|
+
"args": ["/absolute/path/to/phaedo/mcp/server.js"]
|
|
307
349
|
}
|
|
308
350
|
}
|
|
309
351
|
}
|
|
@@ -315,5 +357,5 @@ chat and prepend the returned `projection`.
|
|
|
315
357
|
|
|
316
358
|
**Claude Code** — `.mcp.json` in a project, or:
|
|
317
359
|
```bash
|
|
318
|
-
claude mcp add phaedo -- /absolute/path/to/node /
|
|
360
|
+
claude mcp add phaedo -- /absolute/path/to/node /absolute/path/to/phaedo/mcp/server.js
|
|
319
361
|
```
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// §10.2 `action_binding` — tying a consultation to the call it claims to describe.
|
|
2
|
+
//
|
|
3
|
+
// THE PROBLEM. Every field the resolver decides on lives in `action_descriptor`,
|
|
4
|
+
// and every one of them is authored by the agent being gated: domain, magnitude,
|
|
5
|
+
// reversible, amount, summary. A standing authorization like
|
|
6
|
+
//
|
|
7
|
+
// { match: { domains: ["writing"], magnitude_max: "medium", reversible: true },
|
|
8
|
+
// effect: "autoproceed" }
|
|
9
|
+
//
|
|
10
|
+
// therefore fires on whatever the agent SAYS it is doing, not on what it is doing.
|
|
11
|
+
// A buggy, prompt-injected, or hostile agent auto-proceeds on anything by
|
|
12
|
+
// describing it as low-magnitude reversible writing. The escalation rail inherits
|
|
13
|
+
// the same assumption: the subject approves the agent's prose summary.
|
|
14
|
+
//
|
|
15
|
+
// This is a known 2026 vulnerability class, not a hypothetical — agents requesting
|
|
16
|
+
// approval on a shown path while operating on the resolved target (CWE-61 symlink
|
|
17
|
+
// following + CWE-451 UI misrepresentation) produced CVE-2026-12958 (Amazon) and
|
|
18
|
+
// CVE-2026-50549 (Cursor), among others.
|
|
19
|
+
//
|
|
20
|
+
// WHAT THIS DOES, AND WHAT IT DOES NOT. A binding does NOT make the descriptor
|
|
21
|
+
// truthful — nothing at this layer can, short of the resolver executing the call
|
|
22
|
+
// itself. What it does:
|
|
23
|
+
//
|
|
24
|
+
// 1. makes a mismatch PROVABLE after the fact (the receipt records the exact
|
|
25
|
+
// tool and a hash of its arguments, so the agent's own logs can be checked
|
|
26
|
+
// against what the oracle was told),
|
|
27
|
+
// 2. gives the subject the REAL call on the escalation card, next to the
|
|
28
|
+
// agent's description of it, and
|
|
29
|
+
// 3. lets an authored authorization demand a binding before it will RELAX
|
|
30
|
+
// anything (§10.5 `require_binding`), so the blast radius of a fabricated
|
|
31
|
+
// descriptor is bounded by what the subject explicitly opted into.
|
|
32
|
+
//
|
|
33
|
+
// The hash is over the RFC 8785 canonical form of the arguments, so an agent and
|
|
34
|
+
// an auditor computing it independently agree byte-for-byte. Arguments themselves
|
|
35
|
+
// are NEVER carried — only a digest, plus an optional short human preview that the
|
|
36
|
+
// subject sees on the escalation sheet.
|
|
37
|
+
//
|
|
38
|
+
// PURE: no I/O, no globals, no crypto beyond the hash the caller injects. Shared by
|
|
39
|
+
// the resolver (mcp/consult-core.js), the receipt writer (mcp/receipts.js) and the
|
|
40
|
+
// escalation payload builder (mcp/escalation.js) so the three cannot drift.
|
|
41
|
+
|
|
42
|
+
import { PhaedoError } from './errors.js';
|
|
43
|
+
|
|
44
|
+
// A preview is for a human glance on a phone sheet, not a serialization format.
|
|
45
|
+
export const PREVIEW_MAX = 200;
|
|
46
|
+
export const TOOL_NAME_MAX = 120;
|
|
47
|
+
// A hex or base64url digest. Bounded so a malformed field can't bloat the receipt
|
|
48
|
+
// store or the (encrypted) escalation payload.
|
|
49
|
+
const HASH_RE = /^[A-Za-z0-9_-]{16,128}$/;
|
|
50
|
+
|
|
51
|
+
const isObj = (x) => x !== null && typeof x === 'object' && !Array.isArray(x);
|
|
52
|
+
const clip = (s, n) => String(s).replace(/\s+/g, ' ').trim().slice(0, n);
|
|
53
|
+
|
|
54
|
+
// Parse + validate §10.2 `action_binding`. Returns the normalized binding, or null
|
|
55
|
+
// when absent. THROWS `request_malformed` when present but structurally wrong — a
|
|
56
|
+
// half-formed binding is worse than none, because a caller could believe it is
|
|
57
|
+
// bound when the resolver treats it as unbound.
|
|
58
|
+
export function normalizeActionBinding(raw) {
|
|
59
|
+
if (raw == null) return null;
|
|
60
|
+
if (!isObj(raw)) throw new PhaedoError('request_malformed', 'action_binding must be an object.');
|
|
61
|
+
|
|
62
|
+
const toolName = typeof raw.tool_name === 'string' ? raw.tool_name.trim() : '';
|
|
63
|
+
if (!toolName) throw new PhaedoError('request_malformed', 'action_binding.tool_name is required and must be a non-empty string.');
|
|
64
|
+
if (toolName.length > TOOL_NAME_MAX) throw new PhaedoError('request_malformed', `action_binding.tool_name exceeds ${TOOL_NAME_MAX} characters.`);
|
|
65
|
+
|
|
66
|
+
const hash = typeof raw.arguments_hash === 'string' ? raw.arguments_hash.trim() : '';
|
|
67
|
+
if (!hash) throw new PhaedoError('request_malformed', 'action_binding.arguments_hash is required and must be a non-empty string.');
|
|
68
|
+
if (!HASH_RE.test(hash)) throw new PhaedoError('request_malformed', 'action_binding.arguments_hash must be a hex or base64url digest (16–128 chars).');
|
|
69
|
+
|
|
70
|
+
const out = { tool_name: clip(toolName, TOOL_NAME_MAX), arguments_hash: hash };
|
|
71
|
+
if (typeof raw.arguments_preview === 'string' && raw.arguments_preview.trim()) {
|
|
72
|
+
out.arguments_preview = clip(raw.arguments_preview, PREVIEW_MAX);
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Is this request bound to a concrete call? The single predicate every consumer
|
|
78
|
+
// asks, so "bound" means exactly one thing across the resolver, the policy engine,
|
|
79
|
+
// the receipt and the escalation card.
|
|
80
|
+
export function isBound(binding) {
|
|
81
|
+
return !!(binding && binding.tool_name && binding.arguments_hash);
|
|
82
|
+
}
|
package/agreement.js
CHANGED
|
@@ -77,6 +77,44 @@ export function computeAgreement(receipts, { windowDays = DEFAULT_WINDOW_DAYS, n
|
|
|
77
77
|
};
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
// ── The transportable summary (receipts digest → phone) ───────────────────────
|
|
81
|
+
// `computeAgreement` is the full local report; this is the bounded slice that rides
|
|
82
|
+
// the `agent_to_phone` receipts digest so the subject can see how well their oracle
|
|
83
|
+
// is tracking them, per domain, without opening a terminal.
|
|
84
|
+
//
|
|
85
|
+
// It is derived ENTIRELY from the subject's own receipts and only ever travels
|
|
86
|
+
// between the subject's own devices (ciphertext over the relay), so it raises no
|
|
87
|
+
// §10.4 question — but it still carries no layer content, no rationale, and no
|
|
88
|
+
// `drivers`: counts and a rate, nothing else.
|
|
89
|
+
//
|
|
90
|
+
// Bounded on purpose (the digest is a last-write-wins payload, so it must not grow
|
|
91
|
+
// without limit): unscored domains are dropped (a domain with nothing scored says
|
|
92
|
+
// nothing), the rest are ranked by `scored` and capped. Ties break by name so the
|
|
93
|
+
// payload is DETERMINISTIC — an unstable key order would make every deposit look
|
|
94
|
+
// like a change.
|
|
95
|
+
export const DIGEST_DOMAIN_CAP = 8;
|
|
96
|
+
|
|
97
|
+
const slimBucket = (b) => ({
|
|
98
|
+
rate: b.rate,
|
|
99
|
+
scored: b.scored,
|
|
100
|
+
agreements: b.agreements,
|
|
101
|
+
disagreements: b.disagreements,
|
|
102
|
+
non_directional: b.non_directional,
|
|
103
|
+
awaiting_user_action: b.awaiting_user_action,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// (receipts, { windowDays, now, domainCap }) -> { window_days, overall, domains }
|
|
107
|
+
export function agreementDigest(receipts, { windowDays = DEFAULT_WINDOW_DAYS, now = Date.now(), domainCap = DIGEST_DOMAIN_CAP } = {}) {
|
|
108
|
+
const report = computeAgreement(receipts, { windowDays, now });
|
|
109
|
+
const domains = {};
|
|
110
|
+
Object.entries(report.domains)
|
|
111
|
+
.filter(([, b]) => b.scored > 0)
|
|
112
|
+
.sort((a, b) => (b[1].scored - a[1].scored) || a[0].localeCompare(b[0]))
|
|
113
|
+
.slice(0, Math.max(0, domainCap))
|
|
114
|
+
.forEach(([name, b]) => { domains[name] = slimBucket(b); });
|
|
115
|
+
return { window_days: windowDays, overall: slimBucket(report.overall), domains };
|
|
116
|
+
}
|
|
117
|
+
|
|
80
118
|
// ── Dev CLI: `node agreement.js` / `npm run agreement` — local readout only ───
|
|
81
119
|
// Decrypts the local receipt store with the local key; nothing leaves the device.
|
|
82
120
|
// NOT product UX and NOT fed back into deference (see header).
|
package/ap2.js
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// Anamnesis ↔ AP2 profile — standing authorizations as Intent Mandates.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS EXISTS. The standing risk to Anamnesis is not that a rival builds a
|
|
4
|
+
// better decision oracle; it is the standards cold-start. One implementer is a
|
|
5
|
+
// format, not a standard, and evangelism has not moved that number. Binding to a
|
|
6
|
+
// standard that ALREADY has implementers does: every AP2 participant becomes a
|
|
7
|
+
// partial Anamnesis consumer without adopting Anamnesis.
|
|
8
|
+
//
|
|
9
|
+
// AP2 (Google → FIDO Alliance, May 2026; Mastercard's Verifiable Intent
|
|
10
|
+
// co-contributed; the FIDO Payments TWG is chaired by Mastercard and Visa) defines
|
|
11
|
+
// an INTENT MANDATE: a verifiable credential, signed by the user with a
|
|
12
|
+
// hardware-backed device key, TTL'd, carrying structured constraints and the
|
|
13
|
+
// agent's stated understanding of the request ("prompt playback"), plus an
|
|
14
|
+
// intentionally open-ended risk payload.
|
|
15
|
+
//
|
|
16
|
+
// Read that against §10.5. An Intent Mandate is a standing authorization
|
|
17
|
+
// (`match` + `amount_gt` + `expires_at`) rendered as a credential the card networks
|
|
18
|
+
// already accept — and Phaedo's escalate-to-phone flow is architecturally the signing
|
|
19
|
+
// ceremony, once §8.3 gives the phone a signing key. The two designs arrived
|
|
20
|
+
// at the same primitive from opposite ends. "Prompt playback" is the payments
|
|
21
|
+
// industry independently discovering §10.2 `action_binding`.
|
|
22
|
+
//
|
|
23
|
+
// WHAT THIS MODULE DOES, AND DOES NOT. It is a PURE SERIALIZER: it renders the
|
|
24
|
+
// subject's authorizations into the Intent Mandate payload shape, and renders a
|
|
25
|
+
// consultation signal into a risk-payload entry. It does NOT sign — and today
|
|
26
|
+
// NOTHING does: §8.3 asymmetric signing is unbuilt, so no subject signing key
|
|
27
|
+
// exists anywhere in the product yet. That is the honest current state; the
|
|
28
|
+
// intended design is that the key is generated non-extractably on the phone and
|
|
29
|
+
// the ceremony that turns this payload into a credential is the phone's, not the
|
|
30
|
+
// server's. Deliberately unreachable from here either way. Scope + the P-256 vs
|
|
31
|
+
// Ed25519 decision: docs/roadmap/asymmetric-signing-scope-2026-07-23.md.
|
|
32
|
+
//
|
|
33
|
+
// The mapping is lossy in ONE direction on purpose. Anamnesis carries dispositions
|
|
34
|
+
// and inferred signals that have no place in a payment credential, so only the
|
|
35
|
+
// deterministic, subject-authored half crosses: authorizations, their bounds, and
|
|
36
|
+
// their expiry. Nothing inferred, no layer content, no persona. That is the §10.4
|
|
37
|
+
// boundary applied to a new transport, and it is also why this is safe to hand to a
|
|
38
|
+
// merchant's stack.
|
|
39
|
+
//
|
|
40
|
+
// PURE: no I/O, no crypto, no network.
|
|
41
|
+
|
|
42
|
+
import { signingInput } from './signing.js';
|
|
43
|
+
|
|
44
|
+
const MAG_RANK = { low: 0, medium: 1, high: 2 };
|
|
45
|
+
|
|
46
|
+
const isObj = (x) => x !== null && typeof x === 'object' && !Array.isArray(x);
|
|
47
|
+
|
|
48
|
+
// AP2 expresses shopping constraints as structured fields, not prose. §10.5 `match`
|
|
49
|
+
// is already structured, so the translation is mechanical — the only real decision is
|
|
50
|
+
// which constraints have a faithful counterpart and which must be dropped rather than
|
|
51
|
+
// approximated. `magnitude` has none (it is a behavioral notion, not a commerce one),
|
|
52
|
+
// so it is carried as an advisory annotation, never as a spend limit.
|
|
53
|
+
function matchToConstraints(match) {
|
|
54
|
+
const m = isObj(match) ? match : {};
|
|
55
|
+
const out = {};
|
|
56
|
+
const domains = Array.isArray(m.domains) ? m.domains.map((d) => String(d)).filter(Boolean) : [];
|
|
57
|
+
if (domains.length) out.categories = domains;
|
|
58
|
+
// AP2's `max_amount` is a spend cap, so only a genuine CEILING may fill it.
|
|
59
|
+
// `amount_lt` is that ceiling. `amount_gt` is a FLOOR — "fires above N" — and on an
|
|
60
|
+
// autoproceed rule it means "auto-proceed only the LARGE ones", the opposite of a
|
|
61
|
+
// cap. Mapping it to `max_amount` (as this did before `amount_lt` existed) would
|
|
62
|
+
// have told a merchant the subject capped spending at exactly the figure above
|
|
63
|
+
// which they wanted extra scrutiny. It is carried as an annotation instead.
|
|
64
|
+
// Both ceiling forms map to `max_amount`, and when both are present the LOWER wins — AP2 has
|
|
65
|
+
// one field and no way to say "exclusive", so the credential must not assert more headroom than
|
|
66
|
+
// the rule grants. `amount_lte: 20` becomes `max_amount: 20`; `amount_lt: 20` also becomes 20,
|
|
67
|
+
// which is the pre-existing (slightly generous by one cent) mapping, unchanged.
|
|
68
|
+
const ceilings = [m.amount_lt, m.amount_lte].filter((v) => typeof v === 'number' && Number.isFinite(v));
|
|
69
|
+
if (ceilings.length) {
|
|
70
|
+
out.max_amount = Math.min(...ceilings);
|
|
71
|
+
}
|
|
72
|
+
if (typeof m.amount_gt === 'number' && Number.isFinite(m.amount_gt)) {
|
|
73
|
+
out.annotations = { ...(out.annotations || {}), anamnesis_amount_floor: m.amount_gt };
|
|
74
|
+
}
|
|
75
|
+
if (typeof m.reversible === 'boolean') out.refundable_only = m.reversible;
|
|
76
|
+
if (m.magnitude_max && MAG_RANK[m.magnitude_max] !== undefined) {
|
|
77
|
+
out.annotations = { ...(out.annotations || {}), anamnesis_magnitude_max: m.magnitude_max };
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// One §10.5 authorization → the Intent Mandate constraint block it corresponds to,
|
|
83
|
+
// or null when it has no faithful translation.
|
|
84
|
+
//
|
|
85
|
+
// Only AUTOPROCEED authorizations become mandates. That is the whole semantic match:
|
|
86
|
+
// an Intent Mandate is a grant of authority to act without the human present, and
|
|
87
|
+
// `autoproceed` is the only Anamnesis effect that grants anything. An `escalate` or
|
|
88
|
+
// `decline` rule is the ABSENCE of a mandate — the correct AP2 rendering of "ask me
|
|
89
|
+
// first" is not a restrictive credential, it is no credential at all, and emitting
|
|
90
|
+
// one would invert its meaning to a merchant.
|
|
91
|
+
export function authorizationToIntentConstraints(rule, { now = Date.now() } = {}) {
|
|
92
|
+
if (!isObj(rule)) return null;
|
|
93
|
+
if (rule.kind !== undefined && rule.kind !== 'authorization') return null;
|
|
94
|
+
if (rule.effect !== 'autoproceed') return null;
|
|
95
|
+
// A lapsed authorization must not be exported as live authority.
|
|
96
|
+
const exp = Date.parse(rule.expires_at);
|
|
97
|
+
if (Number.isFinite(exp) && exp <= now) return null;
|
|
98
|
+
// Nor must a CLIENT-SCOPED one (§10.5 `agents`). AP2 has no vocabulary for "this
|
|
99
|
+
// specific paired client", so a scoped grant cannot be expressed in a mandate — and
|
|
100
|
+
// emitting it unscoped would tell a merchant the subject granted unconditionally
|
|
101
|
+
// what they in fact granted to exactly one client. That is the same inversion this
|
|
102
|
+
// function already refuses for restrictive rules, and it crosses to a third party,
|
|
103
|
+
// where it is least correctable. A scope that cannot be carried must instead
|
|
104
|
+
// withhold the credential: the correct AP2 rendering of a grant this format cannot
|
|
105
|
+
// express is no grant at all. (An ABSENT `agents` is unscoped and exports normally;
|
|
106
|
+
// an empty array is a scope naming nobody, so it withholds too.)
|
|
107
|
+
if (Array.isArray(rule.agents)) return null;
|
|
108
|
+
return {
|
|
109
|
+
source_rule_id: rule.instruction_id || rule.id || null,
|
|
110
|
+
constraints: matchToConstraints(rule.match),
|
|
111
|
+
// §10.5 require_binding maps onto AP2's own posture that a mandate authorizes a
|
|
112
|
+
// BOUNDED transaction, not open-ended spending. Carried through so a verifier can
|
|
113
|
+
// see the subject demanded the agent commit to a concrete action.
|
|
114
|
+
requires_action_binding: rule.require_binding === true,
|
|
115
|
+
expires_at: Number.isFinite(exp) ? new Date(exp).toISOString() : null,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// The unsigned Intent Mandate payload for a subject's live authorizations.
|
|
120
|
+
//
|
|
121
|
+
// Returns null when the subject has granted nothing — which is the common case and
|
|
122
|
+
// the right answer: no authority, no mandate. Callers MUST NOT synthesize an empty
|
|
123
|
+
// mandate, because an empty credential reads to a merchant as "authorized with no
|
|
124
|
+
// constraints", the exact inversion this module exists to avoid.
|
|
125
|
+
export function buildIntentMandatePayload(fp, opts = {}) {
|
|
126
|
+
const {
|
|
127
|
+
now = Date.now(),
|
|
128
|
+
agentId = null,
|
|
129
|
+
promptPlayback = null, // the agent's stated understanding (AP2 "prompt playback")
|
|
130
|
+
consultSignal = null, // §10.3 signal, if a consultation preceded this
|
|
131
|
+
} = opts;
|
|
132
|
+
|
|
133
|
+
// A standing authorization lives in EITHER home (spec §10.5: "a `consult_policies` rule
|
|
134
|
+
// or `standing_rules` authorization"), so an Intent Mandate — the subject's live
|
|
135
|
+
// authorizations — must read both. Reading only `standing_rules` silently dropped every
|
|
136
|
+
// autoproceed guardrail authored through the extension's Decision-guardrails form (which
|
|
137
|
+
// writes `consult_policies`), so a subject with real grants got an empty mandate. The
|
|
138
|
+
// per-rule guards are unchanged and live in authorizationToIntentConstraints: autoproceed
|
|
139
|
+
// only, not lapsed, and never a client-scoped grant (AP2 can't express `agents`).
|
|
140
|
+
const standingRules = Array.isArray(fp?.standing_rules) ? fp.standing_rules : [];
|
|
141
|
+
const policyRules = Array.isArray(fp?.consult_policies?.rules) ? fp.consult_policies.rules : [];
|
|
142
|
+
// §10.5 tombstones — a REVOKED authorization must never become a mandate grant. This is
|
|
143
|
+
// the highest-stakes read of the two homes: a grant here is what a payment credential is
|
|
144
|
+
// minted against, so a rule the subject revoked that survived in a stale or restored copy
|
|
145
|
+
// would sign away authority they took back. Same consumer MUST as normalizePolicies.
|
|
146
|
+
const revoked = new Set(
|
|
147
|
+
(Array.isArray(fp?.consult_policies?.revoked) ? fp.consult_policies.revoked : [])
|
|
148
|
+
.map((t) => (t && typeof t === 'object' && t.id != null ? String(t.id) : null))
|
|
149
|
+
.filter(Boolean)
|
|
150
|
+
);
|
|
151
|
+
const grants = [];
|
|
152
|
+
const seen = new Set(); // dedupe by source rule id — the same authorization must not
|
|
153
|
+
for (const r of [...standingRules, ...policyRules]) { // become two mandate grants
|
|
154
|
+
if (r && typeof r === 'object') {
|
|
155
|
+
const rid = r.id != null ? String(r.id) : (r.instruction_id != null ? String(r.instruction_id) : null);
|
|
156
|
+
if (rid && revoked.has(rid)) continue;
|
|
157
|
+
}
|
|
158
|
+
const g = authorizationToIntentConstraints(r, { now });
|
|
159
|
+
if (!g) continue;
|
|
160
|
+
if (g.source_rule_id && seen.has(g.source_rule_id)) continue;
|
|
161
|
+
if (g.source_rule_id) seen.add(g.source_rule_id);
|
|
162
|
+
grants.push(g);
|
|
163
|
+
}
|
|
164
|
+
if (!grants.length) return null;
|
|
165
|
+
|
|
166
|
+
// The mandate expires with the FIRST grant to lapse — an aggregate credential must
|
|
167
|
+
// not outlive the narrowest authority inside it.
|
|
168
|
+
const expiries = grants.map((g) => Date.parse(g.expires_at)).filter(Number.isFinite);
|
|
169
|
+
const expiresAt = expiries.length ? new Date(Math.min(...expiries)).toISOString() : null;
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
mandate_type: 'intent',
|
|
173
|
+
// Version the profile, not the payment protocol: AP2 moves on its own cadence and
|
|
174
|
+
// a verifier needs to know which mapping produced this.
|
|
175
|
+
anamnesis_profile: '0.1',
|
|
176
|
+
issued_at: new Date(now).toISOString(),
|
|
177
|
+
expires_at: expiresAt,
|
|
178
|
+
...(agentId ? { agent_id: String(agentId) } : {}),
|
|
179
|
+
...(promptPlayback ? { prompt_playback: String(promptPlayback).slice(0, 500) } : {}),
|
|
180
|
+
grants,
|
|
181
|
+
// AP2 leaves the risk payload open-ended for exactly this kind of signal. It is
|
|
182
|
+
// the ONE place a behavioral system has something to say to a payments network,
|
|
183
|
+
// and it says it in the §10.4 currency: a constrained signal, never persona.
|
|
184
|
+
risk_payload: buildRiskPayload({ consultSignal }),
|
|
185
|
+
// Unsigned by construction. The server CANNOT sign: per §8.3 the subject's key is
|
|
186
|
+
// non-extractable in the phone's Secure Enclave, so signing is a ceremony that belongs
|
|
187
|
+
// to the device holding the key. `intentMandateSigningInput()` below computes the exact
|
|
188
|
+
// bytes the phone would be asked to sign. Until a signature comes back, this object is
|
|
189
|
+
// a proposal, never a credential.
|
|
190
|
+
signature: null,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The exact bytes a §8.3 signature over an Intent Mandate covers: the §6 RFC 8785 canonical
|
|
196
|
+
* form of the payload with `signature` omitted. This is the seam between the server (which
|
|
197
|
+
* builds the mandate) and the phone (which holds the only key that can sign it) — both sides
|
|
198
|
+
* must agree on these bytes or the resulting signature verifies nowhere.
|
|
199
|
+
*
|
|
200
|
+
* Omitting `signature` is what keeps the input stable once the signature is attached, so a
|
|
201
|
+
* verifier recomputes the same bytes from the finished credential.
|
|
202
|
+
*/
|
|
203
|
+
export function intentMandateSigningInput(payload) {
|
|
204
|
+
return signingInput(payload, { field: 'signature' });
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// §10.3 signal → AP2 risk-payload entry. Deliberately minimal: a merchant learns
|
|
208
|
+
// that the subject's own oracle was consulted and what it said — which is exactly the
|
|
209
|
+
// claim that makes a mandate more trustworthy — and learns nothing about the subject.
|
|
210
|
+
export function buildRiskPayload({ consultSignal = null } = {}) {
|
|
211
|
+
const payload = { anamnesis_consulted: !!consultSignal };
|
|
212
|
+
if (consultSignal) payload.anamnesis_signal = String(consultSignal);
|
|
213
|
+
return payload;
|
|
214
|
+
}
|