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.
Files changed (62) 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 +81 -19
  12. package/consult-policy.js +427 -33
  13. package/consult.js +25 -3
  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 +26 -6
  26. package/pairing.js +5 -1
  27. package/phone-source.js +92 -12
  28. package/projection.js +4 -1
  29. package/pull.js +19 -3
  30. package/receipts-sync.js +197 -0
  31. package/receipts.js +69 -16
  32. package/server.js +305 -15
  33. package/server.json +4 -4
  34. package/signing-audit.js +111 -0
  35. package/signing-request.js +217 -0
  36. package/signing.js +176 -0
  37. package/vendor/authored-source.js +250 -0
  38. package/vendor/capabilities.js +203 -0
  39. package/vendor/capture/capture-core.js +67 -0
  40. package/vendor/capture/detectors.js +287 -0
  41. package/vendor/capture/exchanges.js +77 -0
  42. package/vendor/capture/model-detect.js +195 -0
  43. package/vendor/capture/owner.js +35 -0
  44. package/vendor/capture/package.json +4 -0
  45. package/vendor/capture/source-id.js +56 -0
  46. package/vendor/capture/state.js +148 -0
  47. package/vendor/capture/transcript.js +153 -0
  48. package/vendor/commitments.js +169 -0
  49. package/vendor/context-block.js +251 -18
  50. package/vendor/envelope.js +50 -4
  51. package/vendor/extraction/capture-combine.js +160 -0
  52. package/vendor/extraction/catalog.js +232 -0
  53. package/vendor/extraction/delta-lift.js +109 -0
  54. package/vendor/extraction/fingerprint-update.js +227 -0
  55. package/vendor/extraction/package.json +4 -0
  56. package/vendor/extraction/persist.js +40 -0
  57. package/vendor/extraction/reconcile.js +269 -0
  58. package/vendor/extraction/resolve.js +178 -0
  59. package/vendor/extraction/rule-promotion.js +137 -0
  60. package/vendor/kdf.js +20 -1
  61. package/vendor/protocol.js +7 -0
  62. package/vendored-files.mjs +82 -0
