phaedo-mcp 0.1.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/LICENSE +202 -0
- package/README.md +301 -0
- package/agreement.js +95 -0
- package/build-package.mjs +22 -0
- package/cache.js +76 -0
- package/consult-core.js +439 -0
- package/consult-policies.sample.json +36 -0
- package/consult-policy.js +136 -0
- package/consult.js +118 -0
- package/errors.js +23 -0
- package/fingerprint-source.js +71 -0
- package/hooks/session-start.mjs +48 -0
- package/install.mjs +368 -0
- package/installers/linux/install-phaedo-mcp.sh +32 -0
- package/installers/macos/Install Phaedo MCP.command +37 -0
- package/installers/windows/Install Phaedo MCP.bat +39 -0
- package/lib/node-vault.js +34 -0
- package/lib/phaedo-crypto.js +36 -0
- package/package.json +49 -0
- package/pair.js +29 -0
- package/pairing.js +184 -0
- package/pairing.sample.json +8 -0
- package/phone-source.js +141 -0
- package/policy-editor.html +146 -0
- package/projection.js +115 -0
- package/pull.js +26 -0
- package/receipts.js +211 -0
- package/sample-fingerprint.json +107 -0
- package/server.js +262 -0
- package/server.json +19 -0
- package/vendor/context-block.js +599 -0
- package/vendor/envelope.js +130 -0
- package/vendor/identity.js +122 -0
- package/vendor/kdf.js +114 -0
- package/vendor/protocol.js +56 -0
package/pairing.js
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// MCP server self-pairing (P3, plan: mcp-revocable-approval-plan.md).
|
|
2
|
+
//
|
|
3
|
+
// The server is a real, independent client: its own long-term X25519 identity,
|
|
4
|
+
// its own SAS-confirmed pairing with the phone vault, listed + revocable on the
|
|
5
|
+
// phone (spec §8.4 v0.1.6 multi-client). This obviates the manual pairing-record
|
|
6
|
+
// export — `phaedo-mcp pair` produces `pairing.json` itself.
|
|
7
|
+
//
|
|
8
|
+
// Direct port of the extension's popup.js autoPair: LAN auto-discovery first
|
|
9
|
+
// (same Wi-Fi → no code typing, just the SAS confirm on the phone), else the
|
|
10
|
+
// relay typed-code rendezvous (D3: LAN + relay). The artifact is never touched
|
|
11
|
+
// here — only the pairing handshake; the fingerprint is pulled later by
|
|
12
|
+
// phone-source.js using the record this writes.
|
|
13
|
+
|
|
14
|
+
import { writeFileSync } from 'fs';
|
|
15
|
+
import { resolve } from 'path';
|
|
16
|
+
import { networkInterfaces } from 'os';
|
|
17
|
+
import { Phaedo, b64uEnc, b64uDec } from './lib/phaedo-crypto.js';
|
|
18
|
+
import { installNodeVault } from './lib/node-vault.js';
|
|
19
|
+
|
|
20
|
+
const { Identity, Kdf, Protocol } = Phaedo;
|
|
21
|
+
const RELAY_BASE = 'https://phaedo-relay.fly.dev';
|
|
22
|
+
const API_PORT = 7432;
|
|
23
|
+
const PUBKEY_SIZE = Protocol.SIZES.X25519_PUBKEY;
|
|
24
|
+
// Crockford base32 (no I/L/O/U — unambiguous when typed), matching the extension.
|
|
25
|
+
const ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
|
|
26
|
+
|
|
27
|
+
export function generatePairingCode() {
|
|
28
|
+
const bytes = crypto.getRandomValues(new Uint8Array(8));
|
|
29
|
+
let s = '';
|
|
30
|
+
for (let i = 0; i < 8; i++) s += ALPHABET[bytes[i] & 31];
|
|
31
|
+
return s;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function formatCode(code) {
|
|
35
|
+
return `${code.slice(0, 4)}-${code.slice(4)}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function localSubnets() {
|
|
39
|
+
const out = new Set();
|
|
40
|
+
for (const list of Object.values(networkInterfaces())) {
|
|
41
|
+
for (const ni of list || []) {
|
|
42
|
+
if (ni.family === 'IPv4' && !ni.internal) out.add(ni.address.split('.').slice(0, 3).join('.'));
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return [...out];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Scan the local /24(s) for a phone answering /v1/health on :7432.
|
|
49
|
+
export async function discoverPhone() {
|
|
50
|
+
for (const subnet of localSubnets()) {
|
|
51
|
+
const ips = Array.from({ length: 254 }, (_, i) => `${subnet}.${i + 1}`);
|
|
52
|
+
try {
|
|
53
|
+
return await Promise.any(ips.map(ip =>
|
|
54
|
+
fetch(`http://${ip}:${API_PORT}/v1/health`, { signal: AbortSignal.timeout(800) })
|
|
55
|
+
.then(r => (r.ok ? ip : Promise.reject(new Error('no'))))
|
|
56
|
+
.catch(() => Promise.reject(new Error('no')))
|
|
57
|
+
));
|
|
58
|
+
} catch { /* no phone on this subnet — try the next */ }
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function fetchRelayOffer(code, timeoutMs = 120000) {
|
|
64
|
+
const deadline = Date.now() + timeoutMs;
|
|
65
|
+
while (Date.now() < deadline) {
|
|
66
|
+
try {
|
|
67
|
+
const res = await fetch(`${RELAY_BASE}/pair-offer/${encodeURIComponent(code)}`, { signal: AbortSignal.timeout(5000) });
|
|
68
|
+
if (res.ok) {
|
|
69
|
+
const b = await res.json().catch(() => ({}));
|
|
70
|
+
if (b.relay_device_id) return b;
|
|
71
|
+
}
|
|
72
|
+
} catch { /* keep polling */ }
|
|
73
|
+
await new Promise(r => setTimeout(r, 3000));
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function pollPairVerification(endpoint, client_id, client_secret, timeoutMs = 120000) {
|
|
79
|
+
const deadline = Date.now() + timeoutMs;
|
|
80
|
+
while (Date.now() < deadline) {
|
|
81
|
+
try {
|
|
82
|
+
const res = await fetch(`${endpoint}/v1/pair-status`, {
|
|
83
|
+
method: 'POST',
|
|
84
|
+
headers: { 'content-type': 'application/json' },
|
|
85
|
+
body: JSON.stringify({ client_id, client_secret }),
|
|
86
|
+
signal: AbortSignal.timeout(4000),
|
|
87
|
+
});
|
|
88
|
+
if (res.status === 401) return 'denied';
|
|
89
|
+
if (res.ok) {
|
|
90
|
+
const b = await res.json().catch(() => ({}));
|
|
91
|
+
if (b.denied) return 'denied';
|
|
92
|
+
if (b.verified) return 'verified';
|
|
93
|
+
}
|
|
94
|
+
} catch { /* phone briefly unreachable — keep polling */ }
|
|
95
|
+
await new Promise(r => setTimeout(r, 1500));
|
|
96
|
+
}
|
|
97
|
+
return 'timeout';
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Core handshake against a resolved endpoint. Returns the pairing record (the
|
|
101
|
+
// caller persists it). Exported so tests can drive it against a mock phone.
|
|
102
|
+
export async function pairAtEndpoint(endpoint, { code, relayDeviceId = null, log = () => {} } = {}) {
|
|
103
|
+
const extPub = await Identity.getPublicKey();
|
|
104
|
+
const extPubB64u = b64uEnc(extPub);
|
|
105
|
+
|
|
106
|
+
const res = await fetch(`${endpoint}/v1/pair`, {
|
|
107
|
+
method: 'POST',
|
|
108
|
+
headers: { 'content-type': 'application/json' },
|
|
109
|
+
body: JSON.stringify({
|
|
110
|
+
pairing_token: code,
|
|
111
|
+
client_name: 'Phaedo MCP Server',
|
|
112
|
+
client_type: 'mcp',
|
|
113
|
+
ext_pub: extPubB64u,
|
|
114
|
+
}),
|
|
115
|
+
signal: AbortSignal.timeout(32000),
|
|
116
|
+
});
|
|
117
|
+
if (!res.ok) throw new Error(`pair request failed (HTTP ${res.status})`);
|
|
118
|
+
const body = await res.json();
|
|
119
|
+
if (!body.vault_pub) throw new Error('pair response missing vault_pub');
|
|
120
|
+
|
|
121
|
+
const vaultPub = b64uDec(body.vault_pub);
|
|
122
|
+
if (vaultPub.length !== PUBKEY_SIZE) throw new Error(`vault_pub wrong size (${vaultPub.length})`);
|
|
123
|
+
|
|
124
|
+
// Long-term ECDH → pair_key; SAS (the MITM anchor) over both pubkeys.
|
|
125
|
+
const shared = await Identity.deriveSharedWithPeer(vaultPub);
|
|
126
|
+
const pairKey = await Kdf.derivePairKey(shared, body.client_id);
|
|
127
|
+
const sas = Kdf.formatSAS(await Kdf.deriveSAS(extPub, vaultPub));
|
|
128
|
+
|
|
129
|
+
log('');
|
|
130
|
+
log(' Compare this code with the one on your phone:');
|
|
131
|
+
log('');
|
|
132
|
+
log(` ${sas}`);
|
|
133
|
+
log('');
|
|
134
|
+
log(' If they match, tap "Codes match" on your phone.');
|
|
135
|
+
|
|
136
|
+
const outcome = await pollPairVerification(endpoint, body.client_id, body.client_secret);
|
|
137
|
+
if (outcome === 'denied') throw new Error('pairing was denied on the phone');
|
|
138
|
+
if (outcome === 'timeout') throw new Error('timed out waiting for phone confirmation');
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
endpoint,
|
|
142
|
+
client_id: body.client_id,
|
|
143
|
+
client_secret: body.client_secret,
|
|
144
|
+
pair_key: b64uEnc(pairKey),
|
|
145
|
+
vault_pub: body.vault_pub,
|
|
146
|
+
sas,
|
|
147
|
+
...(relayDeviceId ? { relay_device_id: relayDeviceId } : {}),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Full flow: ensure identity → resolve endpoint (LAN → relay) → pair → persist.
|
|
152
|
+
export async function pair({ identityPath, pairingPath, log = console.log } = {}) {
|
|
153
|
+
installNodeVault(identityPath);
|
|
154
|
+
const code = generatePairingCode();
|
|
155
|
+
|
|
156
|
+
log('Looking for your phone on the local network…');
|
|
157
|
+
const ip = await discoverPhone();
|
|
158
|
+
|
|
159
|
+
let record;
|
|
160
|
+
if (ip) {
|
|
161
|
+
log(`Found your phone at ${ip}. Approve "Phaedo MCP Server" on your phone.`);
|
|
162
|
+
record = await pairAtEndpoint(`http://${ip}:${API_PORT}`, { code, log });
|
|
163
|
+
} else {
|
|
164
|
+
log('');
|
|
165
|
+
log("Couldn't find your phone on this network — pairing over the relay.");
|
|
166
|
+
log('');
|
|
167
|
+
log(` On your phone: Pair with code → ${formatCode(code)}`);
|
|
168
|
+
log('');
|
|
169
|
+
log('Waiting for you to enter the code on your phone…');
|
|
170
|
+
const offer = await fetchRelayOffer(code);
|
|
171
|
+
if (!offer) throw new Error('timed out waiting for the phone to enter the code');
|
|
172
|
+
record = await pairAtEndpoint(`${RELAY_BASE}/relay/${offer.relay_device_id}`, {
|
|
173
|
+
code, relayDeviceId: offer.relay_device_id, log,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// sas is display-only (the MITM compare already happened) — phone-source.js
|
|
178
|
+
// doesn't need it; persist the record fields it reads.
|
|
179
|
+
const { sas, ...persist } = record;
|
|
180
|
+
writeFileSync(resolve(pairingPath), JSON.stringify(persist, null, 2), { mode: 0o600 });
|
|
181
|
+
log('');
|
|
182
|
+
log(`✓ Paired. Wrote ${pairingPath}`);
|
|
183
|
+
return record;
|
|
184
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": "Phase 1b pairing record (Option B: shared with the extension). Copy to mcp/pairing.json (gitignored) or point PHAEDO_PAIRING at it. These are the fields from the extension's phaedo_local_pairing. pair_key is a long-term secret — never commit a real one.",
|
|
3
|
+
"endpoint": "http://192.168.1.50:7432",
|
|
4
|
+
"client_id": "REPLACE_WITH_EXTENSION_client_id",
|
|
5
|
+
"client_secret": "REPLACE_WITH_EXTENSION_client_secret",
|
|
6
|
+
"pair_key": "REPLACE_WITH_EXTENSION_pair_key_base64url_32_bytes",
|
|
7
|
+
"vault_pub": "REPLACE_WITH_phone_vault_pub_base64url"
|
|
8
|
+
}
|
package/phone-source.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// MCP Phase 1b — real key boundary (Option B: share the extension's pairing).
|
|
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
|
|
5
|
+
// direct port of popup.js reAuthorize (:745) + silentSync (:883):
|
|
6
|
+
//
|
|
7
|
+
// authorize: fresh ephemeral X25519, HMAC-bound to pair_key, POST /v1/authorize,
|
|
8
|
+
// verify the vault's reply MAC, ECDH → HKDF session_key
|
|
9
|
+
// pull: GET /v1/profile (Bearer session_token) → Envelope.unwrap → fingerprint
|
|
10
|
+
//
|
|
11
|
+
// No long-term private key and no phaedoVault are needed (pair_key suffices), so
|
|
12
|
+
// nothing browser-only is touched. The decrypted fingerprint is returned to the
|
|
13
|
+
// caller and never written to disk (the 1b at-rest cache is a later increment).
|
|
14
|
+
//
|
|
15
|
+
// Session is cached in memory and re-authorized on expiry — fully autonomous
|
|
16
|
+
// after the one-time pairing.
|
|
17
|
+
|
|
18
|
+
import { readFile } from 'fs/promises';
|
|
19
|
+
import { existsSync } from 'fs';
|
|
20
|
+
import { resolve } from 'path';
|
|
21
|
+
import { Phaedo, b64uEnc, b64uDec } from './lib/phaedo-crypto.js';
|
|
22
|
+
|
|
23
|
+
const { Identity, Kdf, Envelope } = Phaedo;
|
|
24
|
+
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
|
+
|
|
27
|
+
// ── Pairing record (Option B: exported from the extension's phaedo_local_pairing)
|
|
28
|
+
// { endpoint, client_id, client_secret, pair_key (b64url 32B), vault_pub, ... }
|
|
29
|
+
export async function loadPairingRecord(baseDir) {
|
|
30
|
+
const path = process.env.PHAEDO_PAIRING
|
|
31
|
+
? resolve(process.cwd(), process.env.PHAEDO_PAIRING)
|
|
32
|
+
: resolve(baseDir, 'pairing.json');
|
|
33
|
+
const rec = JSON.parse(await readFile(path, 'utf8'));
|
|
34
|
+
for (const k of ['endpoint', 'client_id', 'client_secret', 'pair_key']) {
|
|
35
|
+
if (!rec[k]) throw new Error(`Pairing record at ${path} is missing "${k}".`);
|
|
36
|
+
}
|
|
37
|
+
return rec;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// True iff a pairing record is configured (→ use the phone source over local JSON).
|
|
41
|
+
export function hasPairingRecord(baseDir) {
|
|
42
|
+
if (process.env.PHAEDO_PAIRING) return true;
|
|
43
|
+
return existsSync(resolve(baseDir, 'pairing.json'));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// In-memory session cache, keyed by endpoint. Holds session_key (never persisted).
|
|
47
|
+
const _sessions = new Map();
|
|
48
|
+
|
|
49
|
+
async function authorize(rec, deps) {
|
|
50
|
+
const { fetch, now } = deps;
|
|
51
|
+
const pairKey = b64uDec(rec.pair_key);
|
|
52
|
+
|
|
53
|
+
const eph = await Identity.generateEphemeral(); // {publicKey, privateKey}
|
|
54
|
+
const extMac = await Kdf.authEphemeral(pairKey, 'ext', eph.publicKey);
|
|
55
|
+
|
|
56
|
+
const res = await fetch(`${rec.endpoint}/v1/authorize`, {
|
|
57
|
+
method: 'POST',
|
|
58
|
+
headers: { 'content-type': 'application/json' },
|
|
59
|
+
body: JSON.stringify({
|
|
60
|
+
client_id: rec.client_id,
|
|
61
|
+
client_secret: rec.client_secret,
|
|
62
|
+
scopes: ['profile:full'],
|
|
63
|
+
eph_ext_pub: b64uEnc(eph.publicKey),
|
|
64
|
+
eph_mac: b64uEnc(extMac),
|
|
65
|
+
}),
|
|
66
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
67
|
+
});
|
|
68
|
+
if (!res.ok) throw new PhoneError(`authorize failed (${res.status})`, res.status);
|
|
69
|
+
const body = await res.json();
|
|
70
|
+
|
|
71
|
+
const ephVaultPub = b64uDec(body.eph_vault_pub);
|
|
72
|
+
const macOk = await Kdf.verifyAuthEphemeral(pairKey, 'vault', ephVaultPub, b64uDec(body.eph_mac));
|
|
73
|
+
if (!macOk) throw new PhoneError('vault eph_mac verification failed (possible MITM)', 0);
|
|
74
|
+
|
|
75
|
+
const shared = await Identity.deriveEphemeralShared(eph.privateKey, ephVaultPub);
|
|
76
|
+
const sessionKey = await Kdf.deriveSessionKey(shared, body.session_token);
|
|
77
|
+
shared.fill(0);
|
|
78
|
+
|
|
79
|
+
const session = { token: body.session_token, sessionKey, expiresAt: Date.parse(body.expires_at) || (now() + 3600000) };
|
|
80
|
+
_sessions.set(rec.endpoint, session);
|
|
81
|
+
return session;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function getSession(rec, deps) {
|
|
85
|
+
const cached = _sessions.get(rec.endpoint);
|
|
86
|
+
if (cached && deps.now() < cached.expiresAt - EXPIRY_SKEW_MS) return cached;
|
|
87
|
+
return authorize(rec, deps);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// 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) {
|
|
94
|
+
const m = inner.metrics || {};
|
|
95
|
+
return {
|
|
96
|
+
phaedo_fingerprint: inner.data,
|
|
97
|
+
phaedo_behavioral_signals: m.signals || {},
|
|
98
|
+
phaedo_linguistic_profile: m.ling || {},
|
|
99
|
+
phaedo_session_metrics: m.sessions || {},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function pull(rec, session, deps) {
|
|
104
|
+
const res = await deps.fetch(`${rec.endpoint}/v1/profile`, {
|
|
105
|
+
headers: { authorization: `Bearer ${session.token}` },
|
|
106
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
107
|
+
});
|
|
108
|
+
if (res.status === 401) throw new PhoneError('session rejected', 401);
|
|
109
|
+
if (!res.ok) throw new PhoneError(`profile failed (${res.status})`, res.status);
|
|
110
|
+
const body = await res.json();
|
|
111
|
+
const plaintext = await Envelope.unwrap(body.envelope, session.sessionKey);
|
|
112
|
+
const inner = JSON.parse(new TextDecoder().decode(plaintext));
|
|
113
|
+
plaintext.fill(0);
|
|
114
|
+
if (!inner || !inner.data) throw new PhoneError('profile envelope missing data', 0);
|
|
115
|
+
return toVaultPayload(inner);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export class PhoneError extends Error {
|
|
119
|
+
constructor(message, status) { super(message); this.status = status; }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Pull + decrypt the live fingerprint from the phone. Re-authorizes once if the
|
|
123
|
+
// cached session is rejected (e.g. the phone restarted). `deps` is injectable for
|
|
124
|
+
// tests: { fetch, now }.
|
|
125
|
+
export async function loadFingerprintFromPhone(rec, deps = {}) {
|
|
126
|
+
const d = { fetch: deps.fetch || globalThis.fetch, now: deps.now || Date.now };
|
|
127
|
+
let session = await getSession(rec, d);
|
|
128
|
+
try {
|
|
129
|
+
return await pull(rec, session, d);
|
|
130
|
+
} catch (err) {
|
|
131
|
+
if (err instanceof PhoneError && err.status === 401) {
|
|
132
|
+
_sessions.delete(rec.endpoint); // stale session → re-authorize once
|
|
133
|
+
session = await authorize(rec, d);
|
|
134
|
+
return pull(rec, session, d);
|
|
135
|
+
}
|
|
136
|
+
throw err;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// test/maintenance helper
|
|
141
|
+
export function _clearSessions() { _sessions.clear(); }
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>Phaedo — Consultation Deference Policies</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root { --ink:#1a1a1a; --mut:#666; --line:#e3e3e3; --acc:#2b6cb0; --bg:#fafafa; --warn:#b45309; }
|
|
9
|
+
* { box-sizing: border-box; }
|
|
10
|
+
body { font: 15px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; color: var(--ink); margin: 0; background: var(--bg); }
|
|
11
|
+
.wrap { max-width: 860px; margin: 0 auto; padding: 28px 20px 80px; }
|
|
12
|
+
h1 { font-size: 22px; margin: 0 0 4px; }
|
|
13
|
+
.sub { color: var(--mut); margin: 0 0 20px; }
|
|
14
|
+
.card { background:#fff; border:1px solid var(--line); border-radius:10px; padding:16px 18px; margin-bottom:16px; }
|
|
15
|
+
label { display:block; font-weight:600; font-size:13px; margin:10px 0 4px; }
|
|
16
|
+
.hint { color:var(--mut); font-weight:400; font-size:12px; }
|
|
17
|
+
input[type=text], select { width:100%; padding:8px 10px; border:1px solid var(--line); border-radius:7px; font-size:14px; background:#fff; }
|
|
18
|
+
.row { display:grid; grid-template-columns:1fr 1fr 1fr; gap:12px; }
|
|
19
|
+
.row2 { display:grid; grid-template-columns:1fr 1fr; gap:12px; }
|
|
20
|
+
button { font:inherit; cursor:pointer; border-radius:7px; border:1px solid var(--line); background:#fff; padding:8px 14px; }
|
|
21
|
+
button.primary { background:var(--acc); color:#fff; border-color:var(--acc); font-weight:600; }
|
|
22
|
+
button.danger { color:#b00; border-color:#f0caca; }
|
|
23
|
+
.rules { list-style:none; padding:0; margin:0; }
|
|
24
|
+
.rule { border:1px solid var(--line); border-radius:8px; padding:10px 12px; margin-bottom:8px; display:flex; justify-content:space-between; gap:12px; align-items:flex-start; }
|
|
25
|
+
.rule .desc { font-size:14px; }
|
|
26
|
+
.rule .meta { color:var(--mut); font-size:12px; margin-top:2px; }
|
|
27
|
+
.badge { display:inline-block; font-size:11px; font-weight:700; padding:1px 7px; border-radius:20px; background:#eef2f7; color:var(--acc); }
|
|
28
|
+
.badge.force { background:#fdecec; color:#b00; }
|
|
29
|
+
pre { background:#0f1419; color:#d7e0e8; padding:14px; border-radius:8px; overflow:auto; font-size:12.5px; }
|
|
30
|
+
.actions { display:flex; gap:10px; flex-wrap:wrap; margin-top:10px; }
|
|
31
|
+
.ok { color:#1a7a3a; font-weight:600; }
|
|
32
|
+
code { background:#eef2f7; padding:1px 5px; border-radius:4px; font-size:12.5px; }
|
|
33
|
+
.steps { background:#fff8ec; border:1px solid #f0e0c0; border-radius:8px; padding:12px 16px; font-size:13.5px; }
|
|
34
|
+
</style>
|
|
35
|
+
</head>
|
|
36
|
+
<body>
|
|
37
|
+
<div class="wrap">
|
|
38
|
+
<h1>Phaedo — Consultation deference policies</h1>
|
|
39
|
+
<p class="sub">Author standing rules that <strong>override</strong> what an agent infers about how you'd decide. e.g. <em>always escalate irreversible spend/legal</em>, <em>auto-proceed low-stakes writing</em>. Nothing here leaves this page until you save the file.</p>
|
|
40
|
+
|
|
41
|
+
<div class="card">
|
|
42
|
+
<strong>Add a rule</strong>
|
|
43
|
+
<label>When the action's domain contains any of <span class="hint">(comma-separated keywords; leave blank to match any domain)</span></label>
|
|
44
|
+
<input type="text" id="domains" placeholder="financial, payment, legal" />
|
|
45
|
+
<div class="row">
|
|
46
|
+
<div><label>Magnitude min</label><select id="magmin"><option value="">any</option><option>low</option><option>medium</option><option>high</option></select></div>
|
|
47
|
+
<div><label>Magnitude max</label><select id="magmax"><option value="">any</option><option>low</option><option>medium</option><option>high</option></select></div>
|
|
48
|
+
<div><label>Reversible?</label><select id="rev"><option value="">any</option><option value="true">reversible only</option><option value="false">irreversible only</option></select></div>
|
|
49
|
+
</div>
|
|
50
|
+
<div class="row2">
|
|
51
|
+
<div>
|
|
52
|
+
<label>Then…</label>
|
|
53
|
+
<select id="effect">
|
|
54
|
+
<option value="escalate">escalate — surface to a human</option>
|
|
55
|
+
<option value="decline">decline — refuse the action</option>
|
|
56
|
+
<option value="autoproceed">autoproceed — pre-approve</option>
|
|
57
|
+
<option value="require_human">require_human — (= escalate)</option>
|
|
58
|
+
<option value="bias_cautious">bias cautious — nudge one step safer</option>
|
|
59
|
+
<option value="bias_bold">bias bold — nudge one step bolder</option>
|
|
60
|
+
</select>
|
|
61
|
+
</div>
|
|
62
|
+
<div><label>Note <span class="hint">(shown to the agent as the reason)</span></label><input type="text" id="note" placeholder="Irreversible money goes to a human" /></div>
|
|
63
|
+
</div>
|
|
64
|
+
<div class="actions"><button class="primary" id="add">Add rule</button></div>
|
|
65
|
+
</div>
|
|
66
|
+
|
|
67
|
+
<div class="card">
|
|
68
|
+
<strong>Your rules</strong> <span class="hint">— first matching force-rule wins</span>
|
|
69
|
+
<ul class="rules" id="rules"></ul>
|
|
70
|
+
<p class="hint" id="empty">No rules yet.</p>
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
<div class="card">
|
|
74
|
+
<strong>consult-policies.json</strong>
|
|
75
|
+
<pre id="json"></pre>
|
|
76
|
+
<div class="actions">
|
|
77
|
+
<button class="primary" id="copy">Copy JSON</button>
|
|
78
|
+
<button id="download">Download consult-policies.json</button>
|
|
79
|
+
<span id="copied" class="ok" style="display:none">copied ✓</span>
|
|
80
|
+
</div>
|
|
81
|
+
<div class="steps" style="margin-top:12px">
|
|
82
|
+
<strong>To use it (desktop / MCP):</strong>
|
|
83
|
+
<ol style="margin:6px 0 0; padding-left:18px">
|
|
84
|
+
<li>Save the file as <code>~/phaedo/mcp/consult-policies.json</code> (the server auto-reads it — no env needed).</li>
|
|
85
|
+
<li>Restart your AI client (Cmd-Q Claude Desktop, or a new Claude Code session).</li>
|
|
86
|
+
<li>Call <code>phaedo_consult</code> for a matching action — the signal should reflect your rule, with the reason in <code>rationale_hint</code>.</li>
|
|
87
|
+
</ol>
|
|
88
|
+
<span class="hint">Portable alt: paste the <code>rules</code> into your vault under <code>phaedo_consult_policies</code> so they travel with you (authoring-in-app is a follow-up).</span>
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
</div>
|
|
92
|
+
|
|
93
|
+
<script>
|
|
94
|
+
const rules = [];
|
|
95
|
+
const $ = (id) => document.getElementById(id);
|
|
96
|
+
const val = (id) => $(id).value.trim();
|
|
97
|
+
|
|
98
|
+
function describe(r) {
|
|
99
|
+
const m = r.match || {};
|
|
100
|
+
const dom = (m.domains && m.domains.length) ? `domain ∈ {${m.domains.join(', ')}}` : 'any domain';
|
|
101
|
+
const mag = [m.magnitude_min ? `≥${m.magnitude_min}` : '', m.magnitude_max ? `≤${m.magnitude_max}` : ''].filter(Boolean).join(' ') || 'any magnitude';
|
|
102
|
+
const rev = (m.reversible === true) ? 'reversible' : (m.reversible === false) ? 'irreversible' : 'any reversibility';
|
|
103
|
+
return `${dom} · ${mag} · ${rev}`;
|
|
104
|
+
}
|
|
105
|
+
const FORCE = new Set(['escalate','decline','autoproceed','require_human']);
|
|
106
|
+
|
|
107
|
+
function render() {
|
|
108
|
+
const ul = $('rules'); ul.innerHTML = '';
|
|
109
|
+
$('empty').style.display = rules.length ? 'none' : 'block';
|
|
110
|
+
rules.forEach((r, i) => {
|
|
111
|
+
const li = document.createElement('li'); li.className = 'rule';
|
|
112
|
+
const left = document.createElement('div');
|
|
113
|
+
const badge = `<span class="badge ${FORCE.has(r.effect) ? 'force' : ''}">${r.effect}</span>`;
|
|
114
|
+
left.innerHTML = `<div class="desc">${badge} ${r.note ? r.note : ''}</div><div class="meta">${describe(r)}</div>`;
|
|
115
|
+
const rm = document.createElement('button'); rm.className = 'danger'; rm.textContent = 'Remove';
|
|
116
|
+
rm.onclick = () => { rules.splice(i, 1); render(); };
|
|
117
|
+
li.appendChild(left); li.appendChild(rm); ul.appendChild(li);
|
|
118
|
+
});
|
|
119
|
+
$('json').textContent = JSON.stringify(doc(), null, 2);
|
|
120
|
+
}
|
|
121
|
+
function doc() { return { version: '0.1', rules }; }
|
|
122
|
+
|
|
123
|
+
$('add').onclick = () => {
|
|
124
|
+
const match = {};
|
|
125
|
+
const domains = val('domains').split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
|
|
126
|
+
if (domains.length) match.domains = domains;
|
|
127
|
+
if (val('magmin')) match.magnitude_min = val('magmin');
|
|
128
|
+
if (val('magmax')) match.magnitude_max = val('magmax');
|
|
129
|
+
if ($('rev').value) match.reversible = $('rev').value === 'true';
|
|
130
|
+
const rule = { id: 'rule-' + (rules.length + 1), match, effect: $('effect').value };
|
|
131
|
+
if (val('note')) rule.note = val('note');
|
|
132
|
+
rules.push(rule);
|
|
133
|
+
$('domains').value = ''; $('note').value = ''; $('magmin').value = ''; $('magmax').value = ''; $('rev').value = ''; $('effect').value = 'escalate';
|
|
134
|
+
render();
|
|
135
|
+
};
|
|
136
|
+
$('copy').onclick = async () => {
|
|
137
|
+
try { await navigator.clipboard.writeText(JSON.stringify(doc(), null, 2)); const c = $('copied'); c.style.display = 'inline'; setTimeout(() => c.style.display = 'none', 1500); } catch (e) {}
|
|
138
|
+
};
|
|
139
|
+
$('download').onclick = () => {
|
|
140
|
+
const blob = new Blob([JSON.stringify(doc(), null, 2)], { type: 'application/json' });
|
|
141
|
+
const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'consult-policies.json'; a.click(); URL.revokeObjectURL(a.href);
|
|
142
|
+
};
|
|
143
|
+
render();
|
|
144
|
+
</script>
|
|
145
|
+
</body>
|
|
146
|
+
</html>
|
package/projection.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// Phaedo §9 Injection projection for the MCP binding.
|
|
2
|
+
//
|
|
3
|
+
// Reuses context-block.js (PhaedoContext) — the SAME renderer the Chrome
|
|
4
|
+
// extension injects — so MCP injection is byte-identical to web injection.
|
|
5
|
+
// Wraps the rendered block in the §9.3 Injection Response shape.
|
|
6
|
+
|
|
7
|
+
import { createRequire } from 'module';
|
|
8
|
+
import { existsSync } from 'fs';
|
|
9
|
+
import { dirname, join } from 'path';
|
|
10
|
+
import { fileURLToPath } from 'url';
|
|
11
|
+
const require = createRequire(import.meta.url);
|
|
12
|
+
// context-block.js is the shared renderer; in the dev repo it lives at the root
|
|
13
|
+
// (one source of truth with the extension). For a self-contained npm package,
|
|
14
|
+
// `npm run build` / prepack copies it next to this file — prefer the local copy
|
|
15
|
+
// when present, else fall back to the repo root.
|
|
16
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
const vendored = join(HERE, 'vendor', 'context-block.js');
|
|
18
|
+
require(existsSync(vendored) ? vendored : '../context-block.js');
|
|
19
|
+
const PhaedoContext = globalThis.PhaedoContext;
|
|
20
|
+
if (!PhaedoContext || !PhaedoContext.renderContextBlock) {
|
|
21
|
+
throw new Error('context-block.js did not expose PhaedoContext.renderContextBlock');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// §9.5 error codes (the six v0.1 codes) + PhaedoError now live in errors.js so the
|
|
25
|
+
// pure consultation core can share the class without importing this renderer.
|
|
26
|
+
// Re-exported here so existing `import { … } from './projection.js'` sites are
|
|
27
|
+
// unchanged and `instanceof PhaedoError` holds across the package.
|
|
28
|
+
import { ERROR_CODES, PhaedoError } from './errors.js';
|
|
29
|
+
export { ERROR_CODES, PhaedoError };
|
|
30
|
+
|
|
31
|
+
const DEFAULT_TTL_MS = 60 * 60 * 1000; // 1h, mirrors the session-scoped boundary
|
|
32
|
+
|
|
33
|
+
// persona_strength [0,1]: prefer a stored value; otherwise a bounded, monotone
|
|
34
|
+
// v0.1 estimate from the same axes the fingerprint sharpens along (NOT claimed
|
|
35
|
+
// calibrated — placeholder pending the calibration track).
|
|
36
|
+
function estimatePersonaStrength(vd) {
|
|
37
|
+
const fp = vd.phaedo_fingerprint || {};
|
|
38
|
+
if (typeof fp.persona_strength === 'number') return Math.max(0, Math.min(1, fp.persona_strength));
|
|
39
|
+
const layers = fp.layers || {};
|
|
40
|
+
let answered = 0, total = 0;
|
|
41
|
+
for (const layer of Object.values(layers)) {
|
|
42
|
+
const resp = layer.responses || [];
|
|
43
|
+
total += resp.length;
|
|
44
|
+
answered += resp.filter(r => (r.answer || '').trim()).length;
|
|
45
|
+
}
|
|
46
|
+
const ling = vd.phaedo_linguistic_profile || {};
|
|
47
|
+
const answeredRatio = total ? answered / total : 0;
|
|
48
|
+
const msgFactor = 1 - Math.exp(-((ling.messageCount || 0) / 200));
|
|
49
|
+
return Math.round((0.5 * answeredRatio + 0.5 * msgFactor) * 100) / 100;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Filter the vault payload's fingerprint to a requested layer subset (canonical
|
|
53
|
+
// layer ids). Returns { vd, included }. ["all"] (or empty) → full passthrough.
|
|
54
|
+
function selectLayers(vd, requestedLayers) {
|
|
55
|
+
const wantAll = !requestedLayers || requestedLayers.length === 0 || requestedLayers.includes('all');
|
|
56
|
+
const layers = (vd.phaedo_fingerprint && vd.phaedo_fingerprint.layers) || {};
|
|
57
|
+
if (wantAll) return { vd, included: Object.keys(layers) };
|
|
58
|
+
|
|
59
|
+
const included = requestedLayers.filter(id => layers[id]);
|
|
60
|
+
if (!included.length) {
|
|
61
|
+
throw new PhaedoError('layer_not_available', `None of the requested layers exist: ${requestedLayers.join(', ')}`);
|
|
62
|
+
}
|
|
63
|
+
const filteredLayers = {};
|
|
64
|
+
for (const id of included) filteredLayers[id] = layers[id];
|
|
65
|
+
const filteredVd = {
|
|
66
|
+
...vd,
|
|
67
|
+
phaedo_fingerprint: { ...vd.phaedo_fingerprint, layers: filteredLayers },
|
|
68
|
+
};
|
|
69
|
+
return { vd: filteredVd, included };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Build a §9.3 Injection Response from a vault payload + a §9.2-shaped request.
|
|
73
|
+
// `now` is injectable for deterministic tests. Throws PhaedoError on §9.5 cases.
|
|
74
|
+
export function buildInjectionResponse(vd, request = {}, now = Date.now()) {
|
|
75
|
+
if (!vd || !vd.phaedo_fingerprint) {
|
|
76
|
+
throw new PhaedoError('decryption_failed', 'No fingerprint available from the configured source.');
|
|
77
|
+
}
|
|
78
|
+
const mode = request.mode || 'standard';
|
|
79
|
+
if (mode === 'enhanced_privacy') {
|
|
80
|
+
throw new PhaedoError('mode_not_supported', 'Enhanced privacy mode is not available in this build.');
|
|
81
|
+
}
|
|
82
|
+
if (mode !== 'standard') {
|
|
83
|
+
throw new PhaedoError('request_malformed', `Unknown mode: ${mode}`);
|
|
84
|
+
}
|
|
85
|
+
if (request.requested_layers != null && !Array.isArray(request.requested_layers)) {
|
|
86
|
+
throw new PhaedoError('request_malformed', 'requested_layers must be an array or ["all"].');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const { vd: selectedVd, included } = selectLayers(vd, request.requested_layers);
|
|
90
|
+
const projection = PhaedoContext.renderContextBlock(selectedVd);
|
|
91
|
+
if (!projection) {
|
|
92
|
+
throw new PhaedoError('decryption_failed', 'Fingerprint produced no projection (empty or missing layers).');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const fp = vd.phaedo_fingerprint;
|
|
96
|
+
return {
|
|
97
|
+
phaedo_response_version: '0.1',
|
|
98
|
+
request_type: 'injection',
|
|
99
|
+
fingerprint_id: fp.fingerprint_id || fp.id || null,
|
|
100
|
+
mode: 'standard',
|
|
101
|
+
projection,
|
|
102
|
+
layers_included: included,
|
|
103
|
+
persona_strength: estimatePersonaStrength(vd),
|
|
104
|
+
expires_at: new Date(now + DEFAULT_TTL_MS).toISOString(),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function errorResponse(err, requestType = 'injection') {
|
|
109
|
+
const code = err instanceof PhaedoError ? err.code : 'internal_error';
|
|
110
|
+
return {
|
|
111
|
+
phaedo_response_version: '0.1',
|
|
112
|
+
request_type: requestType,
|
|
113
|
+
error: { code, message: err.message || code },
|
|
114
|
+
};
|
|
115
|
+
}
|
package/pull.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Debug check: pull the fingerprint via the configured source (phone pairing.json
|
|
3
|
+
// → live authorize/pull/decrypt, or the at-rest cache) and print a NON-sensitive
|
|
4
|
+
// confirmation — no profile content. Verifies the pairing works end to end.
|
|
5
|
+
//
|
|
6
|
+
// npm run pull
|
|
7
|
+
|
|
8
|
+
import { fileURLToPath } from 'url';
|
|
9
|
+
import { dirname } from 'path';
|
|
10
|
+
import { loadFingerprint } from './fingerprint-source.js';
|
|
11
|
+
|
|
12
|
+
const BASE_DIR = dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
|
|
14
|
+
loadFingerprint(BASE_DIR)
|
|
15
|
+
.then((vd) => {
|
|
16
|
+
const fp = (vd && vd.phaedo_fingerprint) || {};
|
|
17
|
+
const layers = fp.layers ? Object.keys(fp.layers).length : 0;
|
|
18
|
+
process.stdout.write(
|
|
19
|
+
`✓ Read your profile from the phone — fingerprint ${fp.fingerprint_id || '(no id)'}, ${layers} layer(s).\n`
|
|
20
|
+
);
|
|
21
|
+
process.exit(0);
|
|
22
|
+
})
|
|
23
|
+
.catch((err) => {
|
|
24
|
+
process.stderr.write(`✗ Couldn't read your profile: ${err.message}\n`);
|
|
25
|
+
process.exit(1);
|
|
26
|
+
});
|