phaedo-mcp 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/NOTICE +17 -0
  2. package/README.md +51 -9
  3. package/action-binding.js +82 -0
  4. package/agreement.js +38 -0
  5. package/ap2.js +214 -0
  6. package/autonomy-promotion.js +280 -0
  7. package/build-package.mjs +25 -10
  8. package/capture-a11y.js +116 -0
  9. package/capture-local.js +85 -0
  10. package/capture-sync.js +99 -0
  11. package/consult-core.js +81 -19
  12. package/consult-policy.js +427 -33
  13. package/consult.js +25 -3
  14. package/delegation-sync.js +89 -0
  15. package/escalation-policy.sample.json +13 -0
  16. package/escalation.js +383 -0
  17. package/fingerprint-source.js +78 -4
  18. package/health.js +54 -0
  19. package/hooks/session-end.mjs +240 -0
  20. package/install.mjs +164 -21
  21. package/lib/capture-home.js +46 -0
  22. package/lib/domain.js +101 -0
  23. package/lib/is-main.js +40 -0
  24. package/lib/phaedo-crypto.js +17 -1
  25. package/package.json +26 -6
  26. package/pairing.js +5 -1
  27. package/phone-source.js +92 -12
  28. package/projection.js +4 -1
  29. package/pull.js +19 -3
  30. package/receipts-sync.js +197 -0
  31. package/receipts.js +69 -16
  32. package/server.js +305 -15
  33. package/server.json +4 -4
  34. package/signing-audit.js +111 -0
  35. package/signing-request.js +217 -0
  36. package/signing.js +176 -0
  37. package/vendor/authored-source.js +250 -0
  38. package/vendor/capabilities.js +203 -0
  39. package/vendor/capture/capture-core.js +67 -0
  40. package/vendor/capture/detectors.js +287 -0
  41. package/vendor/capture/exchanges.js +77 -0
  42. package/vendor/capture/model-detect.js +195 -0
  43. package/vendor/capture/owner.js +35 -0
  44. package/vendor/capture/package.json +4 -0
  45. package/vendor/capture/source-id.js +56 -0
  46. package/vendor/capture/state.js +148 -0
  47. package/vendor/capture/transcript.js +153 -0
  48. package/vendor/commitments.js +169 -0
  49. package/vendor/context-block.js +251 -18
  50. package/vendor/envelope.js +50 -4
  51. package/vendor/extraction/capture-combine.js +160 -0
  52. package/vendor/extraction/catalog.js +232 -0
  53. package/vendor/extraction/delta-lift.js +109 -0
  54. package/vendor/extraction/fingerprint-update.js +227 -0
  55. package/vendor/extraction/package.json +4 -0
  56. package/vendor/extraction/persist.js +40 -0
  57. package/vendor/extraction/reconcile.js +269 -0
  58. package/vendor/extraction/resolve.js +178 -0
  59. package/vendor/extraction/rule-promotion.js +137 -0
  60. package/vendor/kdf.js +20 -1
  61. package/vendor/protocol.js +7 -0
  62. package/vendored-files.mjs +82 -0
package/consult-policy.js CHANGED
@@ -18,11 +18,41 @@
18
18
  // a local JSON via PHAEDO_CONSULT_POLICIES works today for testing). §10.3 response
19
19
  // shape is preserved — policy provenance goes in `rationale_hint` for transparency.
20
20
 
21
+ // §5.7 commitments (decision state) project into the same rule shape. commitments.js
22
+ // is a CLASSIC script shared with the popup (the sync-merge.js / rule-review.js
23
+ // pattern), so it loads through createRequire — and it lives at the REPO ROOT, which
24
+ // the published npm tarball does not ship. Same prefer-vendor/fall-back-to-root
25
+ // resolution lib/phaedo-crypto.js uses, so the server works from both a dev clone and
26
+ // an npm install; build-package.mjs vendors it.
27
+ import { createRequire } from 'node:module';
28
+ import { existsSync } from 'node:fs';
29
+ import { dirname, resolve } from 'node:path';
30
+ import { fileURLToPath } from 'node:url';
31
+ // Required for its SIDE EFFECT, then read off the global the IIFE sets — exactly what
32
+ // lib/phaedo-crypto.js does with the other vendored classic modules. The destructured
33
+ // form looks cleaner and is wrong: mcp/package.json is `type: module`, so a vendored
34
+ // `.js` under mcp/vendor/ is parsed as ESM, `module.exports` never runs, and the
35
+ // require() returns an empty object. That failure is SILENT here (loadPolicies is
36
+ // defensive by design), which would have shipped a server where every commitment
37
+ // quietly stopped gating — so the global is the load-bearing path, not a fallback.
38
+ const _here = dirname(fileURLToPath(import.meta.url));
39
+ const _vendored = resolve(_here, 'vendor', 'commitments.js');
40
+ createRequire(import.meta.url)(existsSync(_vendored) ? _vendored : resolve(_here, '..', 'commitments.js'));
41
+ const { commitmentsAsPolicies } = globalThis.PhaedoCommitments;
42
+ import { normalizeDomain, canonicalDomain } from './lib/domain.js';
43
+
21
44
  const MAG = { low: 0, medium: 1, high: 2 };
22
45
  const LADDER = ['proceed', 'proceed_with_note', 'clarify', 'escalate']; // bold → cautious
23
46
  // "Force" effects set the signal outright (the user's hard pre-decision) — these are
24
47
  // the authorizations. (bias_* effects are soft nudges, not authorizations.)
25
48
  const FORCE = { escalate: 'escalate', require_human: 'escalate', decline: 'decline', autoproceed: 'proceed' };
