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.
- package/NOTICE +17 -0
- package/README.md +51 -9
- package/action-binding.js +82 -0
- package/agreement.js +38 -0
- package/ap2.js +214 -0
- package/autonomy-promotion.js +280 -0
- package/build-package.mjs +25 -10
- package/capture-a11y.js +116 -0
- package/capture-local.js +85 -0
- package/capture-sync.js +99 -0
- package/consult-core.js +81 -19
- package/consult-policy.js +427 -33
- package/consult.js +25 -3
- package/delegation-sync.js +89 -0
- package/escalation-policy.sample.json +13 -0
- package/escalation.js +383 -0
- package/fingerprint-source.js +78 -4
- package/health.js +54 -0
- package/hooks/session-end.mjs +240 -0
- package/install.mjs +164 -21
- package/lib/capture-home.js +46 -0
- package/lib/domain.js +101 -0
- package/lib/is-main.js +40 -0
- package/lib/phaedo-crypto.js +17 -1
- package/package.json +26 -6
- package/pairing.js +5 -1
- package/phone-source.js +92 -12
- package/projection.js +4 -1
- package/pull.js +19 -3
- package/receipts-sync.js +197 -0
- package/receipts.js +69 -16
- package/server.js +305 -15
- package/server.json +4 -4
- package/signing-audit.js +111 -0
- package/signing-request.js +217 -0
- package/signing.js +176 -0
- package/vendor/authored-source.js +250 -0
- package/vendor/capabilities.js +203 -0
- package/vendor/capture/capture-core.js +67 -0
- package/vendor/capture/detectors.js +287 -0
- package/vendor/capture/exchanges.js +77 -0
- package/vendor/capture/model-detect.js +195 -0
- package/vendor/capture/owner.js +35 -0
- package/vendor/capture/package.json +4 -0
- package/vendor/capture/source-id.js +56 -0
- package/vendor/capture/state.js +148 -0
- package/vendor/capture/transcript.js +153 -0
- package/vendor/commitments.js +169 -0
- package/vendor/context-block.js +251 -18
- package/vendor/envelope.js +50 -4
- package/vendor/extraction/capture-combine.js +160 -0
- package/vendor/extraction/catalog.js +232 -0
- package/vendor/extraction/delta-lift.js +109 -0
- package/vendor/extraction/fingerprint-update.js +227 -0
- package/vendor/extraction/package.json +4 -0
- package/vendor/extraction/persist.js +40 -0
- package/vendor/extraction/reconcile.js +269 -0
- package/vendor/extraction/resolve.js +178 -0
- package/vendor/extraction/rule-promotion.js +137 -0
- package/vendor/kdf.js +20 -1
- package/vendor/protocol.js +7 -0
- 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
|
|
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) =>
|
|
48
|
-
|
|
49
|
-
|
|
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).
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
|
|
59
|
-
|
|
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) => ({
|
|
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. `
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
// 2. `phaedo_fingerprint.
|
|
102
|
-
//
|
|
103
|
-
//
|
|
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
|
-
|
|
110
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
|
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
|
-
|
|
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) &&
|
|
149
|
-
|
|
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) &&
|
|
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
|
-
|
|
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}
|
|
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
|
|
76
|
-
|
|
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
|
-
|
|
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
|
}
|