package/lib/domain.js ADDED
@@ -0,0 +1,101 @@
1
+ // §10.2 `action_descriptor.domain` — normalization and the published vocabulary.
2
+ //
3
+ // WHY ANY OF THIS EXISTS. `domain` is a bare `{"type":"string"}` the AGENT picks, and
4
+ // everything downstream keys on it verbatim: §10.5 rule matching, the §10.6 receipt,
5
+ // the delegation tally, §10.7's per-class approval counts. Observed live on the dev
6
+ // machine: `financial` ×16, `finance` ×26, `finance.crypto_trading` ×1 — one concept,
7
+ // three keys, and 7 approvals split 3/4 so neither class reached §10.7's
8
+ // `MIN_APPROVALS = 5`. Analysis: `docs/roadmap/domain-identity-2026-07-22.md`.
9
+ //
10
+ // TWO FUNCTIONS, DELIBERATELY DISTINCT. Conflating them is how an alias table starts
11
+ // granting authority nobody authored.
12
+ //
13
+ // normalizeDomain() lowercase + trim + collapse whitespace. Pure presentation;
14
+ // `"Finance"` and `" finance"` cannot mean different things, so
15
+ // merging them can lose no distinction anyone intended.
16
+ //
17
+ // canonicalDomain() normalizeDomain, then map a published ALIAS onto its canonical
18
+ // term (`financial` → `finance`). This is a SEMANTIC claim, and
19
+ // it is only safe where a wrong merge cannot expand authority.
20
+ //
21
+ // THE SAFETY PROPERTY, stated once and enforced at every call site: a vocabulary alias
22
+ // may widen a GUARDRAIL's reach, never a GRANT's. `canonicalDomain` is therefore used
23
+ // for evidence classification and for matching RESTRICTIVE rules; relaxing rules
24
+ // (`autoproceed`, `bias_bold`) keep matching on `normalizeDomain` alone, exactly as
25
+ // before. So adopting this vocabulary cannot hand any client more autonomy than it
26
+ // already had — it can only make an existing guardrail cover more. If the table below
27
+ // is wrong, the failure is "an agent asks when it needn't", never the reverse.
28
+ //
29
+ // ALIASES ARE SYNONYMS, NEVER GENERALIZATIONS. `financial` → `finance` is a synonym.
30
+ // `crypto_trading` → `finance` would be a child→parent generalization, and it is
31
+ // forbidden: a grant authored for the general class would silently cover a narrower,
32
+ // riskier one the subject never demonstrated. Same defect that made dotted-suffix
33
+ // stripping unsafe (v0.1.55), which is why `investing` is its own term here rather
34
+ // than a `finance` alias, and why `crypto_trading` is absent entirely — an
35
+ // unrecognized domain still works, it just does not merge.
36
+ //
37
+ // PUBLISHED, not private. The vocabulary below is a copy of `spec/domains.json` so the
38
+ // npm package (whose `files` whitelist excludes `spec/`) is self-contained;
39
+ // `mcp/test-domain.mjs` asserts the two cannot drift. A private synonym table would
40
+ // put decision semantics somewhere the subject cannot read and a third-party
41
+ // implementer cannot reproduce.
42
+
43
+ /** Canonical terms → their aliases. Mirror of spec/domains.json `domains`. */
44
+ const VOCABULARY = {
45
+ finance: ['financial', 'finances', 'money', 'billing', 'invoicing', 'expenses', 'accounting'],
46
+ investing: ['investment', 'investments', 'trading', 'brokerage', 'portfolio'],
47
+ legal: ['contracts', 'compliance', 'regulatory'],
48
+ health: ['medical', 'healthcare', 'wellness', 'clinical'],
49
+ scheduling: ['calendar', 'meetings', 'appointments', 'availability', 'booking'],
50
+ communication: ['email', 'outbound email', 'messaging', 'messages', 'comms', 'correspondence'],
51
+ writing: ['drafting', 'editing', 'content', 'copywriting', 'documentation'],
52
+ research: ['analysis', 'reading', 'summarization', 'search'],
53
+ shopping: ['purchasing', 'purchase', 'procurement', 'orders', 'commerce', 'ecommerce'],
54
+ travel: ['trips', 'flights', 'lodging', 'itinerary', 'transportation'],
55
+ engineering: ['code', 'coding', 'software', 'development', 'dev', 'devops', 'infrastructure', 'deployment'],
56
+ data: ['files', 'records', 'storage', 'database', 'export'],
57
+ security: ['access', 'permissions', 'credentials', 'authentication', 'authorization'],
58
+ people: ['hr', 'human resources', 'hiring', 'recruiting', 'personnel', 'staffing'],
59
+ social: ['social media', 'posting', 'publishing', 'community'],
60
+ support: ['customer support', 'customer service', 'helpdesk', 'tickets'],
61
+ admin: ['administration', 'settings', 'accounts', 'subscriptions', 'operations', 'ops'],
62
+ };
63
+
64
+ /** alias → canonical, plus every canonical term mapping to itself. */
65
+ const ALIAS_TO_CANONICAL = (() => {
66
+ const m = new Map();
67
+ for (const [term, aliases] of Object.entries(VOCABULARY)) {
68
+ m.set(term, term);
69
+ for (const a of aliases) m.set(a, term);
70
+ }
71
+ return m;
72
+ })();
73
+
74
+ export const VOCABULARY_TERMS = Object.freeze(Object.keys(VOCABULARY).sort());
75
+ export const VOCABULARY_MAP = VOCABULARY;
76
+
77
+ /**
78
+ * Presentation-only normalization. Returns '' for anything unusable, which every
79
+ * caller already treats as "no domain".
80
+ */
81
+ export function normalizeDomain(raw) {
82
+ if (typeof raw !== 'string') return '';
83
+ return raw.trim().toLowerCase().replace(/\s+/g, ' ');
84
+ }
85
+
86
+ /**
87
+ * Normalize, then resolve a published alias to its canonical term. An unrecognized
88
+ * domain is returned normalized but otherwise untouched — free text stays legal and
89
+ * behaves exactly as it does today. Never throws.
90
+ */
91
+ export function canonicalDomain(raw) {
92
+ const n = normalizeDomain(raw);
93
+ if (!n) return '';
94
+ return ALIAS_TO_CANONICAL.get(n) || n;
95
+ }
96
+
97
+ /** True iff this domain resolves to a published vocabulary term. */
98
+ export function isVocabularyTerm(raw) {
99
+ const n = normalizeDomain(raw);
100
+ return !!n && ALIAS_TO_CANONICAL.has(n);
101
+ }
package/lib/is-main.js ADDED
@@ -0,0 +1,40 @@
1
+ // "Was this module executed directly?" — symlink-safe.
2
+ //
3
+ // The obvious ESM idiom
4
+ //
5
+ // import.meta.url === pathToFileURL(resolve(process.argv[1])).href
6
+ //
7
+ // is WRONG whenever any component of the path is a symlink. Node resolves
8
+ // `import.meta.url` through symlinks (it is the module's real URL), while
9
+ // `resolve(process.argv[1])` normalizes `.`/`..` but does NOT follow links — so
10
+ // the two disagree and the check silently returns false.
11
+ //
12
+ // That is not a hypothetical. `os.tmpdir()` on macOS is `/var/folders/…`, a
13
+ // symlink to `/private/var/folders/…`, which made the SessionEnd capture hook a
14
+ // silent no-op for every macOS spawn from a temp path (caught by
15
+ // test-vendored-capture.mjs, which spawns the hook exactly as Claude Code does).
16
+ // The same trap is live in production for any install reached through a link — a
17
+ // symlinked home directory, a linked global npm root, `/tmp` on macOS.
18
+ //
19
+ // A capture hook that quietly does nothing is the worst failure this codebase
20
+ // has: it exits 0, prints nothing, and loses the session. So compare REAL paths,
21
+ // and fall back to the plain URL comparison only if realpath throws (a deleted or
22
+ // unreadable entry point — in which case "not main" is the safe answer anyway).
23
+
24
+ import { realpathSync } from 'node:fs';
25
+ import { resolve } from 'node:path';
26
+ import { fileURLToPath, pathToFileURL } from 'node:url';
27
+
28
+ // (import.meta.url of the caller, [argv]) -> boolean
29
+ export function isMainModule(moduleUrl, argv = process.argv) {
30
+ const entry = argv && argv[1];
31
+ if (!entry || !moduleUrl) return false;
32
+ const self = fileURLToPath(moduleUrl);
33
+ const target = resolve(entry);
34
+ if (self === target) return true; // fast path, no IO
35
+ try {
36
+ return realpathSync(self) === realpathSync(target);
37
+ } catch {
38
+ return pathToFileURL(target).href === moduleUrl; // best effort
39
+ }
40
+ }
@@ -21,7 +21,7 @@ const ROOT = resolve(HERE, '..', '..'); // repo root — dev
21
21
 