49
+ // Membership must be an OWN-property test. `'constructor' in FORCE` is true via Object.prototype,
50
+ // so `in` let a rule with effect:"constructor" past the malformed-rule filter and then yielded the
51
+ // Object CONSTRUCTOR as its signal — which JSON.stringify drops, deleting `signal` (a REQUIRED
52
+ // field) from the response entirely. An agent doing `if (resp.signal === 'escalate')` falls
53
+ // through to its own default, so a malformed rule silently overrode a cautious inferred signal at
54
+ // confidence 1.0. Same reason `toString`/`valueOf`/`hasOwnProperty` must not be members.
55
+ const isForce = (e) => Object.hasOwn(FORCE, e);
26
56
  const FORCE_NOTES = {
27
57
  escalate: 'a standing rule surfaces this for a live decision',
28
58
  require_human: 'a standing rule requires human review here',
@@ -30,33 +60,143 @@ const FORCE_NOTES = {
30
60
  autoproceed: 'a standing rule pre-approves low-stakes actions here',
31
61
  };
32
62
 
63
+ // Effects that EXPAND the agent's autonomy. Only these are gated by
64
+ // `require_binding` and `agents` (§10.5): a rule that makes the agent more
65
+ // deferential must keep applying whether or not the request is bound and whoever
66
+ // is serving it, or either gate would become a way to shed guardrails — by
67
+ // omitting a field, or by running an unlisted client. Authority-narrowing only —
68
+ // the same fail-safe direction as `expires_at`.
69
+ const RELAXING = new Set(['autoproceed', 'bias_bold']);
70
+ // How restrictive a FORCE effect is, by the signal it yields (v0.1.69). Used only to break a tie
71
+ // between EQUALLY SPECIFIC force rules: the more restrictive one governs, so the outcome no longer
72
+ // depends on array position when two restrictive rules of different strength collide. Strictly
73
+ // greater replaces; equal keeps the earlier rule, so a full tie (same specificity, same effect)
74
+ // still resolves by author order and the resolver stays deterministic.
75
+ const STRICTNESS = { decline: 2, escalate: 1, proceed: 0 };
76
+ const strictnessOf = (rule) => STRICTNESS[FORCE[rule.effect]] ?? 0;
77
+
33
78
  const magRank = (m) => MAG[String(m || '').toLowerCase()] ?? 1;
79
+ /**
80
+ * A magnitude on a RULE BOUND, or null if it is not one of the three known values.
81
+ *
82
+ * `magRank` defaults an unknown value to 1 (medium). On a REQUEST that is the reading a RESTRICTIVE
83
+ * rule keeps (a stated-but-invalid value is rejected upstream by `normalizeRequest`; an UNSTATED
84
+ * one reaches here as undefined since v0.1.68 and is handled in `ruleMatches`: it satisfies no
85
+ * magnitude bound on a relaxing rule). On a rule bound it silently WIDENED a grant: `magnitude_max: "minimal"` from a
86
+ * typo or a non-reference producer produced a cap of medium, so an `autoproceed` covered
87
+ * medium-magnitude actions the subject never authorised. Unknown on a bound now yields null,
88
+ * which `boundsMatch` reads as "no bound expressed" for a cap — see the call site.
89
+ */
90
+ const magBound = (m) => (Object.prototype.hasOwnProperty.call(MAG, String(m || '').toLowerCase())
91
+ ? MAG[String(m).toLowerCase()] : null);
34
92
 
35
93
  // Normalize a raw policy doc (array, or { rules: [...] }) → usable rules. Defensive:
36
94
  // silently skips malformed/unknown-effect rules. Never throws.
95
+ /**
96
+ * `expires_at` → an epoch-ms deadline, or null for "no expiry".
97
+ *
98
+ * Fail-CLOSED on anything present that cannot be read as an RFC 3339 instant: the caller
99
+ * intended a bound, and the safe reading of an unintelligible bound is "already past", never
100
+ * "unbounded". Only a genuinely absent field means no expiry.
101
+ */
102
+ function expiresAtOf(v) {
103
+ if (v === undefined || v === null) return null; // absent → no expiry
104
+ if (typeof v !== 'string') return 0; // e.g. epoch-ms number → lapsed
105
+ const t = Date.parse(v);
106
+ return Number.isFinite(t) ? t : 0; // unparseable → lapsed
107
+ }
108
+
37
109
  export function normalizePolicies(raw) {
38
110
  const rules = Array.isArray(raw) ? raw : (raw && Array.isArray(raw.rules) ? raw.rules : []);
111
+ // §10.5 TOMBSTONES. The sync merge already drops revoked rules from `rules`, so this is a
112
+ // belt-and-braces read: a fingerprint that arrived from a producer which does not implement
113
+ // tombstones, or a restored backup predating the revocation, can still carry a rule the
114
+ // subject revoked. The schema makes dropping it a consumer MUST, and the failure direction
115
+ // matters — honoring a revoked `autoproceed` grants autonomy that was explicitly taken back.
116
+ const revokedRaw = (raw && !Array.isArray(raw) && Array.isArray(raw.revoked)) ? raw.revoked : [];
117
+ const revoked = new Set(
118
+ revokedRaw.map((t) => (t && typeof t === 'object' && t.id != null ? String(t.id) : null)).filter(Boolean)
119
+ );
39
120
  const out = [];
40
121
  for (const r of rules) {
41
122
  if (!r || typeof r !== 'object') continue;
123
+ if (r.id != null && revoked.has(String(r.id))) continue; // revoked → never resolves
42
124
  const effect = String(r.effect || '').toLowerCase();
43
- if (!(effect in FORCE) && effect !== 'bias_cautious' && effect !== 'bias_bold') continue;
125
+ if (!isForce(effect) && effect !== 'bias_cautious' && effect !== 'bias_bold') continue;
44
126
  const m = (r.match && typeof r.match === 'object') ? r.match : {};
45
127
  out.push({
46
128
  id: r.id || null,
47
- domains: (Array.isArray(m.domains) ? m.domains : []).map((d) => String(d).toLowerCase()).filter(Boolean),
48
- magMin: m.magnitude_min != null ? magRank(m.magnitude_min) : null,
49
- magMax: m.magnitude_max != null ? magRank(m.magnitude_max) : null,
129
+ domains: (Array.isArray(m.domains) ? m.domains : []).map((d) => normalizeDomain(d)).filter(Boolean),
130
+ // §10.2 vocabulary form of the same scope, used ONLY by the restrictive-rule
131
+ // alias path in domainCovers — see the safety argument there. Precomputed so
132
+ // matching stays allocation-free on the hot path.
133
+ domainsCanonical: (Array.isArray(m.domains) ? m.domains : []).map((d) => canonicalDomain(d)).filter(Boolean),
134
+ // An UNRECOGNISED bound is dropped rather than defaulted. For magMax on a relaxing rule
135
+ // that is the fail-closed direction: `magMaxUnknown` below turns the whole rule inert
136
+ // rather than letting a typo widen it to medium.
137
+ magMin: m.magnitude_min != null ? magBound(m.magnitude_min) : null,
138
+ magMax: m.magnitude_max != null ? magBound(m.magnitude_max) : null,
139
+ // Remember that a cap was INTENDED but could not be read — an unreadable cap must not
140
+ // become an absent one on a grant.
141
+ magMaxUnknown: m.magnitude_max != null && magBound(m.magnitude_max) === null,
50
142
  reversible: typeof m.reversible === 'boolean' ? m.reversible : null,
51
143
  amountGt: typeof m.amount_gt === 'number' && isFinite(m.amount_gt) ? m.amount_gt : null,
144
+ amountLt: typeof m.amount_lt === 'number' && isFinite(m.amount_lt) ? m.amount_lt : null,
145
+ // §10.5 `amount_lte` — an INCLUSIVE ceiling (M7). `amount_lt` is exclusive, and §10.7 set
146
+ // its ceiling to the SECOND-HIGHEST amount approved, so a subject who approved five $20
147
+ // purchases minted `amount_lt: 20` — a grant that could never fire for the very class that
148
+ // earned it. Exclusive is right for a FLOOR on a prohibition ("escalate above $1,000");
149
+ // inclusive is right for a CEILING on a grant ("auto-proceed up to $20"), which is what the
150
+ // evidence actually says.
151
+ //
152
+ // Both may be present. They are then both enforced, which is the more restrictive reading —
153
+ // a rule carrying two ceilings is ambiguous authoring, and ambiguity on a grant fails closed.
154
+ amountLte: typeof m.amount_lte === 'number' && isFinite(m.amount_lte) ? m.amount_lte : null,
52
155
  // Conditional + revocable authority (§10.5). `expiresAt` is ENFORCED: a lapsed
53
156
  // authorization stops firing (time-bound = revocable, no identity needed; the
54
- // direction is always authority-NARROWING, so it's fail-safe). `agents` is
55
- // RESERVED (parsed, not yet enforced): scoping authority to specific agents needs
56
- // an AUTHENTICATED agent identity — a self-asserted agent_id would be false
57
- // security — so enforcement waits for that (build-later).
58
- expiresAt: typeof r.expires_at === 'string' && Number.isFinite(Date.parse(r.expires_at)) ? Date.parse(r.expires_at) : null,
59
- agents: Array.isArray(r.agents) ? r.agents.map((a) => String(a).toLowerCase()).filter(Boolean) : [],
157
+ // direction is always authority-NARROWING, so it's fail-safe).
158
+ //
159
+ // `agents` is now ENFORCED too, against the SERVING PAIRED CLIENT's own
160
+ // `client_id` — never against the self-asserted `agent_id`, which stays an
161
+ // untrusted audit label. One MCP server process serves exactly one paired
162
+ // client, so the server does not have to ask who is calling: it knows who it
163
+ // is, from its own `pairing.json`. Nothing agent-supplied enters the decision.
164
+ // See `docs/roadmap/paired-client-identity-2026-07-22.md`.
165
+ //
166
+ // Compared EXACTLY: these are opaque, phone-issued identifiers, so case is
167
+ // significant and lowercasing them (as the RESERVED parse used to) could alias
168
+ // two distinct clients. Trimmed only.
169
+ //
170
+ // ABSENT vs EMPTY are DIFFERENT, and the distinction is load-bearing:
171
+ // null → no scope was authored → the rule is unscoped and applies to all
172
+ // [] → a scope WAS authored and it names nobody → matches no client
173
+ // Collapsing both to [] (as the first cut did) would make an authoring UI that
174
+ // deselects every client silently grant UNIVERSAL autonomy — the exact inverse
175
+ // of what the subject just expressed, and in the widening direction.
176
+ //
177
+ // Entries must actually BE strings. Coercing with String() would turn `[null]`
178
+ // into a rule scoped to the literal "null", and `[42]` into one that matches a
179
+ // client whose id is "42" — type confusion inventing principals that don't
180
+ // exist. Non-strings are dropped, matching how every other field here is
181
+ // type-checked rather than coerced.
182
+ // ABSENT and UNPARSEABLE are different, and conflating them failed OPEN. Absent means
183
+ // "no expiry"; a value that is present but does not parse used to become null, i.e. the
184
+ // grant became PERMANENT. A typo, or a producer writing epoch-ms instead of RFC 3339, was
185
+ // enough — and §10.5 calls expiry the fail-safe revocation mechanism. `ap2.js` computes a
186
+ // mandate expiry from these too, so an unparseable one there produced an Intent Mandate
187
+ // with `expires_at: null`: an UNBOUNDED payment credential presented to a merchant.
188
+ // Present-but-unparseable is now treated as ALREADY LAPSED (0), so ruleActive() is false.
189
+ expiresAt: expiresAtOf(r.expires_at),
190
+ agents: Array.isArray(r.agents) ? r.agents.filter((a) => typeof a === 'string').map((a) => a.trim()).filter(Boolean) : null,
191
+ // §10.2/§10.5 binding gate: when true, this rule only RELAXES for a request
192
+ // bound to a concrete call (action_binding). Defaults FALSE so every existing
193
+ // authorization keeps its current behaviour — the tightening is opt-in per
194
+ // rule, and the promotion path (which authors rules from observed approvals)
195
+ // always sets it.
196
+ requireBinding: r.require_binding === true,
197
+ // §5.7 provenance. Only `commitment` is meaningful today; it makes a cautious
198
+ // nudge from decision state explain itself (see applyPolicies).
199
+ ruleKind: r.rule_kind === 'commitment' ? 'commitment' : null,
60
200
  effect,
61
201
  note: typeof r.note === 'string' ? r.note : null,
62
202
  });
@@ -73,7 +213,18 @@ function authorizationsFromStandingRules(fp) {
73
213
  const rules = Array.isArray(fp?.standing_rules) ? fp.standing_rules : [];
74
214
  return rules
75
215
  .filter((r) => r && r.kind === 'authorization' && r.effect)
76
- .map((r) => ({ id: r.instruction_id || null, match: r.match || {}, effect: r.effect, note: r.note || r.text || null, expires_at: r.expires_at, agents: r.agents }));
216
+ .map((r) => ({
217
+ id: r.instruction_id || null,
218
+ match: r.match || {},
219
+ effect: r.effect,
220
+ note: r.note || r.text || null,
221
+ expires_at: r.expires_at,
222
+ agents: r.agents,
223
+ // Must be carried: the authored home is where real authorizations live, so
224
+ // dropping it here would leave the §10.5 binding gate declared-but-inert for
225
+ // every rule a subject actually wrote.
226
+ require_binding: r.require_binding,
227
+ }));
77
228
  }
78
229
 
79
230
  // Project a bound `kind:"instruction"` (Proposal 0007 §D) into a consultation BIAS.
@@ -94,39 +245,147 @@ function biasesFromStandingRules(fp) {
94
245
  }
95
246
 
96
247
  // Merge policy sources, the authored home first:
97
- // 1. `phaedo_fingerprint.standing_rules` — the Proposal 0007 authored home:
98
- // kind:authorization → a deterministic gate; kind:instruction bound negative →
99
- // a cautious bias (§D). Rides `inner.data` on the phone pull, travels everywhere
100
- // the fingerprint does (extension ↔ phone ↔ MCP) with NO profile-schema change.
101
- // 2. `phaedo_fingerprint.consult_policies` — DEPRECATED (0007) back-compat read path.
102
- // 3. a top-level vault sibling (`phaedo_consult_policies`) — DEPRECATED, local/extension use.
103
- // 4. opts.policies — a loaded local file (server: mcp/consult-policies.json).
248
+ // 1. the §5 User Configuration sibling (`vd.phaedo_user_configuration`) — CANONICAL
249
+ // when present (config home migration, part 2): its `standing_instructions`
250
+ // REPLACE the fingerprint A-home for the authored read (the two homes are
251
+ // dual-home lockstep, so reading both would double every rule).
252
+ // 2. `phaedo_fingerprint.standing_rules` — the Proposal 0007 A-home (used when no
253
+ // sibling travels yet): kind:authorization → a deterministic gate;
254
+ // kind:instruction bound negative → a cautious bias (§D). Rides `inner.data`
255
+ // on the phone pull with NO profile-schema change.
256
+ // 3. `phaedo_fingerprint.consult_policies` — DEPRECATED (0007) back-compat read path.
257
+ // 4. a top-level vault sibling (`phaedo_consult_policies`) — DEPRECATED, local/extension use.
258
+ // 5. opts.policies — a loaded local file (server: mcp/consult-policies.json).
104
259
  // Force rules (authorizations) always take precedence over bias rules in applyPolicies,
105
260
  // so ordering within the list only affects which same-class rule wins (first match).
106
261
  // Defensive — never throws.
107
262
  export function loadPolicies(vd, opts = {}) {
108
263
  let list = [];
109
- try { list = list.concat(normalizePolicies(authorizationsFromStandingRules(vd?.phaedo_fingerprint))); } catch { /* ignore */ }
110
- try { list = list.concat(normalizePolicies(biasesFromStandingRules(vd?.phaedo_fingerprint))); } catch { /* ignore */ }
264
+ const cfg = vd?.phaedo_user_configuration;
265
+ const fp = vd?.phaedo_fingerprint;
266
+ // §5.4 — the configuration must name THIS fingerprint. Higher stakes here than on any render
267
+ // path: these entries become AUTHORIZATIONS, so a configuration belonging to another identity
268
+ // (or to a fingerprint that has since been replaced by a restore or a cross-device merge)
269
+ // would decide what this agent may do without asking. Falls back to the A-home rather than
270
+ // throwing — a consult that cannot load policies must still be able to run the safe path.
271
+ const cfgGoverns = !!(cfg && typeof cfg === 'object' && fp && typeof fp === 'object'
272
+ && typeof fp.fingerprint_id === 'string' && fp.fingerprint_id
273
+ && cfg.fingerprint_id === fp.fingerprint_id
274
+ && !(typeof fp.subject_id === 'string' && fp.subject_id
275
+ && typeof cfg.subject_id === 'string' && cfg.subject_id
276
+ && cfg.subject_id !== fp.subject_id));
277
+ const authoredHome = (cfgGoverns && Array.isArray(cfg.standing_instructions))
278
+ ? { standing_rules: cfg.standing_instructions } // §5 canonical — same entry shape by construction
279
+ : fp;
280
+ // §10.5 TOMBSTONES APPLY ACROSS BOTH HOMES. `authorizationsFromStandingRules` returns a bare
281
+ // array, so normalizePolicies saw an EMPTY tombstone set and a revoked grant stayed live —
282
+ // while `ap2.js` cross-applied the same tombstones correctly. One artifact, two answers, and
283
+ // the permissive one is the consult path, which is what actually authorizes the agent to act.
284
+ // The schema states dropping a tombstoned rule as a consumer MUST.
285
+ const revoked = Array.isArray(vd?.phaedo_fingerprint?.consult_policies?.revoked)
286
+ ? vd.phaedo_fingerprint.consult_policies.revoked : [];
287
+ const withTombstones = (rules) => ({ rules, revoked });
288
+ try { list = list.concat(normalizePolicies(withTombstones(authorizationsFromStandingRules(authoredHome)))); } catch { /* ignore */ }
289
+ try { list = list.concat(normalizePolicies(withTombstones(biasesFromStandingRules(authoredHome)))); } catch { /* ignore */ }
290
+ // §5.7 decision STATE. Projected into the same rule shape so precedence, expiry
291
+ // and the deterministic pre-flight all work unchanged. Ordered AFTER authorizations
292
+ // deliberately: an authored gate is a durable policy, a commitment is a temporary
293
+ // fact about right now, and where both match the standing policy should speak first
294
+ // (both can only be deferential or more so, so neither ordering is unsafe).
295
+ try { list = list.concat(normalizePolicies(commitmentsAsPolicies(vd?.phaedo_fingerprint, opts))); } catch { /* ignore */ }
111
296
  try { list = list.concat(normalizePolicies(vd?.phaedo_fingerprint?.consult_policies)); } catch { /* ignore */ }
112
297
  try { list = list.concat(normalizePolicies(vd?.phaedo_consult_policies)); } catch { /* ignore */ }
113
298
  try { if (opts.policies) list = list.concat(normalizePolicies(opts.policies)); } catch { /* ignore */ }
114
299
  return list;
115
300
  }
116
301
 
117
- function ruleMatches(rule, n) {
118
- if (rule.domains.length) {
119
- const d = String(n.domain || '').toLowerCase();
120
- if (!d || !rule.domains.some((k) => d.includes(k))) return false;
302
+ /** `key` appears in `d` delimited by a non-alphanumeric character (or a string edge). */
303
+ function wordBoundaryMatch(d, key) {
304
+ if (!key) return false;
305
+ if (d === key) return true;
306
+ const esc = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
307
+ return new RegExp(`(^|[^a-z0-9])${esc}([^a-z0-9]|$)`).test(d);
308
+ }
309
+
310
+ // Does this rule's domain scope cover the action?
311
+ //
312
+ // Two paths, and the asymmetry is the whole safety argument for the §10.2 vocabulary:
313
+ //
314
+ // SUBSTRING (unchanged, always available) — today's behaviour, on normalized
315
+ // strings. A rule scoped to `finance` covers a `finance.crypto_trading` action.
316
+ //
317
+ // CANONICAL EQUALITY (new, RESTRICTIVE effects only) — a rule scoped to `financial`
318
+ // also covers a `finance` action, because the published vocabulary says they are one
319
+ // domain. This path is DENIED to relaxing effects on purpose: an alias table can be
320
+ // wrong, and a wrong alias must never hand an agent autonomy the subject did not
321
+ // grant. It may only ever make a GUARDRAIL cover more. So the worst case for a bad
322
+ // entry in that table is "an agent asks when it needn't" — never the reverse.
323
+ //
324
+ // Purely additive for restrictive rules: nothing that matches today stops matching, so
325
+ // no guardrail can be lost by adopting the vocabulary.
326
+ function domainCovers(rule, n) {
327
+ if (!rule.domains.length) return true;
328
+ const d = normalizeDomain(n.domain);
329
+ if (!d) return false;
330
+ // A RELAXING rule matches on a WORD BOUNDARY, not a bare substring. Plain `includes` let
331
+ // `search` cover `research`, `ops` cover `devops` and `legal` cover `paralegal` — authority the
332
+ // subject never granted, from a coincidental letter overlap.
333
+ //
334
+ // NOT exact-or-dot-parent (escalation.js's rule): domains here are PHRASES, not dot-identifiers
335
+ // — a `draft` rule legitimately covers a `draft email` action, and the shipped suite pins that.
336
+ // A boundary keeps both real cases (`draft email`, `finance.crypto_trading`) and drops every
337
+ // coincidental one.
338
+ //
339
+ // Restrictive effects keep the broader substring reach on purpose: for a prohibition,
340
+ // over-matching is the SAFE direction.
341
+ if (RELAXING.has(rule.effect)) {
342
+ return rule.domains.some((k) => wordBoundaryMatch(d, k));
121
343
  }
344
+ if (rule.domains.some((k) => d.includes(k))) return true;
345
+ // Compare canonical to canonical. An early draft guarded this with `c !== d`, on the
346
+ // assumption that canonicalization only ever moves the ACTION — but the alias is just
347
+ // as often on the RULE ("financial" rule, "finance" action), and that guard silently
348
+ // disabled exactly the case the vocabulary exists for.
349
+ const c = canonicalDomain(d);
350
+ return !!c && rule.domainsCanonical.some((k) => k === c);
351
+ }
352
+
353
+ function ruleMatches(rule, n) {
354
+ if (!domainCovers(rule, n)) return false;
355
+ // AN UNSTATED MAGNITUDE SATISFIES NO MAGNITUDE BOUND ON A RELAXING RULE (v0.1.68). It used to be
356
+ // ranked `medium` — here and, before 2026-09-14, on the wire in normalizeRequest — so a grant the
357
+ // subject capped at `magnitude_max: medium` fired for an action whose stakes the agent simply
358
+ // never stated. That is the `amount` rule's mirror image done wrong: an unpriced action satisfies
359
+ // no priced ceiling, and the agent is the party who decides whether to state either field — the
360
+ // fifth instance of "the party being gated authors the field" (v0.1.48, .53, .56, .66). Now a
361
+ // grant qualified by magnitude does not fire on a request that carries none, exactly as one
362
+ // qualified by amount does not. Restrictive rules keep the `medium` reading: a prohibition should
363
+ // not stop biting because the agent left a field out, and `medium` is the same middle it always
364
+ // was for them (a floor at `high` still does not fire on an unknown, as an `amount_gt` floor does
365
+ // not fire on an unpriced action — "cannot assert it is over the bar").
366
+ const magUnknown = magBound(n.magnitude) === null;
367
+ if (magUnknown && RELAXING.has(rule.effect) && (rule.magMin != null || rule.magMax != null || rule.magMaxUnknown)) return false;
122
368
  const mag = magRank(n.magnitude);
123
369
  if (rule.magMin != null && mag < rule.magMin) return false;
124
370
  if (rule.magMax != null && mag > rule.magMax) return false;
371
+ // A cap the subject wrote but we cannot read makes a RELAXING rule inert. Absent means "no
372
+ // cap"; unreadable means "they meant a cap", and the safe reading of an unintelligible cap on
373
+ // a grant is that it does not apply. Restrictive effects are unaffected — a prohibition with
374
+ // a broken bound should still bite.
375
+ if (rule.magMaxUnknown && RELAXING.has(rule.effect)) return false;
125
376
  if (rule.reversible != null && n.reversible !== rule.reversible) return false;
126
- // Numeric threshold (e.g. "over $5,000"): a rule with amount_gt only matches when
377
+ // Numeric FLOOR (e.g. "over $5,000"): a rule with amount_gt only matches when
127
378
  // the action carries a numeric amount strictly above it. No amount on the action →
128
379
  // the threshold rule does not fire (cannot assert it's over the bar).
129
380
  if (rule.amountGt != null && !(typeof n.amount === 'number' && n.amount > rule.amountGt)) return false;
381
+ // Numeric CEILING (e.g. "under $75"): the mirror, and it matters most on a relaxing
382
+ // rule. Same no-amount handling ON PURPOSE — an unpriced action must NOT slip under
383
+ // a ceiling nobody can check. Both gates fail closed when the amount is absent, which
384
+ // means adding an amount bound can only ever narrow a rule, never widen it.
385
+ if (rule.amountLt != null && !(typeof n.amount === 'number' && n.amount < rule.amountLt)) return false;
386
+ // Inclusive ceiling (M7). Like `amountLt`, an unpriced request does NOT satisfy a priced bound:
387
+ // a rule the subject qualified by money must not fire on a request that carries no amount.
388
+ if (rule.amountLte != null && !(typeof n.amount === 'number' && n.amount <= rule.amountLte)) return false;
130
389
  return true;
131
390
  }
132
391
 
@@ -143,10 +402,128 @@ function ruleActive(rule, now) {
143
402
  return !(rule.expiresAt != null && now >= rule.expiresAt);
144
403
  }
145
404
 
146
- export function matchAuthorization(n, policies, { now = Date.now() } = {}) {
405
+ // A `require_binding` rule is INERT for an unbound request — but only when its
406
+ // effect would expand autonomy. See RELAXING above.
407
+ function bindingSatisfied(rule, n) {
408
+ if (!rule.requireBinding || !RELAXING.has(rule.effect)) return true;
409
+ return !!(n && n.bound);
410
+ }
411
+
412
+ // §10.5 `agents` — scoping authority to specific PAIRED CLIENTS.
413
+ //
414
+ // `clientId` is the serving MCP server's OWN `client_id`, read from its
415
+ // `pairing.json` by the transport layer — a phone-issued identifier established
416
+ // out-of-band over ECDH with a SAS the subject verified on a separate device. It is
417
+ // not presented by the caller and cannot be self-asserted, which is exactly why it
418
+ // can be gated on where `agent_id` cannot.
419
+ //
420
+ // Fail-safe, in the same direction as everything else in §10.5:
421
+ // - unscoped rule (no `agents`) → applies, unchanged
422
+ // - restrictive effect → applies regardless of scope, or `agents`
423
+ // would be a way to shed a guardrail by
424
+ // running an unlisted client
425
+ // - relaxing effect + unpaired/unknown id → INERT ("does not apply"), never
426
+ // broader, never an error
427
+ //
428
+ // A caller with no identity therefore has nothing to match, so scoped authority is
429
+ // simply not extended to it. Note there is deliberately NO analogue of
430
+ // `bindingSuppressed` here: telling an unlisted client that some rule would have
431
+ // relaxed for a *different* client leaks the shape of the subject's other grants,
432
+ // which §10.4 does not permit. Binding is a fixable omission worth reporting; being
433
+ // the wrong principal is not.
434
+ // Ordered so the RESTRICTIVE check comes first and nothing below it can throw: this
435
+ // function sits on the path of every consultation, and `normalizePolicies` promises
436
+ // the file never throws. A rule reaching here without a well-formed `agents` (a
437
+ // hand-built policy object bypassing normalization — the exported API allows it) must
438
+ // fail INERT for a relaxing effect, never with a TypeError that aborts the whole
439
+ // consultation. Same defensive posture as `bindingSatisfied`.
440
+ function agentScopeSatisfied(rule, clientId) {
441
+ if (!RELAXING.has(rule.effect)) return true; // restrictive → always applies
442
+ const scope = rule.agents;
443
+ if (scope == null) return true; // no scope authored → unscoped
444
+ if (!Array.isArray(scope)) return false; // malformed → fail closed
445
+ if (typeof clientId !== 'string' || !clientId) return false;
446
+ return scope.includes(clientId); // [] matches nobody, by design
447
+ }
448
+
449
+ // Did a rule match on its own terms and get held back ONLY by the binding gate?
450
+ // Worth reporting: otherwise an agent whose standing auto-proceed rule silently
451
+ // stops firing has no way to learn that it needs to send an action_binding.
452
+ // Scope-inert rules are excluded FIRST, so a rule that does not apply to this client
453
+ // can't be reported as "held back only by the binding gate" — that would disclose it.
454
+ export function bindingSuppressed(n, policies, { now = Date.now(), clientId = null } = {}) {
455
+ if (!policies || !policies.length || !n || n.type === 'voice_draft') return false;
456
+ return policies.some((r) => ruleActive(r, now) && agentScopeSatisfied(r, clientId)
457
+ && ruleMatches(r, n) && !bindingSatisfied(r, n));
458
+ }
459
+
460
+
461
+ // ── §10.5 precedence: SPECIFICITY, then restrictive, then author order (M3) ──────────────────
462
+ //
463
+ // It used to be FIRST MATCH, full stop, and that was the one §10.5 ordering that resolved in the
464
+ // WIDENING direction: a blanket `autoproceed` authored in January pre-empted an `escalate above
465
+ // $1,000` authored in June, purely because it came first in the array. Every other asymmetry in
466
+ // this file — expiry, `require_binding`, `agents` on relaxing effects, an unreadable
467
+ // `magnitude_max` — narrows authority when something is unclear. This one granted it.
468
+ //
469
+ // THE NAIVE FIX IS WRONG, and the shipped suite proved it before this one shipped. "Restrictive
470
+ // always wins" makes an agent-scoped grant inert the moment any general prohibition exists — and
471
+ // an agent-scoped grant IS a targeted exemption, the subject naming one client they trust with
472
+ // something. Class-based precedence deletes the ability to express an exception at all.
473
+ //
474
+ // So: the MORE SPECIFIC rule wins, and specificity is the number of dimensions on which the
475
+ // subject narrowed the rule's applicability. A rule that names a domain, a price ceiling and a
476
+ // client said more about when it should apply than a blanket one, and the more considered
477
+ // statement should govern — which is how policy resolution works everywhere it is done well
478
+ // (longest-prefix routing, CSS, IAM).
479
+ //
480
+ // TIES GO TO THE RESTRICTIVE EFFECT. Two rules that narrowed on the same number of dimensions are
481
+ // equally considered, and at that point the fail-safe direction decides — a missing grant costs a
482
+ // question, a missing prohibition costs the action. Only when both are equally specific AND the
483
+ // same class does author order break the tie, which keeps the result deterministic.
484
+ //
485
+ // COUNTED, not weighted. A weighting would need a defensible ordering between "named a domain"
486
+ // and "named a price", and there isn't one — any ranking would encode a preference the subject
487
+ // never expressed. Counting says only what is true: this rule is qualified in more ways.
488
+ function specificity(rule) {
489
+ let n = 0;
490
+ if (Array.isArray(rule.domains) && rule.domains.length) n++; // named a domain
491
+ if (Array.isArray(rule.agents)) n++; // scoped to named clients ([] counts: it IS a scope)
492
+ if (rule.amountGt != null) n++; // a floor
493
+ if (rule.amountLt != null || rule.amountLte != null) n++; // a ceiling, either form
494
+ if (rule.magMin != null) n++;
495
+ if (rule.magMax != null || rule.magMaxUnknown) n++; // an intended cap counts even if unreadable
496
+ if (rule.reversible != null) n++;
497
+ if (rule.requireBinding) n++; // only relaxes for a bound request
498
+ if (rule.expiresAt != null) n++; // time-bounded
499
+ return n;
500
+ }
501
+
502
+ /** The governing force rule among those that already matched, or null. */
503
+ function mostSpecificForce(matched) {
504
+ let best = null;
505
+ let bestScore = -1;
506
+ for (const r of matched) {
507
+ if (!isForce(r.effect)) continue;
508
+ const score = specificity(r);
509
+ if (best === null || score > bestScore) { best = r; bestScore = score; continue; }
510
+ // Equal specificity: the MORE RESTRICTIVE effect outranks (decline > escalate > autoproceed).
511
+ // This used to be a two-class test (restrictive beats relaxing), which left escalate-vs-decline
512
+ // at equal specificity to ARRAY ORDER — and a sync merge reorders arrays, so which of two
513
+ // restrictive rules governed could flip between devices (audit M3, 2026-09-13). Strictly greater
514
+ // replaces; equal keeps the EARLIER rule, so a full tie stays deterministic by author order.
515
+ if (score === bestScore && strictnessOf(r) > strictnessOf(best)) {
516
+ best = r;
517
+ }
518
+ }
519
+ return best;
520
+ }
521
+
522
+ export function matchAuthorization(n, policies, { now = Date.now(), clientId = null } = {}) {
147
523
  if (!policies || !policies.length || n.type === 'voice_draft') return null;
148
- const matched = policies.filter((r) => ruleActive(r, now) && ruleMatches(r, n));
149
- const force = matched.find((r) => r.effect in FORCE);
524
+ const matched = policies.filter((r) => ruleActive(r, now) && agentScopeSatisfied(r, clientId)
525
+ && ruleMatches(r, n) && bindingSatisfied(r, n));
526
+ const force = mostSpecificForce(matched);
150
527
  if (!force) return null;
151
528
  return { rule: force, signal: FORCE[force.effect], note: force.note || FORCE_NOTES[force.effect] };
152
529
  }
@@ -156,10 +533,10 @@ export function matchAuthorization(n, policies, { now = Date.now() } = {}) {
156
533
  // Else apply the first matching BIAS rule (nudge one step on the ladder). Returns a
157
534
  // NEW response (§10.3 shape preserved; provenance in rationale_hint). voice_draft is
158
535
  // never policy-overridden (it's a redirect, not a decision).
159
- export function applyPolicies(response, n, policies, { now = Date.now() } = {}) {
536
+ export function applyPolicies(response, n, policies, { now = Date.now(), clientId = null } = {}) {
160
537
  if (!policies || !policies.length || n.type === 'voice_draft') return response;
161
538
 
162
- const auth = matchAuthorization(n, policies, { now });
539
+ const auth = matchAuthorization(n, policies, { now, clientId });
163
540
  if (auth) {
164
541
  return {
165
542
  ...response,
@@ -170,16 +547,33 @@ export function applyPolicies(response, n, policies, { now = Date.now() } = {})
170
547
  };
171
548
  }
172
549
 
173
- const matched = policies.filter((r) => ruleActive(r, now) && ruleMatches(r, n));
550
+ const matched = policies.filter((r) => ruleActive(r, now) && agentScopeSatisfied(r, clientId)
551
+ && ruleMatches(r, n) && bindingSatisfied(r, n));
174
552
  const bias = matched[0];
175
- if (!bias) return response;
553
+ // A relaxing rule that matched but was held back by the binding gate is worth
554
+ // saying out loud — silence would look like a broken rule.
555
+ const bindingNote = bindingSuppressed(n, policies, { now, clientId })
556
+ ? ' (A standing rule that would have expanded autonomy here did not apply: this request is not bound to a concrete action.)'
557
+ : '';
558
+ if (!bias) {
559
+ return bindingNote
560
+ ? { ...response, rationale_hint: `${response.rationale_hint}${bindingNote}`.slice(0, 280) }
561
+ : response;
562
+ }
176
563
  const i = LADDER.indexOf(response.signal);
177
564
  if (i === -1) return response; // e.g. `decline` — not on the proceed↔escalate ladder
178
565
  const j = Math.max(0, Math.min(LADDER.length - 1, i + (bias.effect === 'bias_cautious' ? 1 : -1)));
179
566
  if (j === i) return response;
567
+ // A disposition nudge says "per your preference"; a §5.7 commitment says what it
568
+ // is, because "you are currently bound by something" is the whole reason the signal
569
+ // moved and an agent that isn't told cannot act sensibly on it. The note is already
570
+ // disclosure-filtered (opaque unless the subject marked the entry disclosable).
571
+ const why = bias.ruleKind === 'commitment' && bias.note
572
+ ? `(Nudged cautious — ${bias.note}.)`
573
+ : `(Nudged ${bias.effect === 'bias_cautious' ? 'cautious' : 'bold'} per your preference.)`;
180
574
  return {
181
575
  ...response,
182
576
  signal: LADDER[j],
183
- rationale_hint: `${response.rationale_hint} (Nudged ${bias.effect === 'bias_cautious' ? 'cautious' : 'bold'} per your preference.)`.slice(0, 280),
577
+ rationale_hint: `${response.rationale_hint} ${why}${bindingNote}`.slice(0, 280),
184
578
  };
185
579
  }
package/consult.js CHANGED
@@ -72,10 +72,25 @@ async function modelJudge(vd, n, opts) {
72
72
  if (!res.ok) throw new Error(`model endpoint ${res.status}`);
73
73
  const data = await res.json();
74
74
  const out = parseJudge(data?.choices?.[0]?.message?.content || '');
75
- // §10.4 enforced on parse: only the four signal fields ever leave the function.
76
- return shape(n.type, out.signal, out.confidence ?? 0.6, out.rationale_hint, out.deference_level);
75
+ // §10.4 enforced STRUCTURALLY: the model's free-text rationale_hint is NOT passed
76
+ // through — a misbehaving or prompt-injected judge could otherwise emit verbatim
77
+ // fingerprint/persona text there (the JUDGE_SYSTEM "never quote" line is a request,
78
+ // not enforcement). We keep the model's decision (signal/confidence/deference) and
79
+ // substitute a fixed, persona-free hint keyed to the signal. parseJudge still
80
+ // requires a non-empty rationale_hint so the model must emit well-formed JSON.
81
+ return shape(n.type, out.signal, out.confidence ?? 0.6, MODEL_SAFE_HINT[out.signal] || 'Your decision pattern bears on this action.', out.deference_level);
77
82
  }
78
83
 
84
+ // Persona-free, signal-keyed hints for the model path (see §10.4 note above).
85
+ const MODEL_SAFE_HINT = {
86
+ proceed: 'Your decision pattern supports proceeding here.',
87
+ proceed_with_note: 'Your decision pattern supports proceeding, with a light note.',
88
+ clarify: 'Your decision pattern suggests clarifying before acting.',
89
+ escalate: 'Your decision pattern suggests holding for your explicit go-ahead.',
90
+ decline: 'Your decision pattern leans against this action.',
91
+ insufficient_signal: 'Your fingerprint does not clearly speak to this action.',
92
+ };
93
+
79
94
  // ── Config + orchestrator ─────────────────────────────────────────────────────
80
95
  // Local-only by default. `PHAEDO_CONSULT_MODEL` opts INTO the model judge (point
81
96
  // it at a general chat model in Ollama, e.g. `llama3.1:8b` — NOT the extraction
@@ -87,6 +102,11 @@ export function consultOptsFromEnv(env = process.env) {
87
102
  apiKey: env.PHAEDO_CONSULT_API_KEY || null, // for an OpenAI-compatible endpoint
88
103
  timeoutMs: Number(env.PHAEDO_CONSULT_TIMEOUT_MS) || 15000,
89
104
  policies: null, // a loaded policy doc; the server fills this from the policy file
105
+ // §10.5 `agents`: this server's OWN paired-client id, filled by the server from
106
+ // `pairing.json` (see readServingClientId). Null = unpaired, which makes every
107
+ // `agents`-scoped relaxing rule inert. Never sourced from the environment or the
108
+ // caller — it must not be spoofable by whatever launched the process.
109
+ clientId: null,
90
110
  };
91
111
  }
92
112
 
@@ -110,7 +130,9 @@ export async function resolveConsultation(vd, request = {}, opts = {}) {
110
130
  try {
111
131
  const judged = await modelJudge(vd, n, opts);
112
132
  // Authored guardrails override the model judge too (applied last so they win).
113
- return applyPolicies(judged, n, loadPolicies(vd, opts));
133
+ // Same §10.5 context as the deterministic path — including the serving
134
+ // `client_id`, so `agents` scoping is not bypassed by configuring a model.
135
+ return applyPolicies(judged, n, loadPolicies(vd, opts), { now: opts.now, clientId: opts.clientId ?? null });
114
136
  } catch (err) {
115
137
  process.stderr.write?.(`[phaedo-consult] model judge fell back to rules: ${err.message}\n`);
116
138
  }