phaedo-mcp 0.3.16 → 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/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.
@@ -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
- export async function loadConsultVd(baseDir, { preferCache = false, warn = (m) => process.stderr.write(m) } = {}) {
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
- return withDelegationSignals(vd, signals);
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-
@@ -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.
@@ -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 — ONE RULE. The hook only ever ADDS friction; it never removes it. It
13
- // never returns `allow`, so it cannot pre-approve anything Claude Code would otherwise
14
- // have asked about. On a permissive answer it returns no decision at all (Claude Code's
15
- // own permission flow applies unchanged); on a cautious one it returns `ask`, which puts
16
- // the action in front of the subject. That is why it reads the at-rest profile cache and
17
- // skips the server's stale-authority guard: the guard exists to stop a stale read from
18
- // EXPANDING autonomy, and nothing here can.
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 = preContext({
393
- summary: ship.summary, signal, receiptId, asked, recordsNo: Boolean(deps.readTranscript),
394
- hint: typeof response?.rationale_hint === 'string' ? response.rationale_hint : '',
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
- if (ageDays >= STALE_PROFILE_DAYS && !(now - Date.parse(state.stale_noted_at) < DAY_MS)) {
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
- return { response, drivers, source: sourceOf(vd) };
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "phaedo-mcp",
3
- "version": "0.3.16",
3
+ "version": "0.3.19",
4
4
  "private": false,
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
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
- * Returns the response unchanged for a live read, or for any answer that already defers.
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
- function withStaleAuthorityGuard(response, vd) {
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.16",
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",
16
+ "version": "0.3.19",
17
17
  "transport": {
18
18
  "type": "stdio"
19
19
  }