22
22
  // Prefer the bundled copies (self-contained package) over the repo root (dev).
23
23
  // Load order matters: protocol → envelope → kdf → identity.
24
- for (const f of ['protocol.js', 'envelope.js', 'kdf.js', 'identity.js']) {
24
+ for (const f of ['protocol.js', 'envelope.js', 'kdf.js', 'identity.js', 'capabilities.js', 'authored-source.js']) {
25
25
  const vf = resolve(VENDOR, f);
26
26
  require(existsSync(vf) ? vf : resolve(ROOT, f));
27
27
  }
@@ -31,6 +31,22 @@ if (!Phaedo?.Envelope || !Phaedo?.Kdf || !Phaedo?.Identity || !Phaedo?.Protocol)
31
31
  throw new Error('Phaedo crypto modules did not load (Protocol/Envelope/Kdf/Identity).');
32
32
  }
33
33
 
34
+ // The §5 step-3 upgrade gate's vocabulary. The agent READS authored content, so it has to be
35
+ // able to say what it can do — otherwise the gate stays shut on its silence forever, which is
36
+ // correct but useless. Loaded here rather than imported directly so the published package picks
37
+ // up the vendored copy exactly like the crypto modules do.
38
+ export const Capabilities = globalThis.PhaedoCapabilities;
39
+ if (!Capabilities?.advertise) {
40
+ throw new Error('Phaedo capabilities module did not load.');
41
+ }
42
+
43
+ // The step-3 read path's §5.4 rule. Used at the vd boundary so every authored-content reader
44
+ // downstream is §5-canonical without threading the configuration through its signature.
45
+ export const AuthoredSource = globalThis.PhaedoAuthoredSource;
46
+ if (!AuthoredSource?.configGoverns) {
47
+ throw new Error('Phaedo authored-source module did not load.');
48
+ }
49
+
34
50
  // base64url <-> Uint8Array (Node-native), matching the extension's wire encoding.
35
51
  export const b64uEnc = (u8) => Buffer.from(u8).toString('base64url');
36
52
  export const b64uDec = (s) => new Uint8Array(Buffer.from(s, 'base64url'));
