phaedo-mcp 0.3.15 → 0.3.19
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/autonomy-promotion.js +4 -1
- 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 +14 -0
- package/hooks/pre-ship.mjs +83 -18
- package/lib/rule-leeway.js +56 -0
- package/package.json +1 -1
- package/server.js +45 -7
- package/server.json +2 -2
package/autonomy-promotion.js
CHANGED
|
@@ -73,7 +73,10 @@ function isPromotableApproval(r) {
|
|
|
73
73
|
if (r?.response?.signal !== 'proceed' && r?.response?.signal !== 'proceed_with_note') return false;
|
|
74
74
|
if (ad.reversible !== true) return false; // irreversible is never auto-proceeded
|
|
75
75
|
if (ad.magnitude === 'high') return false; // nor is a major action
|
|
76
|
-
|
|
76
|
+
// `null` is how the receipt store writes an unstated amount (receipts.js), so it means
|
|
77
|
+
// "unpriced", same as absent. Reading it as malformed refused every stored receipt — no
|
|
78
|
+
// promotion fired on real data from 2026-07-22 to 2026-10-31.
|
|
79
|
+
if (ad.amount != null && !(typeof ad.amount === 'number' && Number.isFinite(ad.amount) && ad.amount >= 0))
|
|
77
80
|
return false; // a malformed amount is not evidence of anything
|
|
78
81
|
if (!ad.domain) return false; // an unscoped autoproceed rule is a blank cheque
|
|
79
82
|
return true;
|
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
|
@@ -256,6 +256,20 @@ export function sourceOf(vd) {
|
|
|
256
256
|
return s || { live: true, cachedAt: null, ageMs: 0 };
|
|
257
257
|
}
|
|
258
258
|
|
|
259
|
+
/**
|
|
260
|
+
* Copy `from`'s provenance onto `to` (a derived copy of it) and return `to`. Any overlay that
|
|
261
|
+
* spreads the fingerprint MUST pass its result through this: an absent marker reads as LIVE,
|
|
262
|
+
* so a lost one makes a stale cached read look fresh to the stale-authority guard.
|
|
263
|
+
*/
|
|
264
|
+
export function carrySource(from, to) {
|
|
265
|
+
const s = from && from[SOURCE];
|
|
266
|
+
if (!s || !to || typeof to !== 'object' || to === from) return to;
|
|
267
|
+
try {
|
|
268
|
+
Object.defineProperty(to, SOURCE, { value: s, enumerable: false, configurable: true, writable: true });
|
|
269
|
+
} catch { /* frozen object — provenance is best-effort, never fatal */ }
|
|
270
|
+
return to;
|
|
271
|
+
}
|
|
272
|
+
|
|
259
273
|
// What the subject removed in `npm run learned` is taken out on EVERY read, so injection,
|
|
260
274
|
// consult and the rules files all stop using it — even when the sessions keep showing it.
|
|
261
275
|
// `unfiltered` is for that review screen alone, which has to list what was removed.
|
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.
|
|
@@ -373,29 +410,40 @@ 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 });
|
|
424
|
+
if (grant) extra.push({ cue: 'standingGrant', value: granted ? 'answered' : `held:${grant.why}`, confidence: null, basis: 'observed' });
|
|
380
425
|
const allDrivers = extra.length ? [...(drivers || []), ...extra] : drivers;
|
|
381
426
|
const receiptId = await deps.writeReceipt({ request, response, drivers: allDrivers });
|
|
382
427
|
if (!receiptId) return null; // no receipt = nothing to learn from; stay out of the way
|
|
383
|
-
const asked = ASKING_SIGNALS.has(signal);
|
|
384
428
|
|
|
385
429
|
const state = readState(deps.statePath);
|
|
386
430
|
if (input.tool_use_id) {
|
|
387
431
|
state.pending[input.tool_use_id] = {
|
|
388
432
|
receipt_id: receiptId, cmd_hash: callHash(input), session_id: input.session_id || null,
|
|
389
|
-
asked, prompt_shown: false, at: new Date(now).toISOString(),
|
|
433
|
+
asked, granted, prompt_shown: false, at: new Date(now).toISOString(),
|
|
390
434
|
};
|
|
391
435
|
}
|
|
392
|
-
let context =
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
436
|
+
let context = granted
|
|
437
|
+
? `Phaedo was consulted before this action (${ship.summary}) and answered "${signal}". receipt_id: ${receiptId}. ${grantContext(receiptId)}`
|
|
438
|
+
: preContext({
|
|
439
|
+
summary: ship.summary, signal, receiptId, asked, recordsNo: Boolean(deps.readTranscript),
|
|
440
|
+
hint: typeof response?.rationale_hint === 'string' ? response.rationale_hint : '',
|
|
441
|
+
});
|
|
396
442
|
if (raised) context += ` GitHub shows ${why}, so Phaedo treated this merge as high-stakes.`;
|
|
397
443
|
const ageDays = source && source.live === false && Number.isFinite(source.ageMs) ? Math.floor(source.ageMs / DAY_MS) : 0;
|
|
398
|
-
|
|
444
|
+
// A rule held back only by the copy's age is worth a refresh nudge before the general one.
|
|
445
|
+
const staleNudge = ageDays >= STALE_PROFILE_DAYS || grant?.why === 'stale';
|
|
446
|
+
if (staleNudge && !(now - Date.parse(state.stale_noted_at) < DAY_MS)) {
|
|
399
447
|
context += ' ' + staleContext(ageDays, deps.mcpDir || MCP_DIR);
|
|
400
448
|
state.stale_noted_at = new Date(now).toISOString();
|
|
401
449
|
}
|
|
@@ -404,6 +452,7 @@ export async function runShipHook(input, deps) {
|
|
|
404
452
|
hookSpecificOutput: {
|
|
405
453
|
hookEventName: 'PreToolUse',
|
|
406
454
|
...(asked ? { permissionDecision: 'ask', permissionDecisionReason: promptReason(signal, ship.summary, why, personalWhy(request.action_descriptor, response)) } : {}),
|
|
455
|
+
...(granted ? { permissionDecision: 'allow', permissionDecisionReason: `Phaedo: your standing rule covers this (${ship.summary}).` } : {}),
|
|
407
456
|
additionalContext: context,
|
|
408
457
|
},
|
|
409
458
|
};
|
|
@@ -452,24 +501,40 @@ export async function runShipHook(input, deps) {
|
|
|
452
501
|
// `agentId` names the agent on the receipt; other agents' hooks (agent-hook.mjs) pass their own.
|
|
453
502
|
export async function realDeps({ agentId = AGENT_ID, statePath } = {}) {
|
|
454
503
|
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');
|
|
504
|
+
const { resolveConsultation, consultDrivers, withRevision, checkAuthorization } = await import('../consult.js');
|
|
505
|
+
const { buildReceipt, appendReceipt, receiptsCapFromEnv, readReceipts } = await import('../receipts.js');
|
|
457
506
|
const { sourceOf } = await import('../fingerprint-source.js');
|
|
458
507
|
const { defaultStateDir } = await import('../cache.js');
|
|
459
508
|
const stateDir = defaultStateDir();
|
|
460
509
|
const quiet = () => {};
|
|
461
510
|
const opts = buildConsultOpts(MCP_DIR, process.env, { warn: quiet });
|
|
511
|
+
let consultedVd = null; // the profile the consult read — the grant is checked against the same copy
|
|
512
|
+
let readSource = null; // ...and how old that copy really is (taken before the overlay drops it)
|
|
462
513
|
return {
|
|
463
514
|
statePath: statePath || join(stateDir, 'ship-hook.json'),
|
|
464
515
|
agentId,
|
|
465
516
|
mcpDir: MCP_DIR,
|
|
466
517
|
prFacts: (ship, cwd) => lookupPrFacts(ship, cwd),
|
|
518
|
+
// Only Claude Code's hook may answer a prompt; other agents' hooks never get this.
|
|
519
|
+
...(agentId === AGENT_ID ? {
|
|
520
|
+
async standingGrant(request, { raised, now }) {
|
|
521
|
+
if (!consultedVd || !readSource) return { ok: false, why: 'no_rule' };
|
|
522
|
+
return standingGrantHeld({
|
|
523
|
+
auth: checkAuthorization(consultedVd, request, opts),
|
|
524
|
+
descriptor: request.action_descriptor, raised, source: readSource,
|
|
525
|
+
receipts: await readReceipts(stateDir), now,
|
|
526
|
+
});
|
|
527
|
+
},
|
|
528
|
+
} : {}),
|
|
467
529
|
async consult(request) {
|
|
468
|
-
const vd = await loadConsultVd(MCP_DIR, { preferCache: true, warn: quiet });
|
|
530
|
+
const vd = await loadConsultVd(MCP_DIR, { preferCache: true, warn: quiet, onRead: (plain) => { readSource = sourceOf(plain); } });
|
|
531
|
+
consultedVd = vd;
|
|
469
532
|
const response = await resolveConsultation(vd, request, opts);
|
|
470
533
|
let drivers = [];
|
|
471
534
|
try { drivers = withRevision(consultDrivers(vd, request, opts), response); } catch { /* provenance is non-critical */ }
|
|
472
|
-
|
|
535
|
+
// The plain read's provenance — `sourceOf(vd)` on the overlay always says "live", which is
|
|
536
|
+
// also why the 14-day refresh nudge below never fired before 0.3.17.
|
|
537
|
+
return { response, drivers, source: readSource };
|
|
473
538
|
},
|
|
474
539
|
async writeReceipt({ request, response, drivers }) {
|
|
475
540
|
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
package/server.js
CHANGED
|
@@ -28,8 +28,9 @@ 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';
|
|
31
|
+
import { resolveConsultation, checkAuthorization, standingRuleRelaxes, CONSULTATION_TYPES, consultDrivers, withRevision } from './consult.js';
|
|
32
32
|
import { defaultStateDir } from './cache.js';
|
|
33
|
+
import { ruleLeeway, RULE_FRESH_DAYS } from './lib/rule-leeway.js';
|
|
33
34
|
import { buildConsultOpts, loadConsultVd } from './consult-runtime.js';
|
|
34
35
|
import { buildReceipt, appendReceipt, receiptsCapFromEnv, readReceipts, markOutcome, USER_ACTIONS } from './receipts.js';
|
|
35
36
|
|
|
@@ -65,12 +66,43 @@ async function emitReceipt(via, args, response, drivers) {
|
|
|
65
66
|
const EXPANDING_SIGNALS = new Set(['proceed', 'autoproceed', 'bias_bold']);
|
|
66
67
|
|
|
67
68
|
/**
|
|
68
|
-
* Downgrade an authority-EXPANDING answer that was computed from a stale (cached) read
|
|
69
|
-
*
|
|
69
|
+
* Downgrade an authority-EXPANDING answer that was computed from a stale (cached) read AND rests
|
|
70
|
+
* on one of the subject's own relaxing rules (`ruleDecided`). Returns the response unchanged for a
|
|
71
|
+
* live read, for any answer that already defers, and for Phaedo's inferred judgment.
|
|
70
72
|
* Always annotates provenance so the caller can SAY where the answer came from — the
|
|
71
73
|
* silence is what let a stale "you're authorized" go unnoticed for a day.
|
|
74
|
+
*
|
|
75
|
+
* RULES ONLY (Randy's decision, 2026-10-31). The danger a cached copy carries is a REVOKED GRANT
|
|
76
|
+
* (the 2026-07-25 $500 payment). An inferred "proceed" is a reading of the subject's disposition,
|
|
77
|
+
* not a revocable permission. Guarding it too would turn nearly every consult into an ask, since
|
|
78
|
+
* the phone is rarely reachable. The question only arose because this guard had never engaged on
|
|
79
|
+
* consult/escalate: the overlay dropped the provenance marker (carrySource fixes that).
|
|
80
|
+
*
|
|
81
|
+
* THE LEEWAY (Randy, 2026-10-31: "align them"). `leeway` = lib/rule-leeway.js said the cached copy
|
|
82
|
+
* may still apply the rule: reversible, stated low/medium, no money, copy ≤ RULE_FRESH_DAYS old,
|
|
83
|
+
* no recent "no" in the domain — the same test the Claude Code ship hook applies before it
|
|
84
|
+
* answers a prompt, so a merge and a direct consult give the same answer. Anything outside it
|
|
85
|
+
* (a payment, an irreversible or high-stakes action, an older copy) is withheld as before.
|
|
72
86
|
*/
|
|
73
|
-
|
|
87
|
+
// Can't tell → treat it as rule-decided, so the guard applies (fail closed).
|
|
88
|
+
function ruleRelaxes(vd, req) {
|
|
89
|
+
try { return standingRuleRelaxes(vd, req, CONSULT_OPTS); } catch { return true; }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// What the guard needs to know about this request, in one place for all three tools. The leeway
|
|
93
|
+
// is only worked out for a cached copy (a live read is never withheld); can't tell → no leeway.
|
|
94
|
+
async function guardFacts(vd, req, ruleDecided = ruleRelaxes(vd, req)) {
|
|
95
|
+
const src = sourceOf(vd);
|
|
96
|
+
if (src.live || !ruleDecided) return { ruleDecided };
|
|
97
|
+
try {
|
|
98
|
+
const receipts = await readReceipts(defaultStateDir());
|
|
99
|
+
return { ruleDecided, leeway: ruleLeeway({ source: src, descriptor: req?.action_descriptor, receipts, now: Date.now() }).ok };
|
|
100
|
+
} catch {
|
|
101
|
+
return { ruleDecided, leeway: false };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function withStaleAuthorityGuard(response, vd, { ruleDecided = true, leeway = false } = {}) {
|
|
74
106
|
const src = sourceOf(vd);
|
|
75
107
|
const out = { ...response };
|
|
76
108
|
const hours = src.ageMs != null ? Math.round(src.ageMs / 3600000) : null;
|
|
@@ -79,6 +111,12 @@ function withStaleAuthorityGuard(response, vd) {
|
|
|
79
111
|
: { live: false, cached_age_hours: hours, note: 'Answered from a cached profile — the subject\'s device could not be reached.' };
|
|
80
112
|
if (src.live) return out;
|
|
81
113
|
if (!EXPANDING_SIGNALS.has(String(response?.signal))) return out;
|
|
114
|
+
if (!ruleDecided) return out;
|
|
115
|
+
if (leeway) {
|
|
116
|
+
out.profile_source.note = `Answered from a cached profile${hours != null ? ` about ${hours}h old` : ''}: the subject's standing ` +
|
|
117
|
+
`rule still applies to a reversible, lower-stakes action from a copy up to ${RULE_FRESH_DAYS} days old.`;
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
82
120
|
|
|
83
121
|
out.signal = 'escalate';
|
|
84
122
|
out.authorization_matched = false;
|
|
@@ -334,7 +372,7 @@ server.registerTool(
|
|
|
334
372
|
// profile with no downgrade and no provenance. That is the 2026-07-25 device defect (a
|
|
335
373
|
// revoked $500 grant honoured from a 22h cache) still live on the paths that matter most.
|
|
336
374
|
const raw = await resolveConsultation(vd, args || {}, CONSULT_OPTS);
|
|
337
|
-
const response = withStaleAuthorityGuard(raw, vd);
|
|
375
|
+
const response = withStaleAuthorityGuard(raw, vd, await guardFacts(vd, args || {}));
|
|
338
376
|
// Provenance for the local audit receipt only — the cues that drove this
|
|
339
377
|
// (never in `response`; §10.4). Best-effort: a failure must not block the answer.
|
|
340
378
|
let drivers = [];
|
|
@@ -411,7 +449,7 @@ server.registerTool(
|
|
|
411
449
|
// deterministic pre-flight is a resolution and produces a receipt."
|
|
412
450
|
const preGuard = checkAuthorization(vd, req, CONSULT_OPTS);
|
|
413
451
|
const matchedPreGuard = preGuard.authorization_matched === true;
|
|
414
|
-
const response = withStaleAuthorityGuard(preGuard, vd);
|
|
452
|
+
const response = withStaleAuthorityGuard(preGuard, vd, await guardFacts(vd, req, matchedPreGuard));
|
|
415
453
|
// A matched pre-flight IS a resolved decision (the authorization fired) →
|
|
416
454
|
// receipt. A no-match is not a decision — no receipt (keeps the store from
|
|
417
455
|
// filling with probes; the follow-up phaedo_consult writes its own).
|
|
@@ -550,7 +588,7 @@ server.registerTool(
|
|
|
550
588
|
// and then told the agent the signal was not blocking — i.e. "just proceed", and never
|
|
551
589
|
// wake the subject, on authority that may have been revoked hours earlier.
|
|
552
590
|
const raw = await resolveConsultation(vd, req, CONSULT_OPTS);
|
|
553
|
-
const response = withStaleAuthorityGuard(raw, vd);
|
|
591
|
+
const response = withStaleAuthorityGuard(raw, vd, await guardFacts(vd, req));
|
|
554
592
|
let drivers = [];
|
|
555
593
|
try { drivers = withRevision(consultDrivers(vd, req, CONSULT_OPTS), raw); } catch { /* non-critical */ }
|
|
556
594
|
const escReceiptId = await emitReceipt('phaedo_escalate', req, response, drivers); // the decision being escalated
|
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.19",
|
|
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.19",
|
|
17
17
|
"transport": {
|
|
18
18
|
"type": "stdio"
|
|
19
19
|
}
|