phaedo-mcp 0.2.2 → 0.3.1
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 +182 -19
- package/action-binding.js +82 -0
- package/agreement.js +86 -2
- 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 +126 -8
- package/consult-policy.js +427 -33
- package/consult-runtime.js +60 -0
- package/consult.js +29 -1
- package/delegation-sync.js +95 -0
- package/escalation-policy.sample.json +13 -0
- package/escalation.js +383 -0
- package/fingerprint-source.js +112 -13
- package/health.js +54 -0
- package/hooks/pre-ship.mjs +409 -0
- package/hooks/session-end.mjs +244 -0
- package/hooks/stop-capture.mjs +140 -0
- package/install.mjs +226 -36
- 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 +29 -5
- package/phone-source.js +85 -12
- package/projection.js +4 -1
- package/pull.js +19 -3
- package/receipts-sync.js +197 -0
- package/receipts.js +34 -3
- package/server.js +323 -54
- package/server.json +3 -3
- package/ship-actions.js +164 -0
- package/ship-readout.js +106 -0
- 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 +184 -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 +45 -0
- package/vendor/extraction/reconcile.js +310 -0
- package/vendor/extraction/resolve.js +178 -0
- package/vendor/extraction/rule-promotion.js +103 -0
- package/vendor/kdf.js +20 -1
- package/vendor/protocol.js +7 -0
- package/vendor/signal-ledger.js +402 -0
- package/vendor/trait-phrases.js +656 -0
- package/vendored-files.mjs +86 -0
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "phaedo-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
7
|
-
"description": "Phaedo MCP server
|
|
7
|
+
"description": "Phaedo MCP server \u2014 inject your cognitive fingerprint into any MCP client, and consult your decision pattern at agent decision points (docs/protocol/mcp-binding.md).",
|
|
8
8
|
"mcpName": "io.github.phaedo-labs/phaedo-mcp",
|
|
9
9
|
"homepage": "https://phaedo.so",
|
|
10
10
|
"repository": {
|
|
@@ -32,16 +32,33 @@
|
|
|
32
32
|
"consult.js",
|
|
33
33
|
"consult-core.js",
|
|
34
34
|
"consult-policy.js",
|
|
35
|
+
"consult-runtime.js",
|
|
36
|
+
"ship-actions.js",
|
|
37
|
+
"ship-readout.js",
|
|
38
|
+
"action-binding.js",
|
|
39
|
+
"autonomy-promotion.js",
|
|
40
|
+
"ap2.js",
|
|
35
41
|
"receipts.js",
|
|
42
|
+
"receipts-sync.js",
|
|
36
43
|
"agreement.js",
|
|
37
44
|
"calibration.js",
|
|
45
|
+
"health.js",
|
|
38
46
|
"phone-source.js",
|
|
47
|
+
"delegation-sync.js",
|
|
48
|
+
"escalation.js",
|
|
49
|
+
"signing.js",
|
|
50
|
+
"signing-request.js",
|
|
51
|
+
"signing-audit.js",
|
|
52
|
+
"capture-sync.js",
|
|
53
|
+
"capture-local.js",
|
|
54
|
+
"capture-a11y.js",
|
|
39
55
|
"cache.js",
|
|
40
56
|
"pairing.js",
|
|
41
57
|
"pair.js",
|
|
42
58
|
"pull.js",
|
|
43
59
|
"install.mjs",
|
|
44
60
|
"build-package.mjs",
|
|
61
|
+
"vendored-files.mjs",
|
|
45
62
|
"vendor/",
|
|
46
63
|
"lib/",
|
|
47
64
|
"hooks/",
|
|
@@ -50,7 +67,9 @@
|
|
|
50
67
|
"sample-fingerprint.json",
|
|
51
68
|
"pairing.sample.json",
|
|
52
69
|
"consult-policies.sample.json",
|
|
53
|
-
"
|
|
70
|
+
"escalation-policy.sample.json",
|
|
71
|
+
"README.md",
|
|
72
|
+
"NOTICE"
|
|
54
73
|
],
|
|
55
74
|
"scripts": {
|
|
56
75
|
"start": "node server.js",
|
|
@@ -60,12 +79,14 @@
|
|
|
60
79
|
"setup": "node install.mjs",
|
|
61
80
|
"setup:pair": "node install.mjs --pair",
|
|
62
81
|
"setup:code-hook": "node install.mjs --code-hook",
|
|
82
|
+
"setup:capture-model": "node install.mjs --capture-model",
|
|
63
83
|
"unsetup": "node install.mjs --uninstall",
|
|
64
84
|
"pair": "node pair.js",
|
|
65
85
|
"pull": "node pull.js",
|
|
66
|
-
"test": "node test-smoke.mjs && node test-phone-boundary.mjs && node test-cache.mjs && node test-pairing.mjs && node test-revoke.mjs && node test-install.mjs && node test-consult.mjs && node test-consult-policy.mjs && node test-delegation-sync.mjs && node test-delegation-relay.mjs && node test-escalation.mjs && node test-receipts.mjs && node test-receipts-sync.mjs && node test-agreement.mjs && node test-calibration.mjs && node test-identity.mjs",
|
|
86
|
+
"test": "node test-smoke.mjs && node test-no-profile.mjs && node test-phone-boundary.mjs && node test-cache.mjs && node test-pairing.mjs && node test-revoke.mjs && node test-install.mjs && node test-consult.mjs && node test-consult-policy.mjs && node test-amount-bands.mjs && node test-resolver-revision.mjs && node test-ship-actions.mjs && node test-ship-hook.mjs && node test-ship-readout.mjs && node test-action-binding.mjs && node test-autonomy-promotion.mjs && node test-ap2.mjs && node test-delegation-sync.mjs && node test-delegation-relay.mjs && node test-capture-sync.mjs && node test-capture-hook.mjs && node test-stop-capture.mjs && node test-capture-local.mjs && node test-capture-a11y.mjs && node test-vendored-capture.mjs && node test-escalation.mjs && node test-domain.mjs && node test-receipts.mjs && node test-receipts-sync.mjs && node test-agreement.mjs && node test-calibration.mjs && node test-identity.mjs && node test-signing.mjs && node test-sign-request.mjs && node test-signing-audit.mjs && node test-delegation-domain-fragmentation.mjs && node test-stale-authority.mjs && node test-config-home-fold.mjs && node test-precedence-and-ceiling.mjs && node test-package-closure.mjs",
|
|
67
87
|
"receipts": "node receipts.js list",
|
|
68
88
|
"agreement": "node agreement.js",
|
|
89
|
+
"ship-readout": "node ship-readout.js",
|
|
69
90
|
"calibration": "node calibration.js",
|
|
70
91
|
"health": "node health.js",
|
|
71
92
|
"validate": "node ../spec/validate-fingerprint.mjs sample-fingerprint.json",
|
|
@@ -80,7 +101,10 @@
|
|
|
80
101
|
"smoke:escalation": "node test-escalation.mjs",
|
|
81
102
|
"smoke:receipts": "node test-receipts.mjs",
|
|
82
103
|
"smoke:agreement": "node test-agreement.mjs",
|
|
83
|
-
"smoke:calibration": "node test-calibration.mjs"
|
|
104
|
+
"smoke:calibration": "node test-calibration.mjs",
|
|
105
|
+
"smoke:binding": "node test-action-binding.mjs",
|
|
106
|
+
"smoke:autonomy": "node test-autonomy-promotion.mjs",
|
|
107
|
+
"smoke:ap2": "node test-ap2.mjs"
|
|
84
108
|
},
|
|
85
109
|
"dependencies": {
|
|
86
110
|
"@modelcontextprotocol/sdk": "^1.0.0",
|
package/phone-source.js
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
|
-
// MCP Phase 1b — real key boundary
|
|
1
|
+
// MCP Phase 1b — real key boundary.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
3
|
+
// The pairing record may come from EITHER provenance: `pair.js`, which pairs this
|
|
4
|
+
// server as its own SAS-approved, revocable client (mcp-revocable-approval-plan.md,
|
|
5
|
+
// DONE + device-verified), or the older "Option B" export of the extension's
|
|
6
|
+
// `phaedo_local_pairing`. This path works under both — the phone's drains try each
|
|
7
|
+
// verified client's key — but the extension-facing `agent_to_ext` lane does NOT;
|
|
8
|
+
// see delegation-sync.js and docs/roadmap/paired-client-identity-2026-07-22.md.
|
|
9
|
+
//
|
|
10
|
+
// Given a pairing record holding `pair_key`, this runs the spec-faithful
|
|
11
|
+
// authorize → pull → decrypt flow — a
|
|
5
12
|
// direct port of popup.js reAuthorize (:745) + silentSync (:883):
|
|
6
13
|
//
|
|
7
14
|
// authorize: fresh ephemeral X25519, HMAC-bound to pair_key, POST /v1/authorize,
|
|
@@ -16,15 +23,34 @@
|
|
|
16
23
|
// after the one-time pairing.
|
|
17
24
|
|
|
18
25
|
import { readFile } from 'fs/promises';
|
|
19
|
-
import { existsSync } from 'fs';
|
|
26
|
+
import { existsSync, readFileSync } from 'fs';
|
|
20
27
|
import { resolve } from 'path';
|
|
21
|
-
import { Phaedo, b64uEnc, b64uDec } from './lib/phaedo-crypto.js';
|
|
28
|
+
import { Phaedo, b64uEnc, b64uDec, Capabilities } from './lib/phaedo-crypto.js';
|
|
22
29
|
|
|
23
30
|
const { Identity, Kdf, Envelope } = Phaedo;
|
|
24
31
|
const EXPIRY_SKEW_MS = 30 * 1000; // re-auth this early to avoid edge-of-expiry races
|
|
25
|
-
const TIMEOUT_MS = 5000; // fail fast when the phone is unreachable → cache fallback
|
|
26
32
|
|
|
27
|
-
//
|
|
33
|
+
// TWO timeouts, because the two requests wait on completely different things.
|
|
34
|
+
//
|
|
35
|
+
// `/v1/profile` waits on a MACHINE: the phone either answers or it doesn't, so failing fast
|
|
36
|
+
// is right — an unreachable phone should fall back to the cache quickly.
|
|
37
|
+
//
|
|
38
|
+
// `/v1/authorize` waits on a PERSON: they must notice their phone, read the card, and pass
|
|
39
|
+
// a biometric gate. A single 5s budget covered both, which made every authorize a race the
|
|
40
|
+
// subject usually lost — the client aborted, the card vanished mid-tap, and an approval
|
|
41
|
+
// completed seconds later landed on a request nobody was listening for. Worse, the abort is
|
|
42
|
+
// a TRANSIENT failure, so it took the silent cache fallback: the agent then answered
|
|
43
|
+
// authorization questions from a stale fingerprint while sounding certain. Observed on
|
|
44
|
+
// device 2026-07-25 — the cache had not been refreshed for ~22 hours and a REVOKED payment
|
|
45
|
+
// grant was still being honoured, because no live pull had ever completed.
|
|
46
|
+
//
|
|
47
|
+
// Face ID on the first try can fit in 5s. A passcode fallback cannot, and neither can a
|
|
48
|
+
// phone that is face-down on a table. Two minutes is the human budget; the phone's own card
|
|
49
|
+
// expiry remains the real bound, this just stops the client giving up first.
|
|
50
|
+
const TIMEOUT_MS = 5000; // reachability: /v1/profile
|
|
51
|
+
const APPROVE_TIMEOUT_MS = 120000; // human-in-the-loop: /v1/authorize
|
|
52
|
+
|
|
53
|
+
// ── Pairing record (from `pair.js`, or the legacy extension export — see header)
|
|
28
54
|
// { endpoint, client_id, client_secret, pair_key (b64url 32B), vault_pub, ... }
|
|
29
55
|
export async function loadPairingRecord(baseDir) {
|
|
30
56
|
const path = process.env.PHAEDO_PAIRING
|
|
@@ -55,6 +81,30 @@ export function hasPairingRecord(baseDir) {
|
|
|
55
81
|
return existsSync(resolve(baseDir, 'pairing.json'));
|
|
56
82
|
}
|
|
57
83
|
|
|
84
|
+
// This server's OWN paired-client identifier — the identity §10.5 `agents` scoping
|
|
85
|
+
// matches on (`docs/roadmap/paired-client-identity-2026-07-22.md`).
|
|
86
|
+
//
|
|
87
|
+
// Read SYNCHRONOUSLY at startup, beside the policy file, because it is process-wide
|
|
88
|
+
// configuration rather than per-call state: one MCP server process serves exactly one
|
|
89
|
+
// paired client, so this cannot change between calls without a restart. That is also
|
|
90
|
+
// what makes the whole design work — the server never has to ask a caller who it is.
|
|
91
|
+
//
|
|
92
|
+
// Fail-safe by construction: any failure (no pairing, unreadable, malformed, missing
|
|
93
|
+
// or non-string `client_id`) yields null, and null makes every `agents`-scoped
|
|
94
|
+
// RELAXING rule inert. A broken read can therefore only ever withhold authority, never
|
|
95
|
+
// grant it. Never throws.
|
|
96
|
+
export function readServingClientId(baseDir) {
|
|
97
|
+
const path = process.env.PHAEDO_PAIRING
|
|
98
|
+
? resolve(process.cwd(), process.env.PHAEDO_PAIRING)
|
|
99
|
+
: resolve(baseDir, 'pairing.json');
|
|
100
|
+
try {
|
|
101
|
+
const id = JSON.parse(readFileSync(path, 'utf8'))?.client_id;
|
|
102
|
+
return typeof id === 'string' && id.trim() ? id.trim() : null;
|
|
103
|
+
} catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
58
108
|
// In-memory session cache, keyed by endpoint. Holds session_key (never persisted).
|
|
59
109
|
const _sessions = new Map();
|
|
60
110
|
|
|
@@ -74,8 +124,14 @@ async function authorize(rec, deps) {
|
|
|
74
124
|
scopes: ['profile:full'],
|
|
75
125
|
eph_ext_pub: b64uEnc(eph.publicKey),
|
|
76
126
|
eph_mac: b64uEnc(extMac),
|
|
127
|
+
// What this agent can do, for the §5 step-3 upgrade gate. The agent reads authored
|
|
128
|
+
// content but has no channel of its own to the extension, so the phone records this and
|
|
129
|
+
// couriers it on. Rides the authorize body because that is the one request the agent
|
|
130
|
+
// makes that the phone authenticates AND that recurs — sessions expire, so the record
|
|
131
|
+
// refreshes while the agent is in use and goes stale when it is not, which is the intent.
|
|
132
|
+
capabilities: Capabilities.advertise('agent'),
|
|
77
133
|
}),
|
|
78
|
-
signal: AbortSignal.timeout(
|
|
134
|
+
signal: AbortSignal.timeout(APPROVE_TIMEOUT_MS), // waits on a human, not a machine
|
|
79
135
|
});
|
|
80
136
|
if (!res.ok) throw new PhoneError(`authorize failed (${res.status})`, res.status);
|
|
81
137
|
const body = await res.json();
|
|
@@ -100,12 +156,29 @@ async function getSession(rec, deps) {
|
|
|
100
156
|
}
|
|
101
157
|
|
|
102
158
|
// Map the §9-profile inner plaintext to the vault payload shape the projection
|
|
103
|
-
// expects. Profile inner is { data: <fingerprint>, metrics:
|
|
104
|
-
// sessions}, scopes } (mobile localServer handleProfile + popup
|
|
105
|
-
|
|
106
|
-
|
|
159
|
+
// expects. Profile inner is { data: <fingerprint>, user_configuration?, metrics:
|
|
160
|
+
// {signals, ling, sessions}, scopes } (mobile localServer handleProfile + popup
|
|
161
|
+
// restoreMetrics). The optional §5 sibling (config home migration, part 3) maps
|
|
162
|
+
// to phaedo_user_configuration, activating the canonical authored read
|
|
163
|
+
// (consult-policy loadPolicies / context-block renderContextBlock); absent —
|
|
164
|
+
// pre-part-3 phone — consumers fall back to the fingerprint A-home unchanged.
|
|
165
|
+
export function toVaultPayload(inner) {
|
|
166
|
+
// Prefer every paired browser's counters combined (`metrics_combined`, 2026-10-01): the subject
|
|
167
|
+
// is one person across browsers, and `metrics` is only the browser that posted last. A phone
|
|
168
|
+
// that predates the sibling sends only `metrics`, which still serves.
|
|
169
|
+
const m = (inner.metrics_combined && typeof inner.metrics_combined === 'object' && !Array.isArray(inner.metrics_combined))
|
|
170
|
+
? inner.metrics_combined
|
|
171
|
+
: (inner.metrics || {});
|
|
107
172
|
return {
|
|
108
173
|
phaedo_fingerprint: inner.data,
|
|
174
|
+
...(inner.user_configuration && typeof inner.user_configuration === 'object'
|
|
175
|
+
? { phaedo_user_configuration: inner.user_configuration } : {}),
|
|
176
|
+
// §8.3 signing public key — a sibling of the fingerprint, carried through so a
|
|
177
|
+
// consumer can VERIFY a signature the phone produced (e.g. an AP2 mandate) against a
|
|
178
|
+
// key learned over the trusted pairing channel, not one asserted in the signed object
|
|
179
|
+
// itself. A plain non-empty string or nothing; never trusted from inside `data`.
|
|
180
|
+
...(typeof inner.signing_public_key === 'string' && inner.signing_public_key
|
|
181
|
+
? { phaedo_signing_public_key: inner.signing_public_key } : {}),
|
|
109
182
|
phaedo_behavioral_signals: m.signals || {},
|
|
110
183
|
phaedo_linguistic_profile: m.ling || {},
|
|
111
184
|
phaedo_session_metrics: m.sessions || {},
|
package/projection.js
CHANGED
|
@@ -87,7 +87,10 @@ export function buildInjectionResponse(vd, request = {}, now = Date.now()) {
|
|
|
87
87
|
}
|
|
88
88
|
|
|
89
89
|
const { vd: selectedVd, included } = selectLayers(vd, request.requested_layers);
|
|
90
|
-
|
|
90
|
+
// §5.2 v0.2: the §9.2 session_context (domain / task_hint) drives deterministic
|
|
91
|
+
// activation of domain/task-scoped standing instructions; absent, scoped rules
|
|
92
|
+
// render with a conditional prefix instead.
|
|
93
|
+
const projection = PhaedoContext.renderContextBlock(selectedVd, { sessionContext: request.session_context || null });
|
|
91
94
|
if (!projection) {
|
|
92
95
|
throw new PhaedoError('decryption_failed', 'Fingerprint produced no projection (empty or missing layers).');
|
|
93
96
|
}
|
package/pull.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { fileURLToPath } from 'url';
|
|
9
9
|
import { dirname } from 'path';
|
|
10
|
-
import { loadFingerprint } from './fingerprint-source.js';
|
|
10
|
+
import { loadFingerprint, sourceOf } from './fingerprint-source.js';
|
|
11
11
|
|
|
12
12
|
const BASE_DIR = dirname(fileURLToPath(import.meta.url));
|
|
13
13
|
|
|
@@ -15,10 +15,26 @@ loadFingerprint(BASE_DIR)
|
|
|
15
15
|
.then((vd) => {
|
|
16
16
|
const fp = (vd && vd.phaedo_fingerprint) || {};
|
|
17
17
|
const layers = fp.layers ? Object.keys(fp.layers).length : 0;
|
|
18
|
+
const src = sourceOf(vd);
|
|
19
|
+
// This line used to say "from the phone" unconditionally — including when the pull had
|
|
20
|
+
// failed and the at-rest cache was served instead. That is precisely how a 22-hour-old
|
|
21
|
+
// profile went unnoticed while the agent answered authorization questions from it. The
|
|
22
|
+
// whole point of this command is to verify the pairing works, so it has to distinguish
|
|
23
|
+
// "your phone answered" from "your phone didn't, here's an old copy".
|
|
24
|
+
if (src.live) {
|
|
25
|
+
process.stdout.write(
|
|
26
|
+
`✓ Read your profile from the phone — fingerprint ${fp.fingerprint_id || '(no id)'}, ${layers} layer(s).\n`
|
|
27
|
+
);
|
|
28
|
+
process.exit(0);
|
|
29
|
+
}
|
|
30
|
+
const hours = src.ageMs != null ? Math.round(src.ageMs / 3600000) : null;
|
|
18
31
|
process.stdout.write(
|
|
19
|
-
|
|
32
|
+
`⚠ Could NOT reach your phone — served a CACHED copy${hours != null ? ` about ${hours}h old` : ''} ` +
|
|
33
|
+
`(fingerprint ${fp.fingerprint_id || '(no id)'}, ${layers} layer(s)).\n` +
|
|
34
|
+
` Anything you changed since then — including a revoked authorization — is NOT reflected.\n` +
|
|
35
|
+
` Open Phaedo on your phone and approve the card, then run this again.\n`
|
|
20
36
|
);
|
|
21
|
-
process.exit(
|
|
37
|
+
process.exit(2); // distinct from 1 (hard failure) — stale, not broken
|
|
22
38
|
})
|
|
23
39
|
.catch((err) => {
|
|
24
40
|
process.stderr.write(`✗ Couldn't read your profile: ${err.message}\n`);
|
package/receipts-sync.js
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// §10.6 receipts audit channel — MCP ↔ phone transport (G1+G2).
|
|
2
|
+
//
|
|
3
|
+
// Closes the act-as-me learning loop: today receipts written by `phaedo_consult` /
|
|
4
|
+
// `phaedo_escalate` sit at `user_action: null` until the subject runs `node receipts.js
|
|
5
|
+
// mark` — a dev CLI only. This module puts the audit on the subject's PHONE so they can
|
|
6
|
+
// mark outcomes in the flow, which then feeds delegation learning (consult-core.js
|
|
7
|
+
// `deriveDelegationSignals`).
|
|
8
|
+
//
|
|
9
|
+
// Two directions over the existing fp-mailbox transport (relay/mailbox.js):
|
|
10
|
+
//
|
|
11
|
+
// agent_to_phone — MCP deposits a digest of the latest UNMARKED receipts (a single
|
|
12
|
+
// payload with at most N entries, last-write-wins idempotent: the
|
|
13
|
+
// phone always sees the latest snapshot). §10.4-safe: only the
|
|
14
|
+
// fields the phone needs to render — receipt_id, created_at, via,
|
|
15
|
+
// agent_id, action_descriptor, response.signal — never `drivers`
|
|
16
|
+
// (local provenance) and never layer content.
|
|
17
|
+
// phone_to_agent — phone deposits a BATCH of `{ receipt_id, outcome }`; this module
|
|
18
|
+
// drains and calls `markOutcome` per mark. Idempotent on the MCP
|
|
19
|
+
// side (re-marking the same outcome is a no-op), so re-draining is
|
|
20
|
+
// safe and the phone need not track per-mark ACKs — the next
|
|
21
|
+
// receipts digest reflects applied marks (`user_action` set).
|
|
22
|
+
//
|
|
23
|
+
// Crypto: fp_sync_key derived from this server's pair_key. Drained by the PHONE, whose
|
|
24
|
+
// multi-client loop tries each verified client's key — so independent pairing is fine
|
|
25
|
+
// here (unlike agent_to_ext, see delegation-sync.js). The relay only ever sees
|
|
26
|
+
// ciphertext. Best-effort and isolated:
|
|
27
|
+
// a deposit/drain failure must NEVER break the tool that triggered it.
|
|
28
|
+
|
|
29
|
+
import { Phaedo, b64uDec } from './lib/phaedo-crypto.js';
|
|
30
|
+
import { readReceipts, markOutcome, USER_ACTIONS } from './receipts.js';
|
|
31
|
+
import { agreementDigest } from './agreement.js';
|
|
32
|
+
|
|
33
|
+
const { Kdf, Envelope } = Phaedo;
|
|
34
|
+
const DEFAULT_RELAY = 'https://phaedo-relay.fly.dev';
|
|
35
|
+
const DIGEST_CAP = 50; // phone shows recent N — enough for the latest sittings, bounded payload
|
|
36
|
+
|
|
37
|
+
export function relayBaseFromEnv(env = process.env) {
|
|
38
|
+
return env.PHAEDO_RELAY || DEFAULT_RELAY;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Strip a receipt down to the §10.4-safe fields the phone renders. NEVER includes
|
|
42
|
+
// `drivers` (local provenance cues that would expose Decision&Risk reasoning to the
|
|
43
|
+
// subject device — fine on this device, but the audit UI doesn't need them and not
|
|
44
|
+
// shipping them keeps the digest small + the boundary tight).
|
|
45
|
+
function digestFields(r) {
|
|
46
|
+
if (!r || typeof r !== 'object') return null;
|
|
47
|
+
return {
|
|
48
|
+
receipt_id: r.receipt_id,
|
|
49
|
+
created_at: r.created_at,
|
|
50
|
+
via: r.via,
|
|
51
|
+
agent_id: r.agent_id || null,
|
|
52
|
+
// The paired client that actually SERVED the call, beside the vendor string it
|
|
53
|
+
// claimed. §10.4-safe (an opaque id the subject's own phone issued, not persona
|
|
54
|
+
// content) and the phone is the one surface that can render it usefully: it holds
|
|
55
|
+
// the client registry, so it can show the friendly NAME where the receipt holds
|
|
56
|
+
// only the id. Without it the Activity screen can only report what the agent said
|
|
57
|
+
// it was — which is precisely the claim §10.5 stopped trusting.
|
|
58
|
+
client_id: r.client_id || null,
|
|
59
|
+
request: {
|
|
60
|
+
consultation_type: r?.request?.consultation_type || null,
|
|
61
|
+
action_descriptor: {
|
|
62
|
+
domain: r?.request?.action_descriptor?.domain ?? null,
|
|
63
|
+
reversible: r?.request?.action_descriptor?.reversible ?? null,
|
|
64
|
+
magnitude: r?.request?.action_descriptor?.magnitude ?? null,
|
|
65
|
+
amount: r?.request?.action_descriptor?.amount ?? null,
|
|
66
|
+
summary: r?.request?.action_descriptor?.summary ?? null,
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
response: {
|
|
70
|
+
signal: r?.response?.signal ?? null,
|
|
71
|
+
deference_level: r?.response?.deference_level ?? null,
|
|
72
|
+
},
|
|
73
|
+
user_action: r.user_action ?? null, // already-marked entries stay visible (phone shows status)
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Pure-crypto: take the local receipts list, build a §10.4-safe digest of the most
|
|
78
|
+
// recent `cap` entries, and seal it under fp_sync_key. Returns `{ envelope }` — the
|
|
79
|
+
// same shape `agent_to_ext` (delegation-sync) uses, so the relay's mailbox accepts it.
|
|
80
|
+
export async function buildReceiptsDeposit(pairKeyB64u, receipts, { cap = DIGEST_CAP, now = () => new Date().toISOString() } = {}) {
|
|
81
|
+
const list = Array.isArray(receipts) ? receipts : [];
|
|
82
|
+
// Most-recent N (receipts are append-only, ordered by creation in the store).
|
|
83
|
+
const head = list.slice(-cap).map(digestFields).filter(Boolean);
|
|
84
|
+
// Agreement rides the SAME digest, but is computed over the WHOLE store, not the
|
|
85
|
+
// capped head — the rate is a 30-day property of the subject's history, and slicing
|
|
86
|
+
// it to the last 50 entries would silently redefine the metric.
|
|
87
|
+
const generatedAt = now();
|
|
88
|
+
const agreementAt = Date.parse(generatedAt);
|
|
89
|
+
const agreement = agreementDigest(list, { now: Number.isFinite(agreementAt) ? agreementAt : Date.now() });
|
|
90
|
+
const pairKey = b64uDec(pairKeyB64u);
|
|
91
|
+
let fpSyncKey;
|
|
92
|
+
try {
|
|
93
|
+
fpSyncKey = await Kdf.deriveFpSyncKey(pairKey);
|
|
94
|
+
} finally {
|
|
95
|
+
pairKey.fill(0);
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
const inner = { kind: 'receipts_digest', data: { receipts: head, agreement, generated_at: generatedAt } };
|
|
99
|
+
const plaintext = new TextEncoder().encode(JSON.stringify(inner));
|
|
100
|
+
const envelope = await Envelope.wrap(plaintext, fpSyncKey, { artifact_kind: 'receipts_digest', created_at: now() });
|
|
101
|
+
plaintext.fill(0);
|
|
102
|
+
return { envelope };
|
|
103
|
+
} finally {
|
|
104
|
+
if (fpSyncKey && fpSyncKey.fill) fpSyncKey.fill(0);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Decrypt a phone-deposited marks payload. Returns `{ marks: [{receipt_id, outcome}, ...] }`
|
|
109
|
+
// or null on a malformed/wrong-key envelope. Pure (no I/O) so it's testable.
|
|
110
|
+
export async function parseMarksDeposit(pairKeyB64u, envelope) {
|
|
111
|
+
if (!envelope) return null;
|
|
112
|
+
const pairKey = b64uDec(pairKeyB64u);
|
|
113
|
+
let fpSyncKey;
|
|
114
|
+
try { fpSyncKey = await Kdf.deriveFpSyncKey(pairKey); }
|
|
115
|
+
finally { pairKey.fill(0); }
|
|
116
|
+
try {
|
|
117
|
+
const plaintext = await Envelope.unwrap(envelope, fpSyncKey);
|
|
118
|
+
let inner;
|
|
119
|
+
try { inner = JSON.parse(new TextDecoder().decode(plaintext)); }
|
|
120
|
+
finally { if (plaintext.fill) plaintext.fill(0); }
|
|
121
|
+
if (!inner || inner.kind !== 'receipt_marks' || !inner.data) return null;
|
|
122
|
+
const arr = Array.isArray(inner.data.marks) ? inner.data.marks : [];
|
|
123
|
+
return {
|
|
124
|
+
marks: arr
|
|
125
|
+
.map((m) => ({
|
|
126
|
+
receipt_id: typeof m?.receipt_id === 'string' ? m.receipt_id : null,
|
|
127
|
+
outcome: typeof m?.outcome === 'string' ? m.outcome : null,
|
|
128
|
+
}))
|
|
129
|
+
.filter((m) => m.receipt_id && USER_ACTIONS.includes(m.outcome)),
|
|
130
|
+
};
|
|
131
|
+
} catch {
|
|
132
|
+
return null;
|
|
133
|
+
} finally {
|
|
134
|
+
if (fpSyncKey && fpSyncKey.fill) fpSyncKey.fill(0);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Deposit a receipts digest to the relay's agent_to_phone slot. Best-effort; mirrors
|
|
139
|
+
// `depositDelegationSuggestions` exactly (same auth, same timeout, same isolation —
|
|
140
|
+
// a failure NEVER throws out into the consult/escalate path that called us).
|
|
141
|
+
export async function depositReceiptsDigest(pairing, receipts, deps = {}) {
|
|
142
|
+
const fetchFn = deps.fetch || globalThis.fetch;
|
|
143
|
+
const list = Array.isArray(receipts) ? receipts : [];
|
|
144
|
+
if (!list.length) return { deposited: false, reason: 'no-receipts' };
|
|
145
|
+
if (!pairing || !pairing.pair_key) return { deposited: false, reason: 'unpaired' };
|
|
146
|
+
if (!pairing.relay_device_id) return { deposited: false, reason: 'no-relay-device-id' };
|
|
147
|
+
try {
|
|
148
|
+
const body = await buildReceiptsDeposit(pairing.pair_key, list, deps);
|
|
149
|
+
const url = `${deps.relayBase || relayBaseFromEnv()}/fp-mailbox/${encodeURIComponent(pairing.relay_device_id)}/agent_to_phone`;
|
|
150
|
+
const authHeader = pairing.relay_device_secret
|
|
151
|
+
? { Authorization: `Bearer ${pairing.relay_device_id}:${pairing.relay_device_secret}` }
|
|
152
|
+
: {};
|
|
153
|
+
const res = await fetchFn(url, {
|
|
154
|
+
method: 'POST',
|
|
155
|
+
headers: { 'Content-Type': 'application/json', ...authHeader },
|
|
156
|
+
body: JSON.stringify(body),
|
|
157
|
+
signal: AbortSignal.timeout(deps.timeoutMs || 8000),
|
|
158
|
+
});
|
|
159
|
+
return { deposited: !!(res && res.ok), status: res && res.status, count: list.length };
|
|
160
|
+
} catch (e) {
|
|
161
|
+
return { deposited: false, reason: String(e && e.message || e) };
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Drain phone-deposited outcome marks from the phone_to_agent slot and apply each via
|
|
166
|
+
// markOutcome (idempotent). Returns `{ applied, skipped }`. Best-effort; a transport
|
|
167
|
+
// failure returns `{ applied: 0, skipped: 0, reason }` instead of throwing.
|
|
168
|
+
export async function drainAndApplyMarks(pairing, stateDir, deps = {}) {
|
|
169
|
+
const fetchFn = deps.fetch || globalThis.fetch;
|
|
170
|
+
if (!pairing || !pairing.pair_key) return { applied: 0, skipped: 0, reason: 'unpaired' };
|
|
171
|
+
if (!pairing.relay_device_id) return { applied: 0, skipped: 0, reason: 'no-relay-device-id' };
|
|
172
|
+
try {
|
|
173
|
+
const url = `${deps.relayBase || relayBaseFromEnv()}/fp-mailbox/${encodeURIComponent(pairing.relay_device_id)}/phone_to_agent`;
|
|
174
|
+
const authHeader = pairing.relay_device_secret
|
|
175
|
+
? { Authorization: `Bearer ${pairing.relay_device_id}:${pairing.relay_device_secret}` }
|
|
176
|
+
: {};
|
|
177
|
+
const res = await fetchFn(url, {
|
|
178
|
+
method: 'GET',
|
|
179
|
+
headers: authHeader,
|
|
180
|
+
signal: AbortSignal.timeout(deps.timeoutMs || 8000),
|
|
181
|
+
});
|
|
182
|
+
if (!res || !res.ok) return { applied: 0, skipped: 0, status: res && res.status };
|
|
183
|
+
const body = await res.json().catch(() => null);
|
|
184
|
+
const parsed = await parseMarksDeposit(pairing.pair_key, body && body.envelope);
|
|
185
|
+
if (!parsed) return { applied: 0, skipped: 0, reason: 'no-marks' };
|
|
186
|
+
let applied = 0, skipped = 0;
|
|
187
|
+
for (const m of parsed.marks) {
|
|
188
|
+
try {
|
|
189
|
+
const r = await markOutcome(stateDir, m.receipt_id, m.outcome);
|
|
190
|
+
if (r.matched) applied++; else skipped++;
|
|
191
|
+
} catch { skipped++; }
|
|
192
|
+
}
|
|
193
|
+
return { applied, skipped, total: parsed.marks.length };
|
|
194
|
+
} catch (e) {
|
|
195
|
+
return { applied: 0, skipped: 0, reason: String(e && e.message || e) };
|
|
196
|
+
}
|
|
197
|
+
}
|
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
|
});
|
|
@@ -230,7 +261,7 @@ if (process.argv[1] && process.argv[1].endsWith('receipts.js')) {
|
|
|
230
261
|
for (const r of rs.slice(-50)) {
|
|
231
262
|
const ad = r.request.action_descriptor;
|
|
232
263
|
console.log(
|
|
233
|
-
`${r.receipt_id.slice(0, 8)} ${r.created_at} ${r.via === 'phaedo_check_authorization' ? '[pre-flight]' : '[consult] '}` +
|
|
264
|
+
`${r.receipt_id.slice(0, 8)} ${r.created_at} ${r.via === 'phaedo_check_authorization' ? '[pre-flight]' : r.agent_id === 'claude-code/ship-hook' ? '[ship-hook] ' : '[consult] '}` +
|
|
234
265
|
` ${r.request.consultation_type} ${ad.domain || '-'}` +
|
|
235
266
|
` → ${r.response.signal} (conf ${r.response.confidence}, def ${r.response.deference_level})` +
|
|
236
267
|
` user_action: ${r.user_action ?? '—'}`
|