package/package.json CHANGED
@@ -1,15 +1,15 @@
1
1
  {
2
2
  "name": "phaedo-mcp",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "private": false,
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
7
- "description": "Phaedo MCP server — inject your cognitive fingerprint into any MCP client, and consult your decision pattern at agent decision points (docs/protocol/mcp-binding.md).",
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": {
11
11
  "type": "git",
12
- "url": "git+https://github.com/phaedo-labs/phaedo.git",
12
+ "url": "git+https://github.com/phaedo-labs/phaedo-mcp.git",
13
13
  "directory": "mcp"
14
14
  },
15
15
  "keywords": [
@@ -32,16 +32,30 @@
32
32
  "consult.js",
33
33
  "consult-core.js",
34
34
  "consult-policy.js",
35
+ "action-binding.js",
36
+ "autonomy-promotion.js",
37
+ "ap2.js",
35
38
  "receipts.js",
39
+ "receipts-sync.js",
36
40
  "agreement.js",
37
41
  "calibration.js",
42
+ "health.js",
38
43
  "phone-source.js",
44
+ "delegation-sync.js",
45
+ "escalation.js",
46
+ "signing.js",
47
+ "signing-request.js",
48
+ "signing-audit.js",
49
+ "capture-sync.js",
50
+ "capture-local.js",
51
+ "capture-a11y.js",
39
52
  "cache.js",
40
53
  "pairing.js",
41
54
  "pair.js",
42
55
  "pull.js",
43
56
  "install.mjs",
44
57
  "build-package.mjs",
58
+ "vendored-files.mjs",
45
59
  "vendor/",
46
60
  "lib/",
47
61
  "hooks/",
@@ -50,7 +64,9 @@
50
64
  "sample-fingerprint.json",
51
65
  "pairing.sample.json",
52
66
  "consult-policies.sample.json",
53
- "README.md"
67
+ "escalation-policy.sample.json",
68
+ "README.md",
69
+ "NOTICE"
54
70
  ],
55
71
  "scripts": {
56
72
  "start": "node server.js",
@@ -60,10 +76,11 @@
60
76
  "setup": "node install.mjs",
61
77
  "setup:pair": "node install.mjs --pair",
62
78
  "setup:code-hook": "node install.mjs --code-hook",
79
+ "setup:capture-model": "node install.mjs --capture-model",
63
80
  "unsetup": "node install.mjs --uninstall",
64
81
  "pair": "node pair.js",
65
82
  "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",
83
+ "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-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-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
84
  "receipts": "node receipts.js list",
68
85
  "agreement": "node agreement.js",
69
86
  "calibration": "node calibration.js",
@@ -80,7 +97,10 @@
80
97
  "smoke:escalation": "node test-escalation.mjs",
81
98
  "smoke:receipts": "node test-receipts.mjs",
82
99
  "smoke:agreement": "node test-agreement.mjs",
83
- "smoke:calibration": "node test-calibration.mjs"
100
+ "smoke:calibration": "node test-calibration.mjs",
101
+ "smoke:binding": "node test-action-binding.mjs",
102
+ "smoke:autonomy": "node test-autonomy-promotion.mjs",
103
+ "smoke:ap2": "node test-ap2.mjs"
84
104
  },
85
105
  "dependencies": {
86
106
  "@modelcontextprotocol/sdk": "^1.0.0",
package/pairing.js CHANGED
@@ -124,6 +124,7 @@ export async function pairAtEndpoint(endpoint, { code, relayDeviceId = null, log
124
124
  // Long-term ECDH → pair_key; SAS (the MITM anchor) over both pubkeys.
125
125
  const shared = await Identity.deriveSharedWithPeer(vaultPub);
126
126
  const pairKey = await Kdf.derivePairKey(shared, body.client_id);
127
+ shared.fill(0); // raw ECDH secret consumed — zero it (matches phone-source.js key hygiene)
127
128
  const sas = Kdf.formatSAS(await Kdf.deriveSAS(extPub, vaultPub));
128
129
 
129
130
  log('');
@@ -148,11 +149,14 @@ export async function pairAtEndpoint(endpoint, { code, relayDeviceId = null, log
148
149
  // escalation/fp-mailbox deposits get 401.
149
150
  const relaySecret = body.relay_device_secret || null;
150
151
 
152
+ const pairKeyEnc = b64uEnc(pairKey);
153
+ pairKey.fill(0); // persisted as base64url below — zero the long-lived key buffer
154
+
151
155
  return {
152
156
  endpoint,
153
157
  client_id: body.client_id,
154
158
  client_secret: body.client_secret,
155
- pair_key: b64uEnc(pairKey),
159
+ pair_key: pairKeyEnc,
156
160
  vault_pub: body.vault_pub,
157
161
  sas,
158
162
  ...(relayId ? { relay_device_id: relayId } : {}),
package/phone-source.js CHANGED
@@ -1,7 +1,14 @@
1
- // MCP Phase 1b — real key boundary (Option B: share the extension's pairing).
1
+ // MCP Phase 1b — real key boundary.
2
2
  //
3
- // Given a pairing record that already holds `pair_key` (exported from the paired
4
- // extension), this runs the spec-faithful authorize → pull → decrypt flow — a
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,21 +23,52 @@
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
- // ── Pairing record (Option B: exported from the extension's phaedo_local_pairing)
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
31
57
  ? resolve(process.cwd(), process.env.PHAEDO_PAIRING)
32
58
  : resolve(baseDir, 'pairing.json');
33
- const rec = JSON.parse(await readFile(path, 'utf8'));
59
+ let raw;
60
+ try {
61
+ raw = await readFile(path, 'utf8');
62
+ } catch (e) {
63
+ // No pairing file = UNPAIRED (a valid local-only state), not an error. Return
64
+ // null so callers that pass the record to depositEscalation / pollEscalation /
65
+ // the best-effort deposit channels get a graceful "unpaired" instead of an
66
+ // ENOENT throw. (hasPairingRecord() gates the fingerprint-pull path, so it only
67
+ // calls this when the file exists.)
68
+ if (e && e.code === 'ENOENT') return null;
69
+ throw e;
70
+ }
71
+ const rec = JSON.parse(raw);
34
72
  for (const k of ['endpoint', 'client_id', 'client_secret', 'pair_key']) {
35
73
  if (!rec[k]) throw new Error(`Pairing record at ${path} is missing "${k}".`);
36
74
  }
@@ -43,6 +81,30 @@ export function hasPairingRecord(baseDir) {
43
81
  return existsSync(resolve(baseDir, 'pairing.json'));
44
82
  }
45
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
+
46
108
  // In-memory session cache, keyed by endpoint. Holds session_key (never persisted).
47
109
  const _sessions = new Map();
48
110
 
@@ -62,8 +124,14 @@ async function authorize(rec, deps) {
62
124
  scopes: ['profile:full'],
63
125
  eph_ext_pub: b64uEnc(eph.publicKey),
64
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'),
65
133
  }),
66
- signal: AbortSignal.timeout(TIMEOUT_MS),
134
+ signal: AbortSignal.timeout(APPROVE_TIMEOUT_MS), // waits on a human, not a machine
67
135
  });
68
136
  if (!res.ok) throw new PhoneError(`authorize failed (${res.status})`, res.status);
69
137
  const body = await res.json();
@@ -88,12 +156,24 @@ async function getSession(rec, deps) {
88
156
  }
89
157
 
90
158
  // Map the §9-profile inner plaintext to the vault payload shape the projection
91
- // expects. Profile inner is { data: <fingerprint>, metrics: {signals, ling,
92
- // sessions}, scopes } (mobile localServer handleProfile + popup restoreMetrics).
93
- function toVaultPayload(inner) {
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) {
94
166
  const m = inner.metrics || {};
95
167
  return {
96
168
  phaedo_fingerprint: inner.data,
169
+ ...(inner.user_configuration && typeof inner.user_configuration === 'object'
170
+ ? { phaedo_user_configuration: inner.user_configuration } : {}),
171
+ // §8.3 signing public key — a sibling of the fingerprint, carried through so a
172
+ // consumer can VERIFY a signature the phone produced (e.g. an AP2 mandate) against a
173
+ // key learned over the trusted pairing channel, not one asserted in the signed object
174
+ // itself. A plain non-empty string or nothing; never trusted from inside `data`.
175
+ ...(typeof inner.signing_public_key === 'string' && inner.signing_public_key
176
+ ? { phaedo_signing_public_key: inner.signing_public_key } : {}),
97
177
  phaedo_behavioral_signals: m.signals || {},
98
178
  phaedo_linguistic_profile: m.ling || {},
99
179
  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
- const projection = PhaedoContext.renderContextBlock(selectedVd);
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
- `✓ Read your profile from the phone — fingerprint ${fp.fingerprint_id || '(no id)'}, ${layers} layer(s).\n`
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(0);
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`);