phaedo-mcp 0.3.16 → 0.3.21
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/cache.js +11 -0
- package/consult-core.js +12 -1
- package/consult-policy.js +9 -0
- package/consult-runtime.js +12 -3
- package/consult.js +2 -2
- package/fingerprint-source.js +22 -1
- package/hooks/pre-ship.mjs +88 -21
- package/lib/rule-leeway.js +56 -0
- package/package.json +3 -2
- package/profile-inbox.js +114 -0
- package/pull.js +2 -1
- package/receipts-sync.js +7 -2
- package/server.js +62 -11
- package/server.json +2 -2
- package/weekly.js +23 -3
package/cache.js
CHANGED
|
@@ -58,6 +58,17 @@ export async function clearCache(stateDir) {
|
|
|
58
58
|
try { await rm(join(stateDir, 'cache.enc'), { force: true }); } catch { /* nothing to clear */ }
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
// When the at-rest copy was made (ms), without decrypting it; null when there is none. The phone
|
|
62
|
+
// shows this age (receipts digest `copy_at`), so the subject can see the copy keeping up.
|
|
63
|
+
export async function readCacheTime(stateDir) {
|
|
64
|
+
try {
|
|
65
|
+
const { cached_at } = JSON.parse(await readFile(join(stateDir, 'cache.enc'), 'utf8'));
|
|
66
|
+
return Number.isFinite(cached_at) && cached_at > 0 ? cached_at : null;
|
|
67
|
+
} catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
61
72
|
// Decrypt the cache → { vault, cachedAt } or null (absent/corrupt/undecryptable
|
|
62
73
|
// → treated as no cache, since it's recoverable from the phone).
|
|
63
74
|
export async function readCache(stateDir) {
|
package/consult-core.js
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
// abstain. Signals out, never structure out (§10.4).
|
|
16
16
|
|
|
17
17
|
import { PhaedoError } from './errors.js';
|
|
18
|
-
import { loadPolicies, applyPolicies, matchAuthorization } from './consult-policy.js';
|
|
18
|
+
import { loadPolicies, applyPolicies, matchAuthorization, relaxingRuleApplies } from './consult-policy.js';
|
|
19
19
|
import { normalizeActionBinding, isBound } from './action-binding.js';
|
|
20
20
|
import { normalizeDomain, canonicalDomain } from './lib/domain.js';
|
|
21
21
|
|
|
@@ -914,6 +914,17 @@ export function resolveConsultationCore(vd, request = {}, opts = {}) {
|
|
|
914
914
|
// importers (tests, spec/test-schemas.mjs) are unchanged.
|
|
915
915
|
export const buildConsultationResponse = resolveConsultationCore;
|
|
916
916
|
|
|
917
|
+
/**
|
|
918
|
+
* Does one of the subject's own RELAXING rules (a §10.5 `autoproceed` grant or a `bias_bold`)
|
|
919
|
+
* apply to this request? The server's stale-authority guard withholds an expanding answer from a
|
|
920
|
+
* cached profile only when this is true: a grant is revocable, and the copy may predate the
|
|
921
|
+
* revocation. Phaedo's inferred judgment carries no such risk and is left alone.
|
|
922
|
+
*/
|
|
923
|
+
export function standingRuleRelaxes(vd, request = {}, opts = {}) {
|
|
924
|
+
const n = normalizeRequest(request);
|
|
925
|
+
return relaxingRuleApplies(n, loadPolicies(vd, opts), policyCtx(opts));
|
|
926
|
+
}
|
|
927
|
+
|
|
917
928
|
// ── Deterministic-only authorization check (brief M3) ─────────────────────────
|
|
918
929
|
// (vd, request[, opts]) -> a small, fixed-shape result. Checks ONLY standing
|
|
919
930
|
// authorizations (§10.5 force rules) — it does NOT run inferred evaluation, so it is
|
package/consult-policy.js
CHANGED
|
@@ -446,6 +446,15 @@ function agentScopeSatisfied(rule, clientId) {
|
|
|
446
446
|
return scope.includes(clientId); // [] matches nobody, by design
|
|
447
447
|
}
|
|
448
448
|
|
|
449
|
+
// Does any RELAXING rule (autoproceed / bias_bold) apply to this request — active, in scope,
|
|
450
|
+
// matched and bound? The stale-authority guard keys on this: an answer that rests on one of the
|
|
451
|
+
// subject's own revocable grants must not be acted on from a copy that may predate a revocation.
|
|
452
|
+
export function relaxingRuleApplies(n, policies, { now = Date.now(), clientId = null } = {}) {
|
|
453
|
+
if (!policies || !policies.length || !n || n.type === 'voice_draft') return false;
|
|
454
|
+
return policies.some((r) => RELAXING.has(r.effect) && ruleActive(r, now) && agentScopeSatisfied(r, clientId)
|
|
455
|
+
&& ruleMatches(r, n) && bindingSatisfied(r, n));
|
|
456
|
+
}
|
|
457
|
+
|
|
449
458
|
// Did a rule match on its own terms and get held back ONLY by the binding gate?
|
|
450
459
|
// Worth reporting: otherwise an agent whose standing auto-proceed rule silently
|
|
451
460
|
// stops firing has no way to learn that it needs to send an action_binding.
|
package/consult-runtime.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { join } from 'path';
|
|
10
10
|
import { configDir } from './lib/config-dir.js';
|
|
11
11
|
import { readFileSync, existsSync } from 'fs';
|
|
12
|
-
import { loadFingerprint, loadFingerprintPreferCache } from './fingerprint-source.js';
|
|
12
|
+
import { loadFingerprint, loadFingerprintPreferCache, carrySource } from './fingerprint-source.js';
|
|
13
13
|
import { readServingClientId } from './phone-source.js';
|
|
14
14
|
import { consultOptsFromEnv, deriveDelegationSignals, withDelegationSignals, authoredDelegationSignals } from './consult.js';
|
|
15
15
|
import { defaultStateDir } from './cache.js';
|
|
@@ -43,17 +43,26 @@ export function buildConsultOpts(baseDir, env = process.env, { warn = (m) => pro
|
|
|
43
43
|
// origin-aware resolver then lets a `delegation` correction outrank a `self` signal on
|
|
44
44
|
// the same dimension. Best-effort — if receipts are unreadable, the consult still
|
|
45
45
|
// answers from the plain fingerprint (delegation learning never breaks a consultation).
|
|
46
|
-
|
|
46
|
+
//
|
|
47
|
+
// `onRead(vd)` sees the plain read BEFORE the overlay. The overlay is a spread copy, and the
|
|
48
|
+
// read's provenance (fingerprint-source.js SOURCE) is a non-enumerable marker a spread drops —
|
|
49
|
+
// so `sourceOf()` on the RETURNED profile always says "live". A caller that needs the copy's
|
|
50
|
+
// real age (the ship hook's standing-rule gate) must take it here.
|
|
51
|
+
export async function loadConsultVd(baseDir, { preferCache = false, warn = (m) => process.stderr.write(m), onRead } = {}) {
|
|
47
52
|
// preferCache (escalation, ship hook): read the at-rest cache instead of a live phone
|
|
48
53
|
// pull, so the caller doesn't add a fingerprint-read AUTHORIZE prompt on the phone.
|
|
49
54
|
const vd = preferCache ? await loadFingerprintPreferCache(baseDir) : await loadFingerprint(baseDir);
|
|
55
|
+
if (onRead) onRead(vd);
|
|
50
56
|
try {
|
|
51
57
|
// Two delegation sources, both origin-aware: live override history (every override)
|
|
52
58
|
// + the subject's ENDORSED, portable delegation rules (authored_delegation). The
|
|
53
59
|
// authored rule thus shapes consult too, not only the §9 injection it already renders in.
|
|
54
60
|
const signals = deriveDelegationSignals(await readReceipts(defaultStateDir()))
|
|
55
61
|
.concat(authoredDelegationSignals(vd && vd.phaedo_fingerprint));
|
|
56
|
-
|
|
62
|
+
// The overlay is a spread COPY and the read's provenance is a non-enumerable marker, so it
|
|
63
|
+
// must be carried across — without it every cached read looked live and the server's
|
|
64
|
+
// stale-authority guard never engaged on phaedo_consult / phaedo_escalate (found 2026-10-31).
|
|
65
|
+
return carrySource(vd, withDelegationSignals(vd, signals));
|
|
57
66
|
} catch (e) {
|
|
58
67
|
warn(`[phaedo-mcp] delegation derive skipped (consult unaffected): ${e.message}\n`);
|
|
59
68
|
return vd;
|
package/consult.js
CHANGED
|
@@ -13,7 +13,7 @@ import { buildInjectionResponse } from './projection.js';
|
|
|
13
13
|
import { loadPolicies, applyPolicies } from './consult-policy.js';
|
|
14
14
|
import { PhaedoError } from './errors.js';
|
|
15
15
|
import {
|
|
16
|
-
resolveConsultationCore, buildConsultationResponse, checkAuthorization, normalizeRequest,
|
|
16
|
+
resolveConsultationCore, buildConsultationResponse, checkAuthorization, standingRuleRelaxes, normalizeRequest,
|
|
17
17
|
shape, SIGNALS, CONSULTATION_TYPES, validateVaultShape, consultDrivers,
|
|
18
18
|
deriveDelegationSignals, withDelegationSignals, buildDelegationPromotions, DELEGATION_PROMOTION_FLOOR,
|
|
19
19
|
authoredDelegationSignals,
|
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
|
|
23
23
|
// Re-export the core surface so existing importers (server.js, tests,
|
|
24
24
|
// spec/test-schemas.mjs) keep their `from './consult.js'` paths.
|
|
25
|
-
export { CONSULTATION_TYPES, buildConsultationResponse, resolveConsultationCore, checkAuthorization, validateVaultShape, consultDrivers, deriveDelegationSignals, withDelegationSignals, buildDelegationPromotions, DELEGATION_PROMOTION_FLOOR, authoredDelegationSignals };
|
|
25
|
+
export { CONSULTATION_TYPES, buildConsultationResponse, resolveConsultationCore, checkAuthorization, standingRuleRelaxes, validateVaultShape, consultDrivers, deriveDelegationSignals, withDelegationSignals, buildDelegationPromotions, DELEGATION_PROMOTION_FLOOR, authoredDelegationSignals };
|
|
26
26
|
|
|
27
27
|
// ── Model resolver (richest fingerprint, LOCAL compute — opt-in) ──────────────
|
|
28
28
|
// When a local model is configured (PHAEDO_CONSULT_MODEL + an Ollama/OpenAI-
|
package/fingerprint-source.js
CHANGED
|
@@ -15,6 +15,7 @@ import { resolve, dirname } from 'path';
|
|
|
15
15
|
import { fileURLToPath } from 'url';
|
|
16
16
|
import { hasPairingRecord, loadPairingRecord, loadFingerprintFromPhone, PhoneError } from './phone-source.js';
|
|
17
17
|
import { defaultStateDir, writeCache, readCache, clearCache } from './cache.js';
|
|
18
|
+
import { maybeDrainProfileCopy } from './profile-inbox.js';
|
|
18
19
|
import { foldLocalCapture, isLocalCaptureOwner, localCaptureSessionCount } from './capture-local.js';
|
|
19
20
|
import { Capabilities, AuthoredSource } from './lib/phaedo-crypto.js';
|
|
20
21
|
import { PhaedoError } from './errors.js';
|
|
@@ -256,6 +257,20 @@ export function sourceOf(vd) {
|
|
|
256
257
|
return s || { live: true, cachedAt: null, ageMs: 0 };
|
|
257
258
|
}
|
|
258
259
|
|
|
260
|
+
/**
|
|
261
|
+
* Copy `from`'s provenance onto `to` (a derived copy of it) and return `to`. Any overlay that
|
|
262
|
+
* spreads the fingerprint MUST pass its result through this: an absent marker reads as LIVE,
|
|
263
|
+
* so a lost one makes a stale cached read look fresh to the stale-authority guard.
|
|
264
|
+
*/
|
|
265
|
+
export function carrySource(from, to) {
|
|
266
|
+
const s = from && from[SOURCE];
|
|
267
|
+
if (!s || !to || typeof to !== 'object' || to === from) return to;
|
|
268
|
+
try {
|
|
269
|
+
Object.defineProperty(to, SOURCE, { value: s, enumerable: false, configurable: true, writable: true });
|
|
270
|
+
} catch { /* frozen object — provenance is best-effort, never fatal */ }
|
|
271
|
+
return to;
|
|
272
|
+
}
|
|
273
|
+
|
|
259
274
|
// What the subject removed in `npm run learned` is taken out on EVERY read, so injection,
|
|
260
275
|
// consult and the rules files all stop using it — even when the sessions keep showing it.
|
|
261
276
|
// `unfiltered` is for that review screen alone, which has to list what was removed.
|
|
@@ -293,8 +308,11 @@ async function loadFingerprintUnfiltered(baseDir) {
|
|
|
293
308
|
await clearCache(stateDir);
|
|
294
309
|
throw err;
|
|
295
310
|
}
|
|
311
|
+
// Phone unreachable → the copy the phone last pushed for us, if it is newer (profile-inbox.js),
|
|
312
|
+
// then the encrypted cache (may be stale).
|
|
313
|
+
await maybeDrainProfileCopy(async () => rec, stateDir);
|
|
296
314
|
const cached = await readCache(stateDir);
|
|
297
|
-
if (cached) return stamp(foldConfigHome(normalizeIdentity(cached.vault)), cached);
|
|
315
|
+
if (cached) return stamp(foldConfigHome(normalizeIdentity(cached.vault)), cached);
|
|
298
316
|
throw err; // no phone + no cache → cannot serve
|
|
299
317
|
}
|
|
300
318
|
}
|
|
@@ -357,6 +375,9 @@ async function loadFingerprintUnfiltered(baseDir) {
|
|
|
357
375
|
// escalation in flight.
|
|
358
376
|
export async function loadFingerprintPreferCache(baseDir, opts) {
|
|
359
377
|
if (!process.env.PHAEDO_FINGERPRINT && hasPairingRecord(baseDir)) {
|
|
378
|
+
// The phone pushes a fresh copy whenever its app opens or the profile changes; take it first
|
|
379
|
+
// (throttled, fail-fast — this is the ship hook's path). See profile-inbox.js.
|
|
380
|
+
await maybeDrainProfileCopy(() => loadPairingRecord(baseDir), defaultStateDir());
|
|
360
381
|
const cached = await readCache(defaultStateDir());
|
|
361
382
|
if (cached) return forgetting(stamp(foldConfigHome(normalizeIdentity(cached.vault)), cached), opts);
|
|
362
383
|
}
|
package/hooks/pre-ship.mjs
CHANGED
|
@@ -9,13 +9,24 @@
|
|
|
9
9
|
// Phaedo before those run (ship-actions.js names them) and writes a §10.6 receipt, so the
|
|
10
10
|
// receipts accumulate in normal use instead of waiting for an agent to remember a tool.
|
|
11
11
|
//
|
|
12
|
-
// WHAT IT MAY DO
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
// own
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
12
|
+
// WHAT IT MAY DO. On a cautious answer it returns `ask`, which puts the action in front of
|
|
13
|
+
// the subject. On a permissive answer it returns no decision (Claude Code's own permission
|
|
14
|
+
// flow applies unchanged) — with ONE exception, since 0.3.17: it returns `allow` when the
|
|
15
|
+
// subject's own standing rule decided the action (a §10.5 `autoproceed` authorization they
|
|
16
|
+
// accepted on the phone — usually staged by §10.7 promotion). Every gate must hold
|
|
17
|
+
// (standingGrantHeld): the authorization matched (unexpired, scoped to this client, bound
|
|
18
|
+
// to this call); the action is reversible and not high; GitHub raised nothing; the profile
|
|
19
|
+
// copy is at most GRANT_FRESH_DAYS old; and no "no" in that domain within GRANT_PUSHBACK_DAYS
|
|
20
|
+
// (lib/rule-leeway.js — shared with the server).
|
|
21
|
+
// Claude Code only — other agents' hooks never get the dependency.
|
|
22
|
+
//
|
|
23
|
+
// Why a cached read may apply a rule at all: the hook cannot read live (a live pull needs the
|
|
24
|
+
// subject to approve on the phone, which would trade one prompt for another), and the
|
|
25
|
+
// alternative is a rule that never fires. Bounded instead, by the same leeway the server's
|
|
26
|
+
// stale-authority guard uses (lib/rule-leeway.js): the copy's age caps how long a rule withdrawn
|
|
27
|
+
// on the phone can still act, and the pushback gate is a brake on THIS machine — one "no" stops
|
|
28
|
+
// it at once. Outside that one case the hook reads the at-rest cache and skips the guard
|
|
29
|
+
// because nothing it does can EXPAND autonomy.
|
|
19
30
|
//
|
|
20
31
|
// WHERE OUTCOMES COME FROM — SUBJECT-AUTHORED ONLY (§10.6 honesty rail). The one thing the
|
|
21
32
|
// hook records itself is a yes the subject gave on a prompt, and it records that only on
|
|
@@ -50,6 +61,7 @@ import { execFile } from 'child_process';
|
|
|
50
61
|
import { classifyShipCommand, classifyToolCall } from '../ship-actions.js';
|
|
51
62
|
import { isMainModule } from '../lib/is-main.js';
|
|
52
63
|
import { isDirectoryEdition } from '../lib/config-dir.js';
|
|
64
|
+
import { ruleLeeway, RULE_FRESH_DAYS, RULE_PUSHBACK_DAYS } from '../lib/rule-leeway.js';
|
|
53
65
|
|
|
54
66
|
const MCP_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
55
67
|
|
|
@@ -82,6 +94,31 @@ export const STALE_PROFILE_DAYS = 14;
|
|
|
82
94
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
83
95
|
const PENDING_TTL_MS = DAY_MS;
|
|
84
96
|
|
|
97
|
+
// The copy's age and the "one no pauses it" window — the SAME leeway the server's stale guard
|
|
98
|
+
// gives a rule (lib/rule-leeway.js), so a merge and a direct consult can't disagree.
|
|
99
|
+
export const GRANT_FRESH_DAYS = RULE_FRESH_DAYS;
|
|
100
|
+
export const GRANT_PUSHBACK_DAYS = RULE_PUSHBACK_DAYS;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* May the subject's standing rule answer this prompt? PURE. Returns { ok: true } or
|
|
104
|
+
* { ok: false, why } where `why` is one of: no_rule | raised | not_reversible | high |
|
|
105
|
+
* magnitude_unstated | priced | stale | pushback.
|
|
106
|
+
* `auth` is checkAuthorization's result; `source` is the profile read's provenance (sourceOf).
|
|
107
|
+
*/
|
|
108
|
+
export function standingGrantHeld({ auth, descriptor, raised, source, receipts, now }) {
|
|
109
|
+
if (!auth?.authorization_matched || auth.signal !== 'proceed') return { ok: false, why: 'no_rule' };
|
|
110
|
+
if (raised) return { ok: false, why: 'raised' };
|
|
111
|
+
return ruleLeeway({ source, descriptor, receipts, now });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// What the agent is told when the subject's rule answered the prompt.
|
|
115
|
+
export function grantContext(receiptId) {
|
|
116
|
+
return `This ran without a confirmation prompt: the user's own standing rule in Phaedo covers it ` +
|
|
117
|
+
`(routine, reversible). Tell them in one line that it went ahead under their rule. If they object, ` +
|
|
118
|
+
`call phaedo_record_outcome with receipt_id ${receiptId} and "rejected" (or "modified") — one ` +
|
|
119
|
+
`"no" pauses the rule on this computer.`;
|
|
120
|
+
}
|
|
121
|
+
|
|
85
122
|
// The caller label on the receipt. `via` stays `phaedo_consult` — the hook runs a full
|
|
86
123
|
// consultation through the same path, and the receipt schema's `via` enum is closed — so
|
|
87
124
|
// the free-text, self-asserted `agent_id` is where "this came from the ship hook" lives.
|
|
@@ -291,8 +328,8 @@ export function unpromptedContext(receiptId) {
|
|
|
291
328
|
|
|
292
329
|
export function staleContext(days, mcpDir) {
|
|
293
330
|
return `Also: Phaedo's copy of the user's profile is ${days} days old (their phone has not been ` +
|
|
294
|
-
`reached since). Mention once, in one line, that
|
|
295
|
-
|
|
331
|
+
`reached since). Mention once, in one line, that opening the Phaedo app on their phone refreshes ` +
|
|
332
|
+
`it within minutes, or they can run \`cd ${mcpDir} && npm run pull\` and approve on their phone.`;
|
|
296
333
|
}
|
|
297
334
|
|
|
298
335
|
/**
|
|
@@ -373,29 +410,42 @@ export async function runShipHook(input, deps) {
|
|
|
373
410
|
};
|
|
374
411
|
const { response, drivers, source } = await deps.consult(request);
|
|
375
412
|
const signal = String(response?.signal || '');
|
|
413
|
+
const asked = ASKING_SIGNALS.has(signal);
|
|
414
|
+
// The one way this hook removes a prompt: the subject's own standing rule (see header).
|
|
415
|
+
let grant = null;
|
|
416
|
+
if (!asked && signal === 'proceed' && deps.standingGrant) {
|
|
417
|
+
try { grant = await deps.standingGrant(request, { raised, now }); } catch { grant = null; }
|
|
418
|
+
}
|
|
419
|
+
const granted = grant?.ok === true;
|
|
376
420
|
// Provenance: what the hook saw, beside what drove the answer. Local receipt only.
|
|
377
421
|
const extra = [];
|
|
378
422
|
if (ci) extra.push({ cue: 'ciState', value: ci, confidence: null, basis: ci === 'unknown' ? null : 'observed' });
|
|
379
423
|
if (ci && deps.prFacts) extra.push({ cue: 'diffStakes', value: stakesValue(stakes), confidence: null, basis: stakes ? 'observed' : null });
|
|
380
|
-
|
|
424
|
+
// First, not last: buildReceipt keeps only the first 16 drivers, and the weekly summary on the
|
|
425
|
+
// phone counts this one ("your standing rule decided N of them", weekly.js).
|
|
426
|
+
const lead = grant ? [{ cue: 'standingGrant', value: granted ? 'answered' : `held:${grant.why}`, confidence: null, basis: 'observed' }] : [];
|
|
427
|
+
const allDrivers = (extra.length || lead.length) ? [...lead, ...(drivers || []), ...extra] : drivers;
|
|
381
428
|
const receiptId = await deps.writeReceipt({ request, response, drivers: allDrivers });
|
|
382
429
|
if (!receiptId) return null; // no receipt = nothing to learn from; stay out of the way
|
|
383
|
-
const asked = ASKING_SIGNALS.has(signal);
|
|
384
430
|
|
|
385
431
|
const state = readState(deps.statePath);
|
|
386
432
|
if (input.tool_use_id) {
|
|
387
433
|
state.pending[input.tool_use_id] = {
|
|
388
434
|
receipt_id: receiptId, cmd_hash: callHash(input), session_id: input.session_id || null,
|
|
389
|
-
asked, prompt_shown: false, at: new Date(now).toISOString(),
|
|
435
|
+
asked, granted, prompt_shown: false, at: new Date(now).toISOString(),
|
|
390
436
|
};
|
|
391
437
|
}
|
|
392
|
-
let context =
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
438
|
+
let context = granted
|
|
439
|
+
? `Phaedo was consulted before this action (${ship.summary}) and answered "${signal}". receipt_id: ${receiptId}. ${grantContext(receiptId)}`
|
|
440
|
+
: preContext({
|
|
441
|
+
summary: ship.summary, signal, receiptId, asked, recordsNo: Boolean(deps.readTranscript),
|
|
442
|
+
hint: typeof response?.rationale_hint === 'string' ? response.rationale_hint : '',
|
|
443
|
+
});
|
|
396
444
|
if (raised) context += ` GitHub shows ${why}, so Phaedo treated this merge as high-stakes.`;
|
|
397
445
|
const ageDays = source && source.live === false && Number.isFinite(source.ageMs) ? Math.floor(source.ageMs / DAY_MS) : 0;
|
|
398
|
-
|
|
446
|
+
// A rule held back only by the copy's age is worth a refresh nudge before the general one.
|
|
447
|
+
const staleNudge = ageDays >= STALE_PROFILE_DAYS || grant?.why === 'stale';
|
|
448
|
+
if (staleNudge && !(now - Date.parse(state.stale_noted_at) < DAY_MS)) {
|
|
399
449
|
context += ' ' + staleContext(ageDays, deps.mcpDir || MCP_DIR);
|
|
400
450
|
state.stale_noted_at = new Date(now).toISOString();
|
|
401
451
|
}
|
|
@@ -404,6 +454,7 @@ export async function runShipHook(input, deps) {
|
|
|
404
454
|
hookSpecificOutput: {
|
|
405
455
|
hookEventName: 'PreToolUse',
|
|
406
456
|
...(asked ? { permissionDecision: 'ask', permissionDecisionReason: promptReason(signal, ship.summary, why, personalWhy(request.action_descriptor, response)) } : {}),
|
|
457
|
+
...(granted ? { permissionDecision: 'allow', permissionDecisionReason: `Phaedo: your standing rule covers this (${ship.summary}).` } : {}),
|
|
407
458
|
additionalContext: context,
|
|
408
459
|
},
|
|
409
460
|
};
|
|
@@ -452,24 +503,40 @@ export async function runShipHook(input, deps) {
|
|
|
452
503
|
// `agentId` names the agent on the receipt; other agents' hooks (agent-hook.mjs) pass their own.
|
|
453
504
|
export async function realDeps({ agentId = AGENT_ID, statePath } = {}) {
|
|
454
505
|
const { loadConsultVd, buildConsultOpts } = await import('../consult-runtime.js');
|
|
455
|
-
const { resolveConsultation, consultDrivers, withRevision } = await import('../consult.js');
|
|
456
|
-
const { buildReceipt, appendReceipt, receiptsCapFromEnv } = await import('../receipts.js');
|
|
506
|
+
const { resolveConsultation, consultDrivers, withRevision, checkAuthorization } = await import('../consult.js');
|
|
507
|
+
const { buildReceipt, appendReceipt, receiptsCapFromEnv, readReceipts } = await import('../receipts.js');
|
|
457
508
|
const { sourceOf } = await import('../fingerprint-source.js');
|
|
458
509
|
const { defaultStateDir } = await import('../cache.js');
|
|
459
510
|
const stateDir = defaultStateDir();
|
|
460
511
|
const quiet = () => {};
|
|
461
512
|
const opts = buildConsultOpts(MCP_DIR, process.env, { warn: quiet });
|
|
513
|
+
let consultedVd = null; // the profile the consult read — the grant is checked against the same copy
|
|
514
|
+
let readSource = null; // ...and how old that copy really is (taken before the overlay drops it)
|
|
462
515
|
return {
|
|
463
516
|
statePath: statePath || join(stateDir, 'ship-hook.json'),
|
|
464
517
|
agentId,
|
|
465
518
|
mcpDir: MCP_DIR,
|
|
466
519
|
prFacts: (ship, cwd) => lookupPrFacts(ship, cwd),
|
|
520
|
+
// Only Claude Code's hook may answer a prompt; other agents' hooks never get this.
|
|
521
|
+
...(agentId === AGENT_ID ? {
|
|
522
|
+
async standingGrant(request, { raised, now }) {
|
|
523
|
+
if (!consultedVd || !readSource) return { ok: false, why: 'no_rule' };
|
|
524
|
+
return standingGrantHeld({
|
|
525
|
+
auth: checkAuthorization(consultedVd, request, opts),
|
|
526
|
+
descriptor: request.action_descriptor, raised, source: readSource,
|
|
527
|
+
receipts: await readReceipts(stateDir), now,
|
|
528
|
+
});
|
|
529
|
+
},
|
|
530
|
+
} : {}),
|
|
467
531
|
async consult(request) {
|
|
468
|
-
const vd = await loadConsultVd(MCP_DIR, { preferCache: true, warn: quiet });
|
|
532
|
+
const vd = await loadConsultVd(MCP_DIR, { preferCache: true, warn: quiet, onRead: (plain) => { readSource = sourceOf(plain); } });
|
|
533
|
+
consultedVd = vd;
|
|
469
534
|
const response = await resolveConsultation(vd, request, opts);
|
|
470
535
|
let drivers = [];
|
|
471
536
|
try { drivers = withRevision(consultDrivers(vd, request, opts), response); } catch { /* provenance is non-critical */ }
|
|
472
|
-
|
|
537
|
+
// The plain read's provenance — `sourceOf(vd)` on the overlay always says "live", which is
|
|
538
|
+
// also why the 14-day refresh nudge below never fired before 0.3.17.
|
|
539
|
+
return { response, drivers, source: readSource };
|
|
473
540
|
},
|
|
474
541
|
async writeReceipt({ request, response, drivers }) {
|
|
475
542
|
const r = buildReceipt({ via: 'phaedo_consult', agentId, clientId: opts.clientId, request, response, drivers });
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Phaedo — how far a CACHED profile copy may be trusted to apply one of the subject's own
|
|
2
|
+
// relaxing rules (a §10.5 `autoproceed` grant or a `bias_bold`). One definition, shared by the
|
|
3
|
+
// Claude Code ship hook (hooks/pre-ship.mjs) and the server's stale-authority guard (server.js),
|
|
4
|
+
// so the two paths cannot drift apart again.
|
|
5
|
+
//
|
|
6
|
+
// THE RISK. A rule is revocable on the phone, and a revocation reaches this computer only with
|
|
7
|
+
// the next live read. A cached copy may predate it — the 2026-07-25 incident, where a withdrawn
|
|
8
|
+
// $500 payment permission kept working from a 22-hour-old copy. So by default a cached copy may
|
|
9
|
+
// NOT apply a rule.
|
|
10
|
+
//
|
|
11
|
+
// THE LEEWAY (Randy, 2026-10-31: "align them"). A live read needs the subject to approve on the
|
|
12
|
+
// phone, so "live only" means a rule almost never fires. For the cheap end of the risk, a cached
|
|
13
|
+
// copy may apply the rule when ALL hold:
|
|
14
|
+
// - the action is reversible (stated `true`);
|
|
15
|
+
// - its magnitude is stated `low` or `medium` (never high, never unstated);
|
|
16
|
+
// - it moves no money (no `amount`) — every priced action stays live-only;
|
|
17
|
+
// - the copy is at most RULE_FRESH_DAYS old, which caps how long a withdrawn rule can act;
|
|
18
|
+
// - no rejected / modified outcome in that domain within RULE_PUSHBACK_DAYS — one "no" on
|
|
19
|
+
// this computer stops it at once, before the phone's revocation has arrived.
|
|
20
|
+
// Anything else keeps the strict rule. PURE: no I/O, `now` injected.
|
|
21
|
+
|
|
22
|
+
import { canonicalDomain, normalizeDomain } from './domain.js';
|
|
23
|
+
|
|
24
|
+
export const RULE_FRESH_DAYS = 7;
|
|
25
|
+
export const RULE_PUSHBACK_DAYS = 30;
|
|
26
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
27
|
+
const LEEWAY_MAGNITUDES = new Set(['low', 'medium']);
|
|
28
|
+
// Receipts store the canonical domain; a request may carry an alias or other casing. Compare
|
|
29
|
+
// canonical forms, so "code" and "engineering" are one class here as they are on the receipt.
|
|
30
|
+
const domainKey = (x) => (typeof x === 'string' && x.trim() ? (canonicalDomain(x) || normalizeDomain(x) || '') : '');
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* May a cached copy apply the subject's relaxing rule to this action?
|
|
34
|
+
* (source: sourceOf(read), descriptor: the request's action_descriptor, receipts, now)
|
|
35
|
+
* → { ok: true } | { ok: false, why } where why ∈ not_reversible | high | magnitude_unstated | priced
|
|
36
|
+
* | stale | pushback.
|
|
37
|
+
* The age gate applies only to a cached read; every other gate applies to any read.
|
|
38
|
+
*/
|
|
39
|
+
export function ruleLeeway({ source, descriptor, receipts, now = Date.now() } = {}) {
|
|
40
|
+
const d = descriptor && typeof descriptor === 'object' ? descriptor : {};
|
|
41
|
+
if (d.reversible !== true) return { ok: false, why: 'not_reversible' };
|
|
42
|
+
const mag = typeof d.magnitude === 'string' ? d.magnitude.toLowerCase() : null;
|
|
43
|
+
if (!LEEWAY_MAGNITUDES.has(mag)) return { ok: false, why: mag === 'high' ? 'high' : 'magnitude_unstated' };
|
|
44
|
+
if (d.amount != null) return { ok: false, why: 'priced' };
|
|
45
|
+
if (source && source.live === false && !(Number.isFinite(source.ageMs) && source.ageMs <= RULE_FRESH_DAYS * DAY_MS)) {
|
|
46
|
+
return { ok: false, why: 'stale' };
|
|
47
|
+
}
|
|
48
|
+
const domain = domainKey(d.domain);
|
|
49
|
+
const since = now - RULE_PUSHBACK_DAYS * DAY_MS;
|
|
50
|
+
const pushback = (Array.isArray(receipts) ? receipts : []).some((r) =>
|
|
51
|
+
(r?.user_action === 'rejected' || r?.user_action === 'modified')
|
|
52
|
+
&& domainKey(r?.request?.action_descriptor?.domain) === domain
|
|
53
|
+
&& Date.parse(r?.user_action_at || r?.created_at) >= since);
|
|
54
|
+
if (pushback) return { ok: false, why: 'pushback' };
|
|
55
|
+
return { ok: true };
|
|
56
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "phaedo-mcp",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.21",
|
|
4
4
|
"private": false,
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
"server.js",
|
|
37
37
|
"server.json",
|
|
38
38
|
"fingerprint-source.js",
|
|
39
|
+
"profile-inbox.js",
|
|
39
40
|
"projection.js",
|
|
40
41
|
"errors.js",
|
|
41
42
|
"consult.js",
|
|
@@ -100,7 +101,7 @@
|
|
|
100
101
|
"unsetup": "node install.mjs --uninstall",
|
|
101
102
|
"pair": "node pair.js",
|
|
102
103
|
"pull": "node pull.js",
|
|
103
|
-
"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-weekly.mjs && node test-asks.mjs && node test-precedent.mjs && node test-rules-files.mjs && node test-agent-hook.mjs && node test-learned.mjs && node test-history-import.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 && node test-plugin.mjs",
|
|
104
|
+
"test": "node test-smoke.mjs && node test-no-profile.mjs && node test-phone-boundary.mjs && node test-cache.mjs && node test-profile-inbox.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-weekly.mjs && node test-asks.mjs && node test-precedent.mjs && node test-rules-files.mjs && node test-agent-hook.mjs && node test-learned.mjs && node test-history-import.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 && node test-plugin.mjs",
|
|
104
105
|
"receipts": "node receipts.js list",
|
|
105
106
|
"agreement": "node agreement.js",
|
|
106
107
|
"ship-readout": "node ship-readout.js",
|
package/profile-inbox.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// The phone's pushed profile copy (relay lane `profile_to_agent/<client_id>`, 2026-11-02).
|
|
2
|
+
//
|
|
3
|
+
// The live read (phone-source.js) needs the phone's LAN server up and a Face ID tap, so on the
|
|
4
|
+
// reference Mac it almost never completed and the at-rest copy went days stale — past the 7 days
|
|
5
|
+
// a standing rule may run on (lib/rule-leeway.js). The phone now leaves a fresh copy for this
|
|
6
|
+
// agent whenever its app comes to the foreground or the profile changes (mobile
|
|
7
|
+
// agentProfile.js / agentProfilePush.ts), sealed under this agent's own fp_sync_key. Here we pick
|
|
8
|
+
// it up and make it the at-rest copy.
|
|
9
|
+
//
|
|
10
|
+
// What keeps this safe:
|
|
11
|
+
// - It refreshes the CACHE only. A pushed copy is never "live" (sourceOf stays live:false), so
|
|
12
|
+
// the stale-authority guard and the rule leeway treat it exactly as they treat any cache.
|
|
13
|
+
// - Its age is the PHONE's `generated_at`, not when we drained it, so a copy that sat on the
|
|
14
|
+
// relay for days is days old here too. A time in the future is clamped to now.
|
|
15
|
+
// - Newest wins: a copy no newer than the one we hold is ignored, so the relay cannot roll the
|
|
16
|
+
// profile back to before a rule was removed by replaying an older deposit.
|
|
17
|
+
// - Sealed + tagged: only this agent's pair key opens it, and `kind: 'agent_profile'` stops
|
|
18
|
+
// anything else sealed under that key (our own receipts digest) being read as a profile.
|
|
19
|
+
//
|
|
20
|
+
// Best-effort and throttled (CHECK_GAP_MS, shared across processes through a small state file,
|
|
21
|
+
// since the ship hook runs as a fresh process per tool call): a relay failure leaves the copy we
|
|
22
|
+
// have and never fails the read that asked.
|
|
23
|
+
|
|
24
|
+
import { readFile, writeFile, mkdir } from 'fs/promises';
|
|
25
|
+
import { join } from 'path';
|
|
26
|
+
import { Phaedo, b64uDec } from './lib/phaedo-crypto.js';
|
|
27
|
+
import { readCache, writeCache } from './cache.js';
|
|
28
|
+
import { toVaultPayload } from './phone-source.js';
|
|
29
|
+
import { relayBaseFromEnv } from './receipts-sync.js';
|
|
30
|
+
|
|
31
|
+
const { Kdf, Envelope } = Phaedo;
|
|
32
|
+
export const AGENT_PROFILE_KIND = 'agent_profile';
|
|
33
|
+
export const CHECK_GAP_MS = 10 * 60 * 1000;
|
|
34
|
+
const SEGMENT_RE = /^[A-Za-z0-9_.-]{1,128}$/; // relay/mailbox.js LANE_SUB_RE
|
|
35
|
+
const STATE_FILE = 'profile-inbox.json';
|
|
36
|
+
const TIMEOUT_MS = 3000; // on the ship hook's path: fail fast
|
|
37
|
+
|
|
38
|
+
/** Open a pushed copy → { vault, generatedAt } or null (wrong key, wrong kind, malformed). */
|
|
39
|
+
export async function parseAgentProfileDeposit(pairKeyB64u, envelope) {
|
|
40
|
+
if (!envelope || !pairKeyB64u) return null;
|
|
41
|
+
const pairKey = b64uDec(pairKeyB64u);
|
|
42
|
+
let key;
|
|
43
|
+
try { key = await Kdf.deriveFpSyncKey(pairKey); } finally { pairKey.fill(0); }
|
|
44
|
+
try {
|
|
45
|
+
const plaintext = await Envelope.unwrap(envelope, key);
|
|
46
|
+
let inner;
|
|
47
|
+
try { inner = JSON.parse(new TextDecoder().decode(plaintext)); } finally { if (plaintext.fill) plaintext.fill(0); }
|
|
48
|
+
if (!inner || inner.kind !== AGENT_PROFILE_KIND || !inner.data || typeof inner.data !== 'object') return null;
|
|
49
|
+
const generatedAt = Date.parse(inner.generated_at);
|
|
50
|
+
if (!Number.isFinite(generatedAt)) return null;
|
|
51
|
+
return { vault: toVaultPayload(inner), generatedAt };
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
} finally {
|
|
55
|
+
if (key && key.fill) key.fill(0);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Should a pushed copy replace the cache? Only a strictly newer one. Returns the time to stamp. */
|
|
60
|
+
export function acceptCopy(generatedAt, cachedAt, now = Date.now()) {
|
|
61
|
+
if (!Number.isFinite(generatedAt)) return null;
|
|
62
|
+
const at = Math.min(generatedAt, now); // a phone clock ahead of ours cannot buy freshness
|
|
63
|
+
if (Number.isFinite(cachedAt) && at <= cachedAt) return null;
|
|
64
|
+
return at;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Fetch this agent's pushed copy and, when it is newer than the cache, make it the cache.
|
|
69
|
+
* → { updated: true, generatedAt } | { updated: false, reason }.
|
|
70
|
+
*/
|
|
71
|
+
export async function drainProfileCopy(pairing, stateDir, deps = {}) {
|
|
72
|
+
const fetchFn = deps.fetch || globalThis.fetch;
|
|
73
|
+
const now = deps.now ? deps.now() : Date.now();
|
|
74
|
+
if (!pairing || !pairing.pair_key) return { updated: false, reason: 'unpaired' };
|
|
75
|
+
if (!pairing.relay_device_id) return { updated: false, reason: 'no-relay-device-id' };
|
|
76
|
+
if (typeof pairing.client_id !== 'string' || !SEGMENT_RE.test(pairing.client_id)) return { updated: false, reason: 'no-client-id' };
|
|
77
|
+
try {
|
|
78
|
+
const url = `${deps.relayBase || relayBaseFromEnv()}/fp-mailbox/${encodeURIComponent(pairing.relay_device_id)}/profile_to_agent/${pairing.client_id}`;
|
|
79
|
+
const authHeader = pairing.relay_device_secret
|
|
80
|
+
? { Authorization: `Bearer ${pairing.relay_device_id}:${pairing.relay_device_secret}` }
|
|
81
|
+
: {};
|
|
82
|
+
const res = await fetchFn(url, { method: 'GET', headers: authHeader, signal: AbortSignal.timeout(deps.timeoutMs || TIMEOUT_MS) });
|
|
83
|
+
// 404 = nothing pushed yet; 400 = a relay that predates the lane.
|
|
84
|
+
if (!res || !res.ok) return { updated: false, reason: `http-${res && res.status}` };
|
|
85
|
+
const body = await res.json().catch(() => null);
|
|
86
|
+
const copy = await parseAgentProfileDeposit(pairing.pair_key, body && body.envelope);
|
|
87
|
+
if (!copy) return { updated: false, reason: 'unreadable' };
|
|
88
|
+
const cached = await readCache(stateDir);
|
|
89
|
+
const at = acceptCopy(copy.generatedAt, cached ? Number(cached.cachedAt) : NaN, now);
|
|
90
|
+
if (at == null) return { updated: false, reason: 'not-newer' };
|
|
91
|
+
await writeCache(stateDir, copy.vault, at);
|
|
92
|
+
return { updated: true, generatedAt: at };
|
|
93
|
+
} catch (e) {
|
|
94
|
+
return { updated: false, reason: String(e && e.message || e) };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Throttled `drainProfileCopy`: at most once per CHECK_GAP_MS across every process on this Mac. */
|
|
99
|
+
export async function maybeDrainProfileCopy(loadPairing, stateDir, deps = {}) {
|
|
100
|
+
const now = deps.now ? deps.now() : Date.now();
|
|
101
|
+
const path = join(stateDir, STATE_FILE);
|
|
102
|
+
try {
|
|
103
|
+
const last = Number(JSON.parse(await readFile(path, 'utf8'))?.checked_at);
|
|
104
|
+
if (Number.isFinite(last) && now >= last && now - last < CHECK_GAP_MS) return { updated: false, reason: 'recent' };
|
|
105
|
+
} catch { /* first check, or an unreadable file: check now */ }
|
|
106
|
+
try {
|
|
107
|
+
await mkdir(stateDir, { recursive: true, mode: 0o700 });
|
|
108
|
+
// Claimed BEFORE the request, so the processes that start together do not all ask.
|
|
109
|
+
await writeFile(path, JSON.stringify({ checked_at: now }), { mode: 0o600 });
|
|
110
|
+
} catch { /* a read-only state dir still gets its check */ }
|
|
111
|
+
let pairing = null;
|
|
112
|
+
try { pairing = await loadPairing(); } catch { return { updated: false, reason: 'unpaired' }; }
|
|
113
|
+
return drainProfileCopy(pairing, stateDir, deps);
|
|
114
|
+
}
|
package/pull.js
CHANGED
|
@@ -58,7 +58,8 @@ loadFingerprint(BASE_DIR)
|
|
|
58
58
|
`⚠ Could NOT reach your phone — served a CACHED copy${hours != null ? ` about ${hours}h old` : ''} ` +
|
|
59
59
|
`(fingerprint ${fp.fingerprint_id || '(no id)'}, ${layers} layer(s)).\n` +
|
|
60
60
|
` Anything you changed since then — including a revoked authorization — is NOT reflected.\n` +
|
|
61
|
-
`
|
|
61
|
+
` Opening Phaedo on your phone sends this computer a fresh copy (picked up within 10 minutes);\n` +
|
|
62
|
+
` to read it live instead, keep the app open, approve the card, and run this again.\n`
|
|
62
63
|
);
|
|
63
64
|
process.stdout.write(await week() + review(vd));
|
|
64
65
|
process.exit(2); // distinct from 1 (hard failure) — stale, not broken
|
package/receipts-sync.js
CHANGED
|
@@ -78,7 +78,7 @@ function digestFields(r) {
|
|
|
78
78
|
// Pure-crypto: take the local receipts list, build a §10.4-safe digest of the most
|
|
79
79
|
// recent `cap` entries, and seal it under fp_sync_key. Returns `{ envelope }` — the
|
|
80
80
|
// same shape `agent_to_ext` (delegation-sync) uses, so the relay's mailbox accepts it.
|
|
81
|
-
export async function buildReceiptsDeposit(pairKeyB64u, receipts, { cap = DIGEST_CAP, now = () => new Date().toISOString() } = {}) {
|
|
81
|
+
export async function buildReceiptsDeposit(pairKeyB64u, receipts, { cap = DIGEST_CAP, now = () => new Date().toISOString(), copyAt = null } = {}) {
|
|
82
82
|
const list = Array.isArray(receipts) ? receipts : [];
|
|
83
83
|
// Most-recent N (receipts are append-only, ordered by creation in the store).
|
|
84
84
|
const head = list.slice(-cap).map(digestFields).filter(Boolean);
|
|
@@ -100,7 +100,12 @@ export async function buildReceiptsDeposit(pairKeyB64u, receipts, { cap = DIGEST
|
|
|
100
100
|
// plus the sentence the MCP already shows in /phaedo:status, so both surfaces say the same.
|
|
101
101
|
const w = weeklyHandled(list, { now: Number.isFinite(agreementAt) ? agreementAt : Date.now() });
|
|
102
102
|
const week = { ...w, summary: formatWeekly(w) };
|
|
103
|
-
|
|
103
|
+
// How fresh this computer's profile copy is (cache.js readCacheTime), so the phone can show it.
|
|
104
|
+
// Omitted when there is no copy; never later than this digest.
|
|
105
|
+
const copy = Number.isFinite(copyAt) && copyAt > 0
|
|
106
|
+
? { copy_at: new Date(Math.min(copyAt, Number.isFinite(agreementAt) ? agreementAt : Date.now())).toISOString() }
|
|
107
|
+
: {};
|
|
108
|
+
const inner = { kind: 'receipts_digest', data: { receipts: head, agreement, week, generated_at: generatedAt, ...copy } };
|
|
104
109
|
const plaintext = new TextEncoder().encode(JSON.stringify(inner));
|
|
105
110
|
const envelope = await Envelope.wrap(plaintext, fpSyncKey, { artifact_kind: 'receipts_digest', created_at: now() });
|
|
106
111
|
plaintext.fill(0);
|
package/server.js
CHANGED
|
@@ -28,8 +28,10 @@ import { buildIntentMandatePayload } from './ap2.js';
|
|
|
28
28
|
import { depositSignRequest, pollSignOnce } from './signing-request.js';
|
|
29
29
|
import { buildSigningAuditRecord, appendSigningAudit } from './signing-audit.js';
|
|
30
30
|
import { buildInjectionResponse, errorResponse, PhaedoError } from './projection.js';
|
|
31
|
-
import { resolveConsultation, checkAuthorization, CONSULTATION_TYPES, consultDrivers, withRevision } from './consult.js';
|
|
32
|
-
import { defaultStateDir } from './cache.js';
|
|
31
|
+
import { resolveConsultation, checkAuthorization, standingRuleRelaxes, CONSULTATION_TYPES, consultDrivers, withRevision } from './consult.js';
|
|
32
|
+
import { defaultStateDir, readCacheTime } from './cache.js';
|
|
33
|
+
import { ruleLeeway, RULE_FRESH_DAYS } from './lib/rule-leeway.js';
|
|
34
|
+
import { standingRuleDriver } from './weekly.js';
|
|
33
35
|
import { buildConsultOpts, loadConsultVd } from './consult-runtime.js';
|
|
34
36
|
import { buildReceipt, appendReceipt, receiptsCapFromEnv, readReceipts, markOutcome, USER_ACTIONS } from './receipts.js';
|
|
35
37
|
|
|
@@ -65,12 +67,50 @@ async function emitReceipt(via, args, response, drivers) {
|
|
|
65
67
|
const EXPANDING_SIGNALS = new Set(['proceed', 'autoproceed', 'bias_bold']);
|
|
66
68
|
|
|
67
69
|
/**
|
|
68
|
-
* Downgrade an authority-EXPANDING answer that was computed from a stale (cached) read
|
|
69
|
-
*
|
|
70
|
+
* Downgrade an authority-EXPANDING answer that was computed from a stale (cached) read AND rests
|
|
71
|
+
* on one of the subject's own relaxing rules (`ruleDecided`). Returns the response unchanged for a
|
|
72
|
+
* live read, for any answer that already defers, and for Phaedo's inferred judgment.
|
|
70
73
|
* Always annotates provenance so the caller can SAY where the answer came from — the
|
|
71
74
|
* silence is what let a stale "you're authorized" go unnoticed for a day.
|
|
75
|
+
*
|
|
76
|
+
* RULES ONLY (Randy's decision, 2026-10-31). The danger a cached copy carries is a REVOKED GRANT
|
|
77
|
+
* (the 2026-07-25 $500 payment). An inferred "proceed" is a reading of the subject's disposition,
|
|
78
|
+
* not a revocable permission. Guarding it too would turn nearly every consult into an ask, since
|
|
79
|
+
* the phone is rarely reachable. The question only arose because this guard had never engaged on
|
|
80
|
+
* consult/escalate: the overlay dropped the provenance marker (carrySource fixes that).
|
|
81
|
+
*
|
|
82
|
+
* THE LEEWAY (Randy, 2026-10-31: "align them"). `leeway` = lib/rule-leeway.js said the cached copy
|
|
83
|
+
* may still apply the rule: reversible, stated low/medium, no money, copy ≤ RULE_FRESH_DAYS old,
|
|
84
|
+
* no recent "no" in the domain — the same test the Claude Code ship hook applies before it
|
|
85
|
+
* answers a prompt, so a merge and a direct consult give the same answer. Anything outside it
|
|
86
|
+
* (a payment, an irreversible or high-stakes action, an older copy) is withheld as before.
|
|
72
87
|
*/
|
|
73
|
-
|
|
88
|
+
// Can't tell → treat it as rule-decided, so the guard applies (fail closed).
|
|
89
|
+
function ruleRelaxes(vd, req) {
|
|
90
|
+
try { return standingRuleRelaxes(vd, req, CONSULT_OPTS); } catch { return true; }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// What the guard needs to know about this request, in one place for all three tools. The leeway
|
|
94
|
+
// is only worked out for a cached copy (a live read is never withheld); can't tell → no leeway.
|
|
95
|
+
async function guardFacts(vd, req, ruleDecided = ruleRelaxes(vd, req)) {
|
|
96
|
+
const src = sourceOf(vd);
|
|
97
|
+
if (src.live || !ruleDecided) return { ruleDecided };
|
|
98
|
+
try {
|
|
99
|
+
const receipts = await readReceipts(defaultStateDir());
|
|
100
|
+
return { ruleDecided, leeway: ruleLeeway({ source: src, descriptor: req?.action_descriptor, receipts, now: Date.now() }).ok };
|
|
101
|
+
} catch {
|
|
102
|
+
return { ruleDecided, leeway: false };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// The receipt's note that one of the subject's standing rules let this go ahead (weekly.js counts
|
|
107
|
+
// it for the phone). First in the list, since buildReceipt keeps only the first 16 drivers.
|
|
108
|
+
function ruleAnsweredDriver(vd, req, response) {
|
|
109
|
+
if (response?.signal !== 'proceed') return [];
|
|
110
|
+
try { return standingRuleDriver(checkAuthorization(vd, req, CONSULT_OPTS), response); } catch { return []; }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function withStaleAuthorityGuard(response, vd, { ruleDecided = true, leeway = false } = {}) {
|
|
74
114
|
const src = sourceOf(vd);
|
|
75
115
|
const out = { ...response };
|
|
76
116
|
const hours = src.ageMs != null ? Math.round(src.ageMs / 3600000) : null;
|
|
@@ -79,6 +119,12 @@ function withStaleAuthorityGuard(response, vd) {
|
|
|
79
119
|
: { live: false, cached_age_hours: hours, note: 'Answered from a cached profile — the subject\'s device could not be reached.' };
|
|
80
120
|
if (src.live) return out;
|
|
81
121
|
if (!EXPANDING_SIGNALS.has(String(response?.signal))) return out;
|
|
122
|
+
if (!ruleDecided) return out;
|
|
123
|
+
if (leeway) {
|
|
124
|
+
out.profile_source.note = `Answered from a cached profile${hours != null ? ` about ${hours}h old` : ''}: the subject's standing ` +
|
|
125
|
+
`rule still applies to a reversible, lower-stakes action from a copy up to ${RULE_FRESH_DAYS} days old.`;
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
82
128
|
|
|
83
129
|
out.signal = 'escalate';
|
|
84
130
|
out.authorization_matched = false;
|
|
@@ -113,9 +159,11 @@ async function maybeDepositReceiptsDigest() {
|
|
|
113
159
|
// AND "an existing receipt got marked" (so the next phone drain reflects the mark).
|
|
114
160
|
const latest = receipts[receipts.length - 1];
|
|
115
161
|
const tally = receipts.reduce((acc, r) => { acc[r.user_action ?? 'null'] = (acc[r.user_action ?? 'null'] || 0) + 1; return acc; }, {});
|
|
116
|
-
|
|
162
|
+
// The copy's time rides along, so a fresher copy alone is worth a new digest.
|
|
163
|
+
const copyAt = await readCacheTime(defaultStateDir());
|
|
164
|
+
const sig = `${latest.receipt_id}|${JSON.stringify(tally)}|${copyAt}`;
|
|
117
165
|
if (sig === _lastReceiptsSig) return;
|
|
118
|
-
const res = await depositReceiptsDigest(await loadPairingRecord(BASE_DIR), receipts);
|
|
166
|
+
const res = await depositReceiptsDigest(await loadPairingRecord(BASE_DIR), receipts, { copyAt });
|
|
119
167
|
if (res && res.deposited) _lastReceiptsSig = sig;
|
|
120
168
|
else if (res && !res.deposited && res.reason !== 'no-receipts')
|
|
121
169
|
process.stderr.write(`[phaedo-mcp] receipts digest deposit skipped: ${res.reason || 'http ' + res.status}\n`);
|
|
@@ -334,11 +382,12 @@ server.registerTool(
|
|
|
334
382
|
// profile with no downgrade and no provenance. That is the 2026-07-25 device defect (a
|
|
335
383
|
// revoked $500 grant honoured from a 22h cache) still live on the paths that matter most.
|
|
336
384
|
const raw = await resolveConsultation(vd, args || {}, CONSULT_OPTS);
|
|
337
|
-
const response = withStaleAuthorityGuard(raw, vd);
|
|
385
|
+
const response = withStaleAuthorityGuard(raw, vd, await guardFacts(vd, args || {}));
|
|
338
386
|
// Provenance for the local audit receipt only — the cues that drove this
|
|
339
387
|
// (never in `response`; §10.4). Best-effort: a failure must not block the answer.
|
|
340
388
|
let drivers = [];
|
|
341
389
|
try { drivers = withRevision(consultDrivers(vd, args || {}, CONSULT_OPTS), raw); } catch { /* provenance is non-critical */ }
|
|
390
|
+
drivers = [...ruleAnsweredDriver(vd, args || {}, response), ...drivers];
|
|
342
391
|
const receiptId = await emitReceipt('phaedo_consult', args, response, drivers); // every resolution, abstains included
|
|
343
392
|
if (receiptId) response.receipt_id = receiptId; // the handle for phaedo_record_outcome
|
|
344
393
|
await maybeDepositDelegationPromotions(vd); // act-as-me promotion (best-effort)
|
|
@@ -411,12 +460,13 @@ server.registerTool(
|
|
|
411
460
|
// deterministic pre-flight is a resolution and produces a receipt."
|
|
412
461
|
const preGuard = checkAuthorization(vd, req, CONSULT_OPTS);
|
|
413
462
|
const matchedPreGuard = preGuard.authorization_matched === true;
|
|
414
|
-
const response = withStaleAuthorityGuard(preGuard, vd);
|
|
463
|
+
const response = withStaleAuthorityGuard(preGuard, vd, await guardFacts(vd, req, matchedPreGuard));
|
|
415
464
|
// A matched pre-flight IS a resolved decision (the authorization fired) →
|
|
416
465
|
// receipt. A no-match is not a decision — no receipt (keeps the store from
|
|
417
466
|
// filling with probes; the follow-up phaedo_consult writes its own).
|
|
418
467
|
if (matchedPreGuard) {
|
|
419
|
-
const rid = await emitReceipt('phaedo_check_authorization', req, { signal: response.signal, confidence: 1.0, deference_level: 'high' }
|
|
468
|
+
const rid = await emitReceipt('phaedo_check_authorization', req, { signal: response.signal, confidence: 1.0, deference_level: 'high' },
|
|
469
|
+
ruleAnsweredDriver(vd, req, response));
|
|
420
470
|
if (rid) response.receipt_id = rid;
|
|
421
471
|
await maybeDepositReceiptsDigest(); // G1+G2 audit channel — only on a matched preflight (no-match writes no receipt)
|
|
422
472
|
}
|
|
@@ -550,9 +600,10 @@ server.registerTool(
|
|
|
550
600
|
// and then told the agent the signal was not blocking — i.e. "just proceed", and never
|
|
551
601
|
// wake the subject, on authority that may have been revoked hours earlier.
|
|
552
602
|
const raw = await resolveConsultation(vd, req, CONSULT_OPTS);
|
|
553
|
-
const response = withStaleAuthorityGuard(raw, vd);
|
|
603
|
+
const response = withStaleAuthorityGuard(raw, vd, await guardFacts(vd, req));
|
|
554
604
|
let drivers = [];
|
|
555
605
|
try { drivers = withRevision(consultDrivers(vd, req, CONSULT_OPTS), raw); } catch { /* non-critical */ }
|
|
606
|
+
drivers = [...ruleAnsweredDriver(vd, req, response), ...drivers];
|
|
556
607
|
const escReceiptId = await emitReceipt('phaedo_escalate', req, response, drivers); // the decision being escalated
|
|
557
608
|
await maybeDepositReceiptsDigest(); // G1+G2 audit channel
|
|
558
609
|
|
package/server.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
3
|
"name": "io.github.phaedo-labs/phaedo-mcp",
|
|
4
4
|
"description": "Make AI apps answer your way, check with you before acting, and get approvals on your phone.",
|
|
5
|
-
"version": "0.3.
|
|
5
|
+
"version": "0.3.21",
|
|
6
6
|
"repository": {
|
|
7
7
|
"url": "https://github.com/phaedo-labs/phaedo-mcp",
|
|
8
8
|
"source": "github",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"registryType": "npm",
|
|
14
14
|
"registryBaseUrl": "https://registry.npmjs.org",
|
|
15
15
|
"identifier": "phaedo-mcp",
|
|
16
|
-
"version": "0.3.
|
|
16
|
+
"version": "0.3.21",
|
|
17
17
|
"transport": {
|
|
18
18
|
"type": "stdio"
|
|
19
19
|
}
|
package/weekly.js
CHANGED
|
@@ -10,7 +10,10 @@
|
|
|
10
10
|
// came_to_you — Phaedo said check with you (escalate / clarify)
|
|
11
11
|
// unsure — Phaedo didn't know (insufficient_signal); the agent fell back to its own default
|
|
12
12
|
//
|
|
13
|
-
// "Handled" is went_ahead + stopped.
|
|
13
|
+
// "Handled" is went_ahead + stopped. `by_rule` is the part of went_ahead that one of the subject's
|
|
14
|
+
// standing rules decided — the receipt carries a `standingRule` (server) or `standingGrant` (ship
|
|
15
|
+
// hook) driver saying so — which is how they see a rule they accepted actually being used.
|
|
16
|
+
// Pure over the receipts list; the CLI reads the local store.
|
|
14
17
|
//
|
|
15
18
|
// npm run week
|
|
16
19
|
|
|
@@ -27,14 +30,30 @@ const BUCKET = {
|
|
|
27
30
|
insufficient_signal: 'unsure',
|
|
28
31
|
};
|
|
29
32
|
|
|
33
|
+
// The driver cues that mean "a standing rule answered this" (server.js ruleAnsweredDriver,
|
|
34
|
+
// hooks/pre-ship.mjs). A held grant is recorded as `held:<why>`, so only 'answered' counts.
|
|
35
|
+
export const RULE_CUES = new Set(['standingRule', 'standingGrant']);
|
|
36
|
+
export function answeredByRule(r) {
|
|
37
|
+
return Array.isArray(r?.drivers) && r.drivers.some((d) => RULE_CUES.has(d?.cue) && d?.value === 'answered');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The server's driver for a receipt a standing rule decided: an authorization that matched and
|
|
41
|
+
* said go ahead, AND an answer that still says go ahead after the stale-authority guard — one the
|
|
42
|
+
* guard withheld went to the subject instead, so the rule did not decide it. */
|
|
43
|
+
export function standingRuleDriver(auth, response) {
|
|
44
|
+
return auth?.authorization_matched === true && auth.signal === 'proceed' && response?.signal === 'proceed'
|
|
45
|
+
? [{ cue: 'standingRule', value: 'answered', confidence: 1, basis: 'authored' }] : [];
|
|
46
|
+
}
|
|
47
|
+
|
|
30
48
|
export function weeklyHandled(receipts, { now = Date.now(), days = WEEK_DAYS } = {}) {
|
|
31
49
|
const since = now - days * DAY_MS;
|
|
32
|
-
const out = { days, went_ahead: 0, stopped: 0, came_to_you: 0, unsure: 0 };
|
|
50
|
+
const out = { days, went_ahead: 0, by_rule: 0, stopped: 0, came_to_you: 0, unsure: 0 };
|
|
33
51
|
for (const r of Array.isArray(receipts) ? receipts : []) {
|
|
34
52
|
const t = Date.parse(r?.created_at);
|
|
35
53
|
if (!Number.isFinite(t) || t < since || t > now) continue;
|
|
36
54
|
const b = BUCKET[r?.response?.signal];
|
|
37
55
|
if (b) out[b]++;
|
|
56
|
+
if (b === 'went_ahead' && answeredByRule(r)) out.by_rule++;
|
|
38
57
|
}
|
|
39
58
|
out.handled = out.went_ahead + out.stopped;
|
|
40
59
|
out.total = out.handled + out.came_to_you + out.unsure;
|
|
@@ -53,8 +72,9 @@ export function formatWeekly(w) {
|
|
|
53
72
|
? `This week Phaedo handled ${plural(w.handled, 'decision')} without interrupting you (${parts.join(', ')})`
|
|
54
73
|
: 'This week Phaedo handled no decisions on its own';
|
|
55
74
|
const asked = w.came_to_you ? ` and brought ${w.came_to_you} to you` : '';
|
|
75
|
+
const rule = w.by_rule ? ` Your standing rule decided ${w.handled === 1 ? 'it' : `${w.by_rule === 1 ? 'one' : w.by_rule} of them`}.` : '';
|
|
56
76
|
const unsure = w.unsure ? ` ${w.unsure === 1 ? 'One more was' : `${w.unsure} more were`} too unclear to call.` : '';
|
|
57
|
-
return `${handled}${asked}.${unsure}`;
|
|
77
|
+
return `${handled}${asked}.${rule}${unsure}`;
|
|
58
78
|
}
|
|
59
79
|
|
|
60
80
|
if (isMainModule(import.meta.url)) {
|