candor-ts 0.23.0 → 0.24.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/policy.mjs CHANGED
@@ -13,20 +13,138 @@ export const EFFECTS = ["Net", "Fs", "Db", "Exec", "Env", "Clock", "Ipc", "Log",
13
13
  // candor-rust's — the mapping below mirrors java's prefix-based ReasonClass.classify(String).
14
14
  export const REASON_CLASSES = ["reflect", "dispatch", "indirect", "native", "unresolved", "setup"];
15
15
  // `dynamic` = every GENUINE blind-spot class (excludes `setup`), incl. `unresolved` so it never under-gates.
16
- const DYNAMIC_CLASSES = ["reflect", "dispatch", "indirect", "native", "unresolved"];
16
+ // Exported so the `--class` flag (query-core) resolves the alias from THIS list rather than a second copy:
17
+ // the flag and the policy filter name the same vocabulary, so `--class dynamic` and `Unknown[dynamic]`
18
+ // cannot drift into meaning different sets.
19
+ export const DYNAMIC_CLASSES = ["reflect", "dispatch", "indirect", "native", "unresolved"];
17
20
  /** Map a raw `unknownWhy` token (e.g. `reflect:eval`, `callback:fetch`) to its normative reason class. */
18
21
  export function reasonClass(why) {
19
22
  const w = String(why).trim().toLowerCase();
20
- if (w.startsWith("reflect") || w === "dynamicmemberlookup") return "reflect";
23
+ // `startsWith`, NOT `===`. candor-swift emits this token in the normative `kind:detail` form —
24
+ // `dynamicMemberLookup:<root>.<prop>` (CallCollector.swift) — and never bare, so an equality test can
25
+ // never match a real one and the token falls through to the `unresolved` catch-all below. Both classes
26
+ // are in DYNAMIC_CLASSES, so a bare `deny Unknown` is unaffected; what silently weakens is the
27
+ // class-targeted `deny Unknown[reflect]`, which is the form the reason ratchet is adopted in. Found by
28
+ // the swift sweep (the same equality test made `Unknown[reflect]` unsatisfiable there even in one tree)
29
+ // and fixed in candor-java as `d9b07b0`. Widening to a prefix is monotone: only ever MORE tokens reach
30
+ // `reflect`, never fewer.
31
+ if (w.startsWith("reflect") || w.startsWith("dynamicmemberlookup")) return "reflect";
21
32
  if (w.startsWith("native")) return "native";
22
33
  if (w.startsWith("callback") || w.startsWith("closure") || w.startsWith("task-handoff")) return "indirect";
23
34
  if (w.startsWith("dispatch") || w.startsWith("indy") || w.startsWith("ambiguous")) return "dispatch";
24
35
  if (w.startsWith("missing-config") || w.startsWith("no-tsconfig") || w.startsWith("no-node_modules")) return "setup";
25
36
  return "unresolved"; // conservative catch-all
26
37
  }
38
+
39
+ // ⟨0.24⟩ THE reason-class resolution — ONE copy, for the GATE (evaluatePolicy, over the scan's in-memory
40
+ // graph) and for the DISCLOSURE (`unverified --class`, over a loaded report). SPEC §6.2: "THE GATE AND THE
41
+ // DISCLOSURE MUST APPLY THE SAME RULE, AND SHOULD SHARE THE SAME CODE." They did not: the gate resolved
42
+ // transitively and the query open-coded a second, direct-only match — two implementations of one rule
43
+ // inside one engine, drifting because nothing compared them, and the consumer-side one under-reported the
44
+ // very holes the verb exists to name. Measured before the repair, `--class dynamic` (an alias for every
45
+ // genuine class, so it must exclude NOTHING) against unfiltered `unverified`: 207→173 on this engine's own
46
+ // sources, 268→158 on execa, 64→21 on got. So: no second fixpoint, no second match rule.
47
+ //
48
+ // `unknownWhy` is DIRECT-ONLY by design (§4: a reason names a site in the function's OWN body), so a
49
+ // function whose Unknown is purely INHERITED carries no reason of its own — 24% of Unknown-bearing entries
50
+ // on this engine's sources and 57-58% on execa/got. Matching against the direct field reads a field that
51
+ // answers a different question.
52
+ export function resolveReasonClasses(functions, callgraph = {}) {
53
+ const acc = new Map();
54
+ // ⟨0.24⟩ THE REACH IS THE REPORT'S OWN, not the sidecar's. §2 embeds the call edges per entry (`calls`)
55
+ // precisely so a consumer without the sidecar can reconstruct the graph, and rust (`reason_class_acc`),
56
+ // java (`gateInputFromReport`: `edges…addAll(e.calls())`) and swift all resolve the reason classes over
57
+ // THAT field. candor-ts read `<prefix>.callgraph.json` and nothing else, so a report whose sidecar was
58
+ // deleted / never written / left behind in another workspace silently degraded to a DIRECT-ONLY
59
+ // resolution — every inherited hole's class set came back empty, and the empty set then matched every
60
+ // filter (see reasonClassesMatch). Two independent faults compounding into one wrong answer: `--class
61
+ // unresolved` selected `b_reasoned_only`, whose Unknown its callee classified `dispatch`, which is the
62
+ // literal outcome §6.2 requirement 3 forbids. Seeding from `calls` also makes the answer BYTE-IDENTICAL
63
+ // with and without the sidecar, which is the property the other three engines have and this one lacked.
64
+ // UNION rather than either-or: the sidecar can carry edges an older report's entries don't, and more
65
+ // edges only ever ADD classes — the fail-closed direction for the gate that shares this resolution.
66
+ const edges = new Map();
67
+ const addEdge = (caller, callee) => {
68
+ let s = edges.get(caller);
69
+ if (!s) { s = new Set(); edges.set(caller, s); }
70
+ s.add(callee);
71
+ };
72
+ for (const f of functions ?? []) for (const c of f.calls ?? []) addEdge(f.fn, c);
73
+ for (const [caller, callees] of Object.entries(callgraph ?? {})) {
74
+ for (const c of Array.isArray(callees) ? callees : []) addEdge(caller, c);
75
+ }
76
+ for (const f of functions ?? []) {
77
+ const cs = new Set((f.unknownWhy ?? []).map(reasonClass));
78
+ // ⟨0.24⟩ CONTRIBUTES, not "is treated as". A DIRECT Unknown the function did not name ADDS
79
+ // `unresolved` HERE, at the node that owns the hole — never as a fallback on the JOINED set being
80
+ // empty. Emptiness is not upward-closed, so the old absence-keyed default was REMOVED by acquiring a
81
+ // second, classifiable reason: a caller of one reasonless dep was rejected by `Unknown[unresolved]`,
82
+ // a caller of one reasoned dep was not, and a caller of BOTH — strictly worse-known than the first —
83
+ // passed. Adding a call turned a red verdict green (the monotone-denial corollary).
84
+ // Gated on a DIRECT Unknown (§6.2 req 3), never on the reason set merely being absent: absence is
85
+ // also exactly what an INHERITED Unknown looks like, and tagging one of those `unresolved` when its
86
+ // callee classified it perfectly well is the mirror fabrication.
87
+ // candor-ts's own reports cannot reach this branch — its emitter already writes `["unresolved"]` on a
88
+ // direct Unknown it could not name (scan.mjs), which is the same rule one layer earlier, at the source.
89
+ // It fires for a FOREIGN report (java/rust/swift/an older build), which every query verb also reads.
90
+ if ((f.direct ?? []).includes("Unknown") && !(f.unknownWhy ?? []).length) cs.add("unresolved");
91
+ if (cs.size) acc.set(f.fn, cs);
92
+ }
93
+ // …then propagate over the call graph to a fixpoint, so `Unknown[reflect]` at a caller inheriting
94
+ // Unknown from a reflect-caused callee still fires (matches java/rust reasonClassAcc).
95
+ for (let changed = true; changed; ) {
96
+ changed = false;
97
+ for (const [caller, callees] of edges) {
98
+ for (const callee of callees) {
99
+ const cc = acc.get(callee);
100
+ if (!cc) continue;
101
+ let set = acc.get(caller);
102
+ if (!set) { set = new Set(); acc.set(caller, set); }
103
+ for (const c of cc) if (!set.has(c)) { set.add(c); changed = true; }
104
+ }
105
+ }
106
+ }
107
+ return acc;
108
+ }
109
+
110
+ /** ⟨0.24⟩ The class MATCH, shared by the gate and `unverified --class`. `classes` = a fn's resolved class
111
+ * set (resolveReasonClasses); `filter` = the rule's / the flag's class tokens (an array or a Set). A null
112
+ * filter means no narrowing.
113
+ *
114
+ * THE UNCLASSIFIABLE CASE PROJECTS TO `{unresolved}` — it is not "kept by every filter". §6.2: a hole
115
+ * nothing classified CONTRIBUTES `unresolved`, and `unresolved` is one class among six, so the hole is
116
+ * kept by `unresolved` and by `dynamic` (which names every genuine class) and dropped by `native`,
117
+ * `reflect`, `dispatch`, `indirect` and `setup` — the spec's own discrimination control says post-repair
118
+ * `--class native` selects 0. Byte-aligns with rust `reason_class_matches` (its None/empty arms both read
119
+ * `want.contains(Unresolved)`) and java `reasonClassesOf` (`Set.of(UNRESOLVED)`).
120
+ *
121
+ * A blanket `return true` here was the mirror of the fail-open it replaced, and worse on two counts.
122
+ * (1) `setup`: `dynamic` deliberately EXCLUDES `setup` as non-genuine, so an entry matching `--class
123
+ * setup` because nothing classified it is doubly wrong. (2) It re-opened requirement 3 through the MATCH
124
+ * arm rather than the contribution: an inherited `Unknown` its callee classified `dispatch` was selected
125
+ * by `--class unresolved` whenever the reach could not be walked, which is the literal outcome §6.2
126
+ * forbids — and the requirement-3 gating in resolveReasonClasses, which is correct, cannot prevent it
127
+ * because the empty set never reaches that code. Measured cross-engine on identical bytes before the
128
+ * repair: `--class native` on an orphan-callee report gave rust/java/swift `[]` and ts `['app.orphan']`.
129
+ *
130
+ * This is still the fail-CLOSED direction for the case that made a blanket keep tempting: a hole no
131
+ * filter would name is kept by the two filters an adopter narrows THROUGH (`unresolved`, `dynamic`),
132
+ * never dropped by all six. What it refuses is asserting a class the engine does not have evidence for. */
133
+ export function reasonClassesMatch(classes, filter) {
134
+ if (!filter) return true;
135
+ const has = filter instanceof Set ? (c) => filter.has(c) : (c) => filter.includes(c);
136
+ if (!classes || classes.size === 0) return has("unresolved");
137
+ for (const c of classes) if (has(c)) return true;
138
+ return false;
139
+ }
27
140
  // The literal surfaces `allow` can restrict. `Llm` ⟨0.13⟩ rides Net's host literal (SPEC §1) —
28
141
  // `allow Llm <host…>` restricts which MODEL hosts a scope may reach, matched by hostname like Net.
29
142
  const ALLOW_EFFECTS = new Set(["Net", "Exec", "Fs", "Db", "Llm"]);
143
+ // The two effect-position vocabularies, as sorted arrays. `deny`'s is every effect plus the §4 trust
144
+ // marker; `allow`'s is the CLOSED set of effects that carry a literal surface a value list can restrict.
145
+ const EFFECT_VOCAB = [...EFFECTS, "Unknown"].sort();
146
+ const ALLOW_VOCAB = [...ALLOW_EFFECTS].sort();
147
+ const ALLOW_FORM = ["allow <Effect> [in <scope>] <value…>"];
30
148
 
31
149
  // The §6.2 token separator: ASCII whitespace ONLY (space/tab/LF/VT/FF/CR). JS `\s`/`String.trim` strip
32
150
  // Unicode spaces (NBSP, ideographic, …) that Java drops — a gateless-green cross-engine divergence
@@ -36,8 +154,79 @@ const ASCII_WS_TRIM = /^[ \t\n\v\f\r]+|[ \t\n\v\f\r]+$/g;
36
154
  // ⟨0.19⟩ `aliases` (a Map name→class-token[], from `.candor/config` `unknown-alias`) lets an `Unknown[<name>]`
37
155
  // filter resolve a user-defined name (SPEC §6.2). A config alias never changes what bare `deny E Unknown`
38
156
  // means (always `Unknown[*]`), so a rule's denied set stays legible from the policy alone.
157
+ //
158
+ // ⟨0.24⟩ RETURNS `errors` AND `aliasesUsed` BESIDE THE RULES (SPEC §6.2 `382a7e0`/`be0b9a9`, §3.1 `99eb4e9`).
159
+ // The rule objects are UNCHANGED — a token this parser cannot honour is still recorded in the rule exactly
160
+ // as before, so `parsepolicy` can still show what was written — but an UNRECOGNISED VALUE TOKEN now lands
161
+ // in `errors`, and every verdict-bearing call site refuses (exit 2) instead of running a policy it silently
162
+ // rewrote. Measured on this engine before the change, over a report whose only `Unknown` is reflect-caused:
163
+ //
164
+ // deny Unknown[dispatch,nativ] app -> exit 0, rule kept as `Unknown[dispatch]` NARROWS
165
+ // deny Net[known-partner,unkown-host] app -> exit 0, rule kept as `Net[known-partner]` NARROWS
166
+ // deny Unknown[corp] app -> exit 1, rule kept as bare `deny Unknown` WIDENS
167
+ //
168
+ // The NARROWING half is the fail-open and it is the common case — a typo lands beside correct tokens far
169
+ // more often than alone — and the rule stops gating what the operator spelled while the gate still looks
170
+ // armed. The WIDENING half is loud but announces "ignoring policy rule" and then KEEPS a DIFFERENT rule,
171
+ // which is a false disclosure. Neither is a policy the operator wrote.
172
+ //
173
+ // ⟨0.24⟩ AND `errors` COVERS EVERY LINE THIS PARSER DID NOT HONOUR, NOT ONLY UNRECOGNISED TOKENS (SPEC §3.1
174
+ // `195d45a`). Measured against the reference engine on the conformance battery: java reported 12, candor-ts
175
+ // 5 — the seven missing were LINES DROPPED WHOLE (an unknown effect name, an `allow` on an effect that
176
+ // takes no operand, two malformed `forbid`s, two `allow`s naming no values, an unknown rule kind), each
177
+ // warned on stderr and invisible to the machine output. **A dropped rule is the LIMIT CASE of "silently
178
+ // rewritten into a different policy": the rewritten policy is the one without that line**, which is a
179
+ // BIGGER rewrite than a narrowed filter, not a smaller one. The witness was disclosing the two cases that
180
+ // prompted the clause and silent on the ones that did not.
181
+ export const POLICY_ERROR_KINDS = ["reason-class/alias", "Net destination-class", "effect-name", "rule-kind"];
182
+ // ⟨0.24⟩ FATAL vs DISCLOSED-ONLY. `errors` is ONE list because §3.1 pins one list, but it carries two
183
+ // populations and only the verdict-bearing call sites need the distinction, so it is derived from `kind`
184
+ // rather than stored.
185
+ //
186
+ // ⟨0.24⟩ `effect-name` IS FATAL (SPEC §6.2 `1e1748a`) — a typo'd EFFECT NAME deleted the rule, silently,
187
+ // four-way green. Measured on all four engines:
188
+ //
189
+ // deny Nett app -> rust 0 ts 0 java 0 swift 0 the rule is DELETED, the gate is green
190
+ // allow Nett host.example -> rust 0 ts 0 java 0 swift 0 the certification silently vanishes
191
+ //
192
+ // The operator reads an armed `deny Net`; there is no gate at all. This engine already called a dropped
193
+ // rule the LIMIT CASE of silently rewriting the policy — a BIGGER rewrite than a narrowed filter — and the
194
+ // bigger one was warning-only while the smaller one was exit 2. The grammar defence for leaving it open is
195
+ // real but narrower than it was taken to be, and the two conditions that escape it are exactly the ones
196
+ // that raise this kind:
197
+ // · `allow`'s effect position is a FIXED, CLOSED set with no scope reading available, so a token outside
198
+ // it is unambiguously a typo (and `allow Clock …` is the same shape: an effect carrying no literal
199
+ // surface a value list could restrict);
200
+ // · a `deny` whose effect list ends up EMPTY after scope-splitting is malformed under either reading —
201
+ // there is no legitimate policy it could be — so refusing loses nothing.
202
+ // What stays open, deliberately, is the genuinely ambiguous middle: a `deny` with at least one valid effect
203
+ // and an unrecognised trailing token that MIGHT be a scope (`deny Net Exex app`). `parsepolicy` reports it
204
+ // either way, so the operator can always see it.
205
+ //
206
+ // `rule-kind` stays DISCLOSED-ONLY: `195d45a` is explicit that reporting a dropped line is "additive to the
207
+ // witness and silent about the gate", and a malformed `forbid` or an unknown rule kind has no closed
208
+ // vocabulary to be measured against — that would be a grammar change, not a token change.
209
+ const FATAL_ERROR_KINDS = new Set(["reason-class/alias", "Net destination-class", "effect-name"]);
210
+ export const fatalPolicyErrors = (errors) => (errors ?? []).filter((e) => FATAL_ERROR_KINDS.has(e.kind));
211
+ // The accepted sets, as ARRAYS (SPEC §3.1 `901f14d`: `accepted` is an array of tokens, not prose — a prose
212
+ // string is unparseable by the consumer the field exists for).
213
+ const REASON_VOCAB = [...REASON_CLASSES, "dynamic", "*"];
214
+ const RULE_KIND_VOCAB = ["deny", "pure", "forbid", "allow"];
215
+ const DROPPED = (why) => `policy line NOT HONOURED — DROPPED (${why}); it is absent from the parse, so the `
216
+ + `policy that ran is the one without it`;
217
+
39
218
  export function parsePolicy(text, aliases = null) {
40
219
  const deny = [], allow = [], forbid = [];
220
+ // ⟨0.24⟩ every line this parser could not honour AS WRITTEN, and every alias a rule RESOLVED THROUGH.
221
+ // `aliasesUsed` is recorded at the point of USE, never from the alias map: a config defining ten aliases
222
+ // the policy never mentions moved nothing, and naming it would train the reader to skip the field.
223
+ //
224
+ // THE ENTRY SHAPE IS PINNED (SPEC §3.1 `195d45a` + `901f14d`): `{kind, token, accepted, rule, message}`,
225
+ // `kind` drawn from the closed POLICY_ERROR_KINDS, `accepted` an ARRAY, `rule` the source line verbatim.
226
+ // This engine emitted `vocabulary` for `kind`, `where` for `rule`, prose for `accepted`, and no `message`
227
+ // at all — three renamed/reshaped fields on the one output whose whole job is to be diffed.
228
+ const errors = [], aliasesUsed = new Map();
229
+ const err = (kind, token, accepted, rule, message) => errors.push({ kind, token, accepted, rule, message });
41
230
  // Split LINES on \n / \r\n / bare \r — the three forms Java's Files.readAllLines (the reference parser)
42
231
  // breaks on. Splitting on \n ONLY let a classic-Mac (bare-\r) file collapse to one line: \r is also an
43
232
  // in-line ASCII-ws token separator (below), so every rule after the first was glued into the first rule's
@@ -67,7 +256,8 @@ export function parsePolicy(text, aliases = null) {
67
256
  if (!cn) continue;
68
257
  if (cn === "*") netStar = true;
69
258
  else if (NET_DEST_CLASSES.includes(cn)) netClasses.add(cn);
70
- else warn(`unknown Net destination-class \`${cn}\` (known: ${NET_DEST_CLASSES.join(",")}, or *)`);
259
+ else err("Net destination-class", cn, [...NET_DEST_CLASSES, "*"], line,
260
+ `unknown Net destination-class \`${cn}\` (known: ${NET_DEST_CLASSES.join(", ")}, or *)`);
71
261
  }
72
262
  continue;
73
263
  }
@@ -80,8 +270,10 @@ export function parsePolicy(text, aliases = null) {
80
270
  if (cn === "*") unknownStar = true;
81
271
  else if (cn === "dynamic") DYNAMIC_CLASSES.forEach((c) => unknownClasses.add(c));
82
272
  else if (REASON_CLASSES.includes(cn)) unknownClasses.add(cn);
83
- else if (aliases && aliases.has(cn)) aliases.get(cn).forEach((c) => unknownClasses.add(c)); // ⟨0.19⟩ config unknown-alias
84
- else warn(`unknown reason-class/alias \`${cn}\` (known: ${REASON_CLASSES.join(",")}; aliases: dynamic,*, or a config \`unknown-alias\`)`);
273
+ else if (aliases && aliases.has(cn)) { aliasesUsed.set(cn, [...aliases.get(cn)].sort()); aliases.get(cn).forEach((c) => unknownClasses.add(c)); } // ⟨0.19⟩ config unknown-alias
274
+ else err("reason-class/alias", cn, REASON_VOCAB, line,
275
+ `unknown reason-class/alias \`${cn}\` (known: ${REASON_CLASSES.join(", ")}; aliases: `
276
+ + "dynamic, *, or a config `unknown-alias`)");
85
277
  }
86
278
  continue;
87
279
  }
@@ -91,7 +283,11 @@ export function parsePolicy(text, aliases = null) {
91
283
  if (tok === "Net") netStar = true; // bare Net ⇒ all destinations
92
284
  } else { scope = tok; break; }
93
285
  }
94
- if (effects.length === 0) { warn("deny names no known effect"); continue; }
286
+ if (effects.length === 0) {
287
+ warn("deny names no known effect");
288
+ err("effect-name", t[1] ?? "", EFFECT_VOCAB, line, DROPPED("names no known effect"));
289
+ continue;
290
+ }
95
291
  // `*` (or bare Unknown) means all classes ⇒ empty filter (matches any Unknown).
96
292
  let uc = unknownStar ? [] : [...unknownClasses].sort();
97
293
  // `*` (or bare Net) means all destinations ⇒ empty filter (matches any Net).
@@ -104,25 +300,145 @@ export function parsePolicy(text, aliases = null) {
104
300
  } else if (t[0] === "pure") {
105
301
  deny.push({ effects: [], scope: t[1] ?? "", unknownClasses: [], netClasses: [], raw: line });
106
302
  } else if (t[0] === "allow") {
107
- if (t.length < 3) { warn("allow names no values"); continue; }
108
- if (!ALLOW_EFFECTS.has(t[1])) { warn("allow supports only Net hosts / Llm hosts / Exec commands / Fs paths / Db tables"); continue; }
303
+ if (t.length < 3) {
304
+ warn("allow names no values");
305
+ // `token` is EMPTY, not the line's remainder: nothing here was UNRECOGNISED — the line is
306
+ // truncated, so there is no offending token to name and inventing one would misdirect the reader.
307
+ err("rule-kind", "", ALLOW_FORM, line, DROPPED("allow names no values"));
308
+ continue;
309
+ }
310
+ if (!ALLOW_EFFECTS.has(t[1])) {
311
+ warn("allow supports only Net hosts / Llm hosts / Exec commands / Fs paths / Db tables");
312
+ err("effect-name", t[1], ALLOW_VOCAB, line,
313
+ DROPPED("allow supports only Net hosts / Llm hosts / Exec commands / Fs paths / Db tables"));
314
+ continue;
315
+ }
109
316
  let scope = "", vi = 2;
110
317
  if (t[2] === "in") { scope = t[3] ?? ""; vi = 4; }
111
318
  const values = t.slice(vi);
112
- if (values.length === 0) { warn("allow names no values"); continue; }
319
+ if (values.length === 0) {
320
+ warn("allow names no values");
321
+ err("rule-kind", "", ALLOW_FORM, line, DROPPED("allow names no values"));
322
+ continue;
323
+ }
113
324
  allow.push({ effect: t[1], scope, values: [...new Set(values)].sort(), raw: line }); // dedup (set)
114
325
  } else if (t[0] === "forbid") {
115
326
  // Token-wise like the Rust/JVM parsers: the arrow must be its own whitespace-separated token
116
327
  // (`a->b` glued is malformed), and tokens past `b` are ignored. A regex here once accepted and
117
328
  // rejected DIFFERENT lines than the other engines — the one thing a shared gate must not do.
118
329
  const [a, arrow, b] = [t[1] ?? "", t[2] ?? "", t[3] ?? ""];
119
- if (!a || arrow !== "->" || !b) { warn("malformed forbid (want `forbid <scope> -> <scope>`)"); continue; }
330
+ if (!a || arrow !== "->" || !b) {
331
+ warn("malformed forbid (want `forbid <scope> -> <scope>`)");
332
+ err("rule-kind", t.slice(1).join(" "), ["forbid <scope> -> <scope>"], line,
333
+ DROPPED("want `forbid <scope> -> <scope>`"));
334
+ continue;
335
+ }
120
336
  forbid.push({ from: a, to: b, raw: line });
121
337
  } else {
122
338
  warn("unknown rule kind");
339
+ err("rule-kind", t[0], RULE_KIND_VOCAB, line, DROPPED(`unknown rule kind \`${t[0]}\``));
123
340
  }
124
341
  }
125
- return { deny, allow, forbid };
342
+ // ⟨0.24⟩ `aliasesUsed` is name -> THE CLASSES IT RESOLVED TO, not a bare name list (SPEC §3.1
343
+ // `b4e9155`): naming the source without the content leaves a reader knowing they were affected and not
344
+ // how. Key order is sorted so the disclosure is deterministic across runs and across the two routes.
345
+ //
346
+ // ⟨0.24⟩ THIS IS THE OBJECT FORM AND IT IS DELIBERATE — a measured three-to-one divergence, KEPT.
347
+ // candor-ts emits `policyVocabulary.aliases` as `{"corp": ["reflect"]}` where rust, java and swift emit
348
+ // the ARRAY `["corp"]`; conformance R9's shape walk was blind one level down and scored all four OK.
349
+ // The object stands, on `b4e9155`'s own argument rather than on preference:
350
+ //
351
+ // · The clause's SHAPE is `{ "config": "<path>", "aliases": { … } }` — braces, i.e. an object. The
352
+ // array is a departure from the written pin, not the pin.
353
+ // · Its ARGUMENT is decisive and is about the array, one level down. It rejects swift's
354
+ // `configSources: [path]` because that "names the file but drops the alias names, and the file is
355
+ // the lesser half — an operator reading a verdict changed by an ambient definition needs to see WHAT
356
+ // THE DEFINITION WAS, not merely that one existed. A disclosure that names the source but not the
357
+ // content leaves the reader knowing they were affected and not how." `aliases: ["corp"]` fails that
358
+ // sentence for exactly the same reason `configSources` does: it names the alias and drops the
359
+ // definition. And the definition is the whole of what moved the verdict — `corp = reflect` and
360
+ // `corp = reflect,native` gate differently under one unchanged policy line, so a reader who sees
361
+ // only the NAME cannot tell which gate ran.
362
+ // · The object is a strict superset: `Object.keys(aliases)` recovers the array exactly, so nothing a
363
+ // consumer of the array form can do is lost.
364
+ //
365
+ // Being outnumbered is not the argument against it, and this family has twice found the outlier to be
366
+ // the correct one on `gate --report` questions. Recorded for the spec to adjudicate.
367
+ return { deny, allow, forbid, errors,
368
+ aliasesUsed: Object.fromEntries([...aliasesUsed.entries()].sort((x, y) => (x[0] < y[0] ? -1 : x[0] > y[0] ? 1 : 0))) };
369
+ }
370
+
371
+ // ⟨0.24⟩ The ONE rendering of the unreadable-policy posture (SPEC §6.2), so the wording cannot drift
372
+ // between the scan gate, `gate --report`, `whatif`, `fix-gate`, `unverified` and `parsepolicy`. Returns the
373
+ // human line; the caller prints it, writes the ⟨0.24⟩ refusal document and exits 2.
374
+ export function policyErrorText(policyFile, errors) {
375
+ const head = `candor-ts: policy ${policyFile} cannot be honoured AS WRITTEN`;
376
+ const body = errors.map((e) =>
377
+ ` unknown ${e.kind} \`${e.token}\` (known: ${e.accepted.join(", ")})\n in: ${e.rule}`).join("\n");
378
+ // ⟨0.24⟩ the tail is written for the population actually present. A VALUE-token typo rewrites a rule;
379
+ // an EFFECT-name typo DELETES it — different harms, and the alias remedy applies only to the first.
380
+ const dropped = errors.filter((e) => e.kind === "effect-name");
381
+ const tail = dropped.length === errors.length
382
+ ? " Refusing (exit 2), policy NOT evaluated: the rule names no effect this engine knows, so honouring "
383
+ + "the policy as written would mean DROPPING the line — and the policy that then runs is the one "
384
+ + "without it, which is a bigger rewrite than a narrowed filter, not a smaller one.\n"
385
+ + " Fix the spelling. (`allow` takes only Net/Llm hosts, Exec commands, Fs paths or Db tables.)"
386
+ : " Refusing (exit 2), policy NOT evaluated: dropping the token would rewrite the policy into a "
387
+ + "DIFFERENT one. If it is the list's only token the rule WIDENS to the bare effect; if it sits beside "
388
+ + "valid tokens the rule NARROWS and stops gating what you spelled, while the gate still looks armed.\n"
389
+ + " Fix the spelling, or define it in `.candor/config` as `unknown-alias <name> = <class,…>`.";
390
+ return `${head} — ${errors.length} line(s) it cannot honour:\n${body}\n` + tail;
391
+ }
392
+
393
+ // ⟨0.24⟩ THE `unevaluated` DISCLOSURE FOR A POLICY THIS ENGINE CANNOT HONOUR AS WRITTEN — one entry per
394
+ // RAW POLICY LINE (SPEC §3.1: "the RAW policy line, verbatim"), grouped because one line can carry two bad
395
+ // tokens. ONE builder, because BOTH gate routes emit it and §3.1 makes byte-equality between their
396
+ // documents the acceptance test: the scan route gained it first (a certain baseline regression must ride
397
+ // exit 1 with the refusal disclosed beside it) and `gate --report` has to say the same thing in the same
398
+ // bytes or the equality MUST breaks on the very change that repaired the precedence.
399
+ //
400
+ // The wording is written FOR THE DOCUMENT rather than reusing `policyErrorText`, which ends "Refusing
401
+ // (exit 2)" — true of a refusal document and FALSE beside a dominating violation, and a machine-consumer
402
+ // channel is exactly where a stale sentence about the exit code does damage.
403
+ export function policyErrorUnevaluated(errors) {
404
+ const byLine = new Map();
405
+ for (const e of errors) {
406
+ if (!byLine.has(e.rule)) byLine.set(e.rule, []);
407
+ byLine.get(e.rule).push(`\`${e.token}\` is not a recognised ${e.kind} (known: ${e.accepted.join(", ")})`);
408
+ }
409
+ return [...byLine.entries()].map(([rule, ts]) => ({ rule,
410
+ why: `NOT EVALUATED — this line cannot be honoured as written: ${ts.join("; ")}. Dropping the token `
411
+ + `would gate a DIFFERENT policy than the one spelled (alone the rule widens to the bare effect; `
412
+ + `beside valid tokens it narrows while the gate still looks armed).` }));
413
+ }
414
+
415
+ // ⟨0.24⟩ …and the same for a policy that could not be READ at all. ONE sentence and one `unevaluated`
416
+ // entry for both gate routes: they had two different `reason` strings, so §3.1's byte-equality MUST was
417
+ // already broken for this cause and no test could see it (the equality row only exercises the UNHONOURABLE
418
+ // policy). `rule` names the WHOLE FILE, parenthesised so it cannot be mistaken for a policy line — an
419
+ // unreadable policy has no lines to name, and a consumer of a verdict where a certain violation DOMINATED
420
+ // this refusal must still be able to see from the DOCUMENT that the policy half of the gate never ran.
421
+ export function policyUnreadable(policyFile) {
422
+ const shown = policyFile === "" ? "(configured empty)" : policyFile;
423
+ const why = `policy ${shown} could not be read — the gate was NOT enforced from it`;
424
+ return { why, unevaluated: [{ rule: `(entire policy ${shown} — unreadable, no rules parsed)`, why }] };
425
+ }
426
+
427
+ // ⟨0.24⟩ THE REFUSAL DOCUMENT (SPEC §3.1 `107755b`, carve-outs removed by `1503368`). A refusal used to
428
+ // write NO `--gate-json` document at all, so a CI wrapper reading that path unconditionally re-read THE
429
+ // PREVIOUS RUN'S document as current — a green file from yesterday's clean run, still on disk, is how a
430
+ // refusal becomes an all-clear. Deleting the path is not the fix either: a consumer that treats a missing
431
+ // file as "nothing to report" fails open by a different route.
432
+ //
433
+ // It MUST be fail-closed to a NAIVE reader: `ok: false` so a consumer keying only on `ok` lands on FAIL,
434
+ // `refused: true` so one keying on that learns why, and **NO `violations` KEY AT ALL** — the gate is making
435
+ // no claim about violations, and an empty array is precisely the claim it cannot make. ABSENT, not empty.
436
+ // `1503368`: no cause is exempt, including an unreadable policy — a stale green does not care why this run
437
+ // declined to overwrite it.
438
+ export function refusalVerdict(spec, reason, unevaluated = null) {
439
+ const o = { spec, ok: false, refused: true, reason };
440
+ if (unevaluated && unevaluated.length) o.unevaluated = unevaluated;
441
+ return o;
126
442
  }
127
443
 
128
444
  /** §6.2 scope match: by NAME SEGMENT, last segment a prefix.
@@ -175,16 +491,227 @@ export function literalAllowed(effect, reached, values) {
175
491
  }
176
492
  }
177
493
 
494
+ // ⟨0.24⟩ SPEC §3.1 — the REPORT route's Net destination classes, taken VERBATIM off the wire.
495
+ //
496
+ // `netClass` is already the TRANSITIVE set the producer derived (scan.mjs writes `netClassesOf([...rec
497
+ // .hosts], rec.incomplete.has("Net"), netPartners)` into every Net-bearing entry), so re-deriving it in a
498
+ // consumer is a §3.1 ⟨0.24⟩ re-classification with two live failure modes, in OPPOSITE directions:
499
+ // · the producer's `.candor/config` `net-partner` list is NOT on the wire, so a host the producer
500
+ // classified `known-partner` re-derives here as `unknown-host` — `deny Net[unknown-host]` fires on a
501
+ // function the producing project asserted safe (a FABRICATED violation, and one whose evidence the
502
+ // consumer cannot even see);
503
+ // · the AS-EFF-008 masked-surface flag is not on the wire either, so an entry whose `netClass` says
504
+ // `unknown-host` BECAUSE its host was runtime-computed re-derives from its one benign visible literal
505
+ // and loses the `unknown-host` member — the fail-OPEN mirror, on the exact filter a hardening team
506
+ // narrows through.
507
+ // TWO MODES, because the two report routes differ in ONE way that matters — whether a refusal channel
508
+ // exists downstream:
509
+ // · DEFAULT (MCP `candor_gate`, LSP diagnostics): map only the entries that actually CARRY the field.
510
+ // An entry without it falls back to the derivation, which is the fail-CLOSED reading (`netClassesOf`
511
+ // floors an empty/masked surface at `unknown-host`) and keeps a pre-⟨0.20⟩ or foreign report gated
512
+ // rather than silently un-narrowed. Neither surface can refuse a question, so a hedge beats a hole.
513
+ // · `authoritative` (`gate --report`): map EVERY `Net`-bearing entry, to its `netClass` or to the EMPTY
514
+ // set, so the derivation is unreachable and the report is the only source of the class — the §3.1
515
+ // ⟨0.24⟩ MUST NOT taken literally. That verb REFUSES a scoped `deny Net[…]` over a `netClass`-less
516
+ // entry (exit 2), so no gate DECISION rests on the empty set; what the mode additionally prevents is
517
+ // the class list a BARE `deny Net` violation REPORTS being re-derived from `hosts` — asserting a
518
+ // destination class the report never made, in a field a consumer reads as the producer's judgment.
519
+ export function reportNetClasses(functions, { authoritative = false } = {}) {
520
+ const m = new Map();
521
+ for (const f of functions ?? []) {
522
+ const carried = Array.isArray(f.netClass) ? f.netClass : [];
523
+ if (carried.length === 0 && !(authoritative && (f.inferred ?? []).includes("Net"))) continue;
524
+ const prev = m.get(f.fn);
525
+ // A repeated `fn` is malformed input; UNION rather than overwrite — the direction that cannot turn a
526
+ // violation into a pass (java `gateInputFromReport` merges the same way).
527
+ m.set(f.fn, prev ? [...new Set([...prev, ...carried])].sort() : carried);
528
+ }
529
+ return m;
530
+ }
531
+
532
+ // ⟨0.24⟩ MOVED HERE FROM query.mjs (SPEC §6.2: "THE GATE AND THE DISCLOSURE MUST APPLY THE SAME RULE, AND
533
+ // SHOULD SHARE THE SAME CODE"). It lived beside `gate --report` because that is where it was written, and
534
+ // the consequence was measured on the two surfaces that have no exit code: `mcp.mjs` and `lsp.mjs` called
535
+ // `evaluatePolicy` with NO withhold predicate and no discloser, so BOTH directions of harm were live on the
536
+ // surface an agent trusts and no human reads. Same report, same policy, against the CLI:
537
+ //
538
+ // deny Unknown[reflect] app -> CLI exit 2 (refused); MCP {"ok":true,"violations":[]} FALSE ALL-CLEAR
539
+ // deny Net[unknown-host] app -> CLI exit 2 (refused); MCP fires AND asserts
540
+ // "netClass":["unknown-host"] the report never carried
541
+ //
542
+ // The control (`deny Unknown app`) fires on both, so the channel was not simply dead — it was answering a
543
+ // narrowing question the report cannot answer, in whichever direction the missing evidence happened to fall.
544
+ // A helper that only one of three call sites can reach is a rule only one of them implements.
545
+ /**
546
+ * ⟨0.24⟩ THE THIRD ANSWERABILITY CASE (SPEC §3.1) — a class-scoped `deny` over a report that cannot answer
547
+ * the narrowing question. Returns the refusal message, or null when every scoped filter is answerable.
548
+ *
549
+ * A bare `deny Net` / `deny Unknown` asks a question the effect set alone answers. A SCOPED one —
550
+ * `deny Net[unknown-host]`, `deny Unknown[dispatch]` — asks a second question ("…and is the destination /
551
+ * the reason class one of THESE?") and NARROWS the gate on the answer. Where the report does not carry the
552
+ * evidence, the field is simply absent, the matcher sees an empty set, nothing intersects, and the effect
553
+ * is dropped from the violation: **the narrowing succeeds BECAUSE the evidence is missing**. Measured on
554
+ * the reference engine — `deny Net[unknown-host]` over a `Net`-bearing entry with no `netClass` returned
555
+ * exit 0 where bare `deny Net` returns 1, an absent optional field silently un-scoping a fail-closed gate.
556
+ *
557
+ * THE REFUSAL IS MINIMAL, and the minimality is the subtle half. §3.1 ⟨0.24⟩: a scoped `deny` is NOT
558
+ * unanswerable merely because some datum is missing. The class set only ever GROWS as evidence arrives
559
+ * (§6.2 CONTRIBUTES, never retracts) and `Reject` is upward-closed in it, so
560
+ * · if the classes determinable from the entry ALONE already INTERSECT the filter, the rule FIRES and the
561
+ * answer is certain — missing data could only have added more matches. That case never reaches here:
562
+ * it is `evaluatePolicy`'s ordinary path.
563
+ * · only where the determinable set is EMPTY does the rule fail to fire AND could the missing datum still
564
+ * make it fire. That, and only that, is refused.
565
+ * A NON-empty set that does not intersect is answered (tolerated), because on the wire both fields are
566
+ * total by construction for the entries this can reach: `netClass` is emitted for EVERY `Net`-bearing entry
567
+ * and floored at `unknown-host`, and an in-scope `Unknown` always resolves — a DIRECT one CONTRIBUTES
568
+ * `unresolved` at its own entry (resolveReasonClasses, §6.2 requirement 3) and an INHERITED one has the
569
+ * callee that raised it in `calls`, because that callee carries `Unknown` and is therefore effectful and
570
+ * present. So a non-empty set means the channel is carried and the producer's set is the whole answer.
571
+ *
572
+ * That entry-level CONTRIBUTION is also why this engine does not repeat the over-broad refusal the spec
573
+ * records against candor-swift: a function whose direct `Unknown` names no reason is answerable here under
574
+ * `deny E Unknown[unresolved]` — the class comes from the entry itself, with no transitive step — so it
575
+ * FIRES rather than exiting 2.
576
+ *
577
+ * PER (RULE, FUNCTION), not per policy — §3.1's granularity ruling. A scoped rule whose own matches all
578
+ * carry their evidence evaluates normally; only the rule that would have been silently narrowed is refused.
579
+ *
580
+ * ⟨0.24⟩ IT NO LONGER SHORT-CIRCUITS THE GATE (SPEC §3.1 `7271c69` + `5a8cf48`), and that is the whole of
581
+ * this rung's precedence correction. It used to return the FIRST refusal message and the verb exited 2 on
582
+ * the spot — so a policy carrying a firing `deny Fs` BESIDE one unanswerable scoped rule exited 2 and wrote
583
+ * no `--gate-json` document at all, DELETING A CERTAIN VIOLATION from the machine-consumer channel. `Reject`
584
+ * is upward-closed (PAPER3 Lemma 2): if a rule fires on evidence the report carries, however the
585
+ * unanswerable rule would have resolved cannot un-reject it, so exit 1 is not merely fail-closed there, it
586
+ * is CERTAIN — and it names the violation where exit 2 names nothing.
587
+ *
588
+ * So it now returns EVERY unanswerable (rule, function, effect) triple, as a disclosure that travels
589
+ * ALONGSIDE a verdict rather than as the whole output, plus the `withhold` predicate `evaluatePolicy` needs
590
+ * to keep the unevidenced pairs from FIRING (see the note on that parameter — flooring an empty class set
591
+ * at `unresolved` is right for a matcher and wrong for a firing).
592
+ */
593
+ export function unanswerableScoped(pol, functions, reasonAcc, netMap) {
594
+ const held = new Set(), byRule = new Map();
595
+ const key = (raw, fn, eff) => `${raw}\u0000${fn}\u0000${eff}`;
596
+ const note = (r, fn, eff, why) => {
597
+ held.add(key(r.raw, fn, eff));
598
+ let e = byRule.get(r.raw);
599
+ if (!e) { e = { rule: r.raw, fns: [], why }; byRule.set(r.raw, e); }
600
+ e.fns.push(fn);
601
+ };
602
+ for (const r of pol.deny) {
603
+ for (const f of functions) {
604
+ if (r.scope && !scopeMatches(f.fn, r.scope)) continue;
605
+ const inf = f.inferred ?? [];
606
+ if (r.netClasses?.length && inf.includes("Net") && !(netMap.get(f.fn)?.length))
607
+ note(r, f.fn, "Net", "narrows on the Net DESTINATION CLASS, but %F carr%S Net with no "
608
+ + "`netClass` in this report — the field the filter reads is absent, so the narrowing would "
609
+ + "succeed for lack of evidence and drop a Net the bare `deny Net` catches. NOT EVALUATED for "
610
+ + "those functions rather than passed: an absent optional field must not relax a fail-closed "
611
+ + "gate. Use the bare `deny Net`, or gate at scan time (candor-ts <src> --policy <file>).");
612
+ if (r.unknownClasses?.length && inf.includes("Unknown") && !(reasonAcc.get(f.fn)?.size))
613
+ note(r, f.fn, "Unknown", "narrows on the Unknown REASON CLASS, but %F carr%S Unknown with no "
614
+ + "reason reachable in this report — neither %P own `unknownWhy` nor a `calls` edge to one. "
615
+ + "§6.2 resolves the class set TRANSITIVELY over the gate's reach; with the channel missing, "
616
+ + "every narrowed filter silently tolerates while only the bare `deny Unknown` fires. NOT "
617
+ + "EVALUATED for those functions rather than passed. Use the bare `deny Unknown`, or gate at "
618
+ + "scan time (candor-ts <src> --policy <file>).");
619
+ }
620
+ }
621
+ // EVERY withheld function is named, never one witness standing for the rest: the operator has to be able
622
+ // to see which part of the policy went unenforced, and a count cannot be acted on.
623
+ const unevaluated = [...byRule.values()].map(({ rule, fns, why }) => {
624
+ const names = [...new Set(fns)].sort();
625
+ const list = names.map((n) => `\`${n}\``).join(", ");
626
+ return { rule, why: `\`${rule}\` ` + why.replace("%F", list).replace("%S", names.length === 1 ? "ies" : "y")
627
+ .replace("%P", names.length === 1 ? "its" : "their") };
628
+ });
629
+ return { unevaluated, withhold: held.size ? (r, fn, eff) => held.has(key(r.raw, fn, eff)) : null };
630
+ }
631
+
178
632
  /**
179
633
  * The standing gate: evaluate a parsed policy over a report + callgraph (AS-EFF-006 deny/pure over
180
634
  * transitive inferred; AS-EFF-008 allowlists over the transitive literal surfaces, the no-visible-
181
635
  * literal case flagged as uncertifiable; AS-EFF-009 forbid by reachability). One line per violation.
636
+ *
637
+ * ⟨0.24⟩ THE ONLY MATCHING CODE IN THIS ENGINE. `scan --policy` lands here over the live graph;
638
+ * `gate --report` (SPEC §3.1) lands here over a WRITTEN report, with `callgraph`/`incomplete`/`partners`
639
+ * empty — none of the three rides the ⟨0.24⟩ wire, and the two rule kinds that would have needed them
640
+ * (`forbid`, `allow`) are REFUSED by that verb rather than evaluated on partial evidence. That shared
641
+ * landing is what makes "the same verdict from the same signature" a property of the code rather than of
642
+ * two consistent authors, and it is what §6.2 asks for: "THE GATE AND THE DISCLOSURE MUST APPLY THE SAME
643
+ * RULE, AND SHOULD SHARE THE SAME CODE."
182
644
  */
183
645
  // Each violation is a STRUCTURED record { rule, fn, effects, detail } (candor-spec §3.3 ⟨0.8⟩): `effects`
184
646
  // is the specific denied/allowed effect set the violation concerns ([] for the 009 layer-flow, which has
185
647
  // no single effect); `detail` is the message BODY (no `[AS-EFF-00x]` prefix — the rule carries the code).
186
648
  // The console gate renders `[${rule}] ${detail}`; --gate-json emits the records verbatim.
187
- export function evaluatePolicy(pol, functions, callgraph, incomplete = new Map(), partners = new Set()) {
649
+ // ⟨0.24⟩ `withhold(rule, fn, effect) -> bool` (SPEC §3.1 `5a8cf48`) — THE OPERATIONAL FORM OF MINIMAL
650
+ // REFUSAL, and the half of the precedence ruling that makes it safe. It is null on the scan route (full
651
+ // evidence, nothing to withhold) and supplied by `gate --report`.
652
+ //
653
+ // WHY IT EXISTS, because the mechanism is not obvious from the signature. Until the precedence fix, an
654
+ // unanswerable scoped rule SHORT-CIRCUITED the whole gate, so this function was never reached with one.
655
+ // Removing the short-circuit made it reachable — and `reasonClassesMatch` floors an empty class set at
656
+ // `unresolved`, which is the correct fail-closed default for a MATCHER ("could this rule apply?") and the
657
+ // WRONG basis for a FIRING ("did it?"). Measured on this engine: a `deny Unknown[unresolved]` over an entry
658
+ // whose Unknown is INHERITED and reasonless began emitting an actual violation RECORD, asserting a reason
659
+ // nobody recorded. Same constant, same helper, opposite direction of harm — a fabrication reachable ONLY
660
+ // through the soundness fix.
661
+ //
662
+ // It is per (rule, function, effect), never whole-policy and never whole-rule: `deny Fs Net[unknown-host]`
663
+ // over a function carrying BOTH a certain `Fs` and a `netClass`-less `Net` must still report the `Fs`.
664
+ // Withholding the pair would delete a certain violation — the very harm the precedence ruling is fixing.
665
+ /** ⟨0.24⟩ ONE definition of a function's Net destination classes for a run: the report's own field when it
666
+ * carries one (reportNetClasses), else the derivation from the surfaces the caller supplied. Exported so
667
+ * the ADVISORY verbs resolve the class the same way the gate does — see `classFilterExcludes`. */
668
+ export function netClassResolver(incomplete = new Map(), partners = new Set(), netClasses = null) {
669
+ // `has`, not `get(…) ?? derive`: an entry mapped to the EMPTY set is a report that carried no class, and
670
+ // on the authoritative route that absence is the answer — deriving one there would re-classify.
671
+ return (f) => (netClasses && netClasses.has(f.fn))
672
+ ? netClasses.get(f.fn)
673
+ : netClassesOf(f.hosts ?? [], incomplete.get(f.fn)?.has("Net") ?? false, partners);
674
+ }
675
+
676
+ /**
677
+ * ⟨0.24⟩ THE CLASS FILTER, AS A PREDICATE ON ONE (rule, function, effect) — does `r`'s `Unknown[…]` /
678
+ * `Net[…]` narrowing EXCLUDE `eff` at `entry`? Factored out of `evaluatePolicy` because it was inlined
679
+ * there, and inlined there meant the two ADVISORY verbs that ask the same question computed from the
680
+ * effect set ALONE. Measured on this engine, `deny Unknown[reflect,unresolved] app` over a report whose
681
+ * only hole is `native:dlopen` — the class the policy EXCLUDES:
682
+ *
683
+ * gate --report exit 0, no violations <- correct, the class is excluded
684
+ * fix-gate --strict exit 1 + a remedy naming it <- OVER-CHARGE: a red CI check and a hoist
685
+ * instruction for a boundary nothing denies
686
+ * unverified --strict exit 0, ok:true, [] <- UNDER-REPORT, and the worse half: the layer
687
+ * PASSES while carrying an Unknown, so it is a
688
+ * pass-but-Unknown hole, and the verb whose whole
689
+ * job is "your green gate is not provably green"
690
+ * certified it clean
691
+ *
692
+ * Same shape on the `Net[…]` sibling (`deny Net[unknown-host]` over a `known-partner` entry). SPEC §6.2:
693
+ * "THE GATE AND THE DISCLOSURE MUST APPLY THE SAME RULE, AND SHOULD SHARE THE SAME CODE" — this is that
694
+ * code, and both halves existed because the disclosure side never reached it.
695
+ *
696
+ * THE MATCHER QUESTION, NOT THE FIRING ONE. `reasonClassesMatch` floors an empty class set at `unresolved`,
697
+ * which the note on `withhold` records as the correct fail-closed default for a MATCHER ("could this rule
698
+ * apply?") and the wrong basis for a FIRING ("did it?"). The advisory verbs ask the matcher question — they
699
+ * emit no violation record that could assert a reason nobody recorded — so they use this predicate WITHOUT
700
+ * `withhold`, whose paired `unevaluated` disclosure has no ruled shape on those verbs anyway.
701
+ *
702
+ * An entry ABSENT from the report excludes NOTHING: a missing entry is missing evidence, and the direction
703
+ * that cannot turn a boundary crossing into a silent pass is "the rule still applies".
704
+ */
705
+ export function classFilterExcludes(r, entry, eff, reasonAcc, netClassOf) {
706
+ if (!entry) return false;
707
+ if (eff === "Unknown" && r.unknownClasses?.length)
708
+ return !reasonClassesMatch(reasonAcc.get(entry.fn), r.unknownClasses);
709
+ if (eff === "Net" && r.netClasses?.length)
710
+ return !netClassOf(entry).some((c) => r.netClasses.includes(c));
711
+ return false;
712
+ }
713
+
714
+ export function evaluatePolicy(pol, functions, callgraph, incomplete = new Map(), partners = new Set(), netClasses = null, withhold = null) {
188
715
  const out = [];
189
716
  // `Llm` ⟨0.13⟩ reaches the SAME hosts surface as Net (an Llm host WAS captured as a Net host literal).
190
717
  const surfaces = { Net: "hosts", Llm: "hosts", Exec: "cmds", Fs: "paths", Db: "tables" };
@@ -196,27 +723,13 @@ export function evaluatePolicy(pol, functions, callgraph, incomplete = new Map()
196
723
  if (netClass && netClass.length) rec.netClass = netClass;
197
724
  out.push(rec);
198
725
  };
199
- // Reason-scoped Unknown: the Unknown reason CLASS must travel the call graph the same way the Unknown
200
- // EFFECT does (unknownWhy in the report is direct-only). Classify each fn's DIRECT reasons to class
201
- // tokens, then propagate transitively over `callgraph` to a fixpoint — so `deny E Unknown[reflect]` at a
202
- // caller inheriting Unknown from a reflect-caused callee still fires (matches java/rust reasonClassAcc).
203
- const reasonAcc = new Map();
204
- for (const f of functions) {
205
- const cs = new Set((f.unknownWhy ?? []).map(reasonClass));
206
- if (cs.size) reasonAcc.set(f.fn, cs);
207
- }
208
- for (let changed = true; changed; ) {
209
- changed = false;
210
- for (const [caller, callees] of Object.entries(callgraph)) {
211
- for (const callee of callees) {
212
- const cc = reasonAcc.get(callee);
213
- if (!cc) continue;
214
- let set = reasonAcc.get(caller);
215
- if (!set) { set = new Set(); reasonAcc.set(caller, set); }
216
- for (const c of cc) if (!set.has(c)) { set.add(c); changed = true; }
217
- }
218
- }
219
- }
726
+ // Reason-scoped Unknown: the Unknown reason CLASS travels the call graph the same way the Unknown
727
+ // EFFECT does. ONE copy of that resolution, shared with the disclosure side — see resolveReasonClasses.
728
+ const reasonAcc = resolveReasonClasses(functions, callgraph);
729
+ // ⟨0.24⟩ ONE definition of a function's Net destination classes for this run (netClassResolver above):
730
+ // the report's own field when it carries one, else the derivation from the surfaces the caller supplied.
731
+ // The gate's TEST and the class list it REPORTS both read it, so the two can't disagree about one function.
732
+ const netClassOf = netClassResolver(incomplete, partners, netClasses);
220
733
  for (const f of functions) {
221
734
  for (const r of pol.deny) {
222
735
  if (r.scope && !scopeMatches(f.fn, r.scope)) continue;
@@ -228,28 +741,21 @@ export function evaluatePolicy(pol, functions, callgraph, incomplete = new Map()
228
741
  ? f.inferred.filter((e) => e !== "Unknown")
229
742
  : f.inferred.filter((e) => r.effects.includes(e));
230
743
  // Reason-scoped Unknown: a `deny E Unknown[classes]` keeps its Unknown hit only for a fn whose
231
- // TRANSITIVE reason classes include one of those; an Unknown with no recorded reason ⇒ `unresolved`.
232
- let kept = hits;
233
- if (hits.includes("Unknown") && (r.unknownClasses?.length)) {
234
- const cs = reasonAcc.get(f.fn);
235
- const fnClasses = cs && cs.size ? [...cs] : ["unresolved"];
236
- if (!fnClasses.some((c) => r.unknownClasses.includes(c))) kept = hits.filter((e) => e !== "Unknown");
237
- }
238
- // Net destination-class: a `deny Net[dest…]` keeps its Net hit only for a fn reaching one of those
239
- // destination classes; else tolerate (only asserted-safe destinations). Fail-closed: a masked surface /
240
- // a Net with no visible host is unknown-host (netClassesOf). The class travels the call graph via
241
- // f.hosts + f.incomplete, both propagated transitively before the gate (scan.mjs).
242
- if (kept.includes("Net") && (r.netClasses?.length)) {
243
- const fnNet = netClassesOf(f.hosts ?? [], incomplete.get(f.fn)?.has("Net") ?? false, partners);
244
- if (!fnNet.some((c) => r.netClasses.includes(c))) kept = kept.filter((e) => e !== "Net");
245
- }
744
+ // TRANSITIVE reason classes include one of those; Net destination-class: a `deny Net[dest…]` keeps its
745
+ // Net hit only for a fn reaching one of those destinations, else tolerates (only asserted-safe ones).
746
+ // Fail-closed: a masked surface / a Net with no visible host is unknown-host (netClassesOf); the class
747
+ // travels the call graph via f.hosts + f.incomplete, propagated transitively before the gate (scan.mjs).
748
+ // ⟨0.24⟩ BOTH now live in `classFilterExcludes`, so `fix-gate` and `unverified` apply the same filter
749
+ // instead of computing from the effect set alone (see that function's measurement).
750
+ let kept = hits.filter((e) => !classFilterExcludes(r, f, e, reasonAcc, netClassOf));
751
+ // ⟨0.24⟩ …and LAST, because it overrides the matchers rather than joining them: an effect whose match
752
+ // this report cannot evidence is neither a violation nor a pass. The caller lists it as `unevaluated`.
753
+ if (withhold && kept.length) kept = kept.filter((e) => !withhold(r, f.fn, e));
246
754
  if (kept.length) {
247
755
  // When Unknown is denied, report ALL reason classes on the fn (transitive) — every reason the gate bit.
248
756
  const rc = kept.includes("Unknown") ? [...(reasonAcc.get(f.fn) ?? [])].sort() : undefined;
249
757
  // ⟨0.20⟩ when Net is denied, report ALL of the fn's destination classes (transitive).
250
- const ncv = kept.includes("Net")
251
- ? netClassesOf(f.hosts ?? [], incomplete.get(f.fn)?.has("Net") ?? false, partners)
252
- : undefined;
758
+ const ncv = kept.includes("Net") ? netClassOf(f) : undefined;
253
759
  push("AS-EFF-006", f.fn, kept, `\`${f.fn}\` performs { ${kept.join(", ")} }, forbidden by policy: \`${r.raw}\``, rc, ncv);
254
760
  }
255
761
  }
@@ -334,10 +840,48 @@ export function discoverConfigText(fromDir) {
334
840
  }
335
841
  }
336
842
 
843
+ // ⟨0.24⟩ The PATH the text above came from (SPEC §3.1 `99eb4e9`: "if a config file supplied vocabulary that
844
+ // participated in the verdict, the `--gate-json` document MUST name that file"). Discovery WALKS PARENT
845
+ // DIRECTORIES and `CANDOR_CONFIG` overrides it outright, so a file the operator never named — and cannot
846
+ // see named anywhere in the output — can decide the verdict. That is the ambient-input failure this format
847
+ // exists to refuse, and the remedy is the usual one: not to forbid the input, but to make it unable to act
848
+ // unnamed. Same walk as `discoverConfigText`, deliberately, so the two cannot name different files.
849
+ export function discoverConfigPath(fromDir) {
850
+ const env = process.env.CANDOR_CONFIG;
851
+ if (env) { try { fs.readFileSync(env, "utf8"); return nodePath.resolve(env); } catch { return null; } }
852
+ let dir = nodePath.resolve(fromDir);
853
+ for (;;) {
854
+ const cand = nodePath.join(dir, ".candor", "config");
855
+ if (fs.existsSync(cand)) return cand;
856
+ const parent = nodePath.dirname(dir);
857
+ if (parent === dir) return null;
858
+ dir = parent;
859
+ }
860
+ }
861
+
862
+ // ⟨0.24⟩ The ANCHOR for keys that supply POLICY VOCABULARY (SPEC §3.1 `99eb4e9`). All four gate verbs used
863
+ // to anchor config discovery at the POLICY file's directory while all four scan routes anchored at the
864
+ // TARGET, so with the policy filed outside the scan target `scan --policy P` and `gate --report R --policy
865
+ // P` expanded the SAME rule differently — §3.1's byte-equality MUST broken by a file that is neither the
866
+ // report nor the policy. Vocabulary travels with the policy that uses it. TARGET-scoped keys (`deps`,
867
+ // `net-partner`, scan settings) keep anchoring at the target, because they describe the thing being scanned
868
+ // rather than the language the rules are written in.
869
+ export function policyVocabularyAnchor(policyFile, fallbackDir) {
870
+ return policyFile ? nodePath.dirname(nodePath.resolve(policyFile)) : fallbackDir;
871
+ }
872
+
337
873
  // ⟨0.19⟩ Parse `unknown-alias <name> = <class,…>` lines (SPEC §6.2) into a Map name→class-token[]. A name
338
874
  // that shadows a built-in (`*`/`dynamic`/a class token) is warned-and-skipped, as is a no-valid-class def.
339
875
  // Byte-shape with the java `Config.addAlias` / rust `parse_unknown_aliases`.
340
- export function parseUnknownAliases(configText) {
876
+ // ⟨0.24⟩ `errors`, when supplied, receives an unrecognised token in an alias DEFINITION under the same rule
877
+ // as one in the policy itself (SPEC §6.2 `be0b9a9` — the rule binds EVERY policy value list). This is the
878
+ // sharper of the two: the typo is in the VOCABULARY the policy is written against rather than in the policy,
879
+ // and it fails open identically — `unknown-alias corp = dispatch,nativ` silently becomes `{dispatch}`, so
880
+ // `deny Unknown[corp]` stops gating native-caused holes that `= dispatch,native` catches, and the operator
881
+ // reads a policy that mentions no typo at all. A RESERVED or empty NAME stays a warn-and-skip: the name is
882
+ // not a value token, the definition is dropped whole, and a rule referencing it then raises its own
883
+ // unrecognised-token error at the policy — loud by the route the ruling already covers.
884
+ export function parseUnknownAliases(configText, errors = null) {
341
885
  const out = new Map();
342
886
  if (!configText) return out;
343
887
  for (const raw of configText.split(/\r?\n/)) {
@@ -358,6 +902,10 @@ export function parseUnknownAliases(configText) {
358
902
  if (!cn) continue;
359
903
  if (cn === "dynamic") DYNAMIC_CLASSES.forEach((c) => classes.add(c));
360
904
  else if (REASON_CLASSES.includes(cn)) classes.add(cn);
905
+ else if (errors) errors.push({ kind: "reason-class/alias", token: cn,
906
+ accepted: [...REASON_CLASSES, "dynamic"], rule: line,
907
+ message: `unknown reason-class \`${cn}\` in an \`unknown-alias\` `
908
+ + `DEFINITION (known: ${REASON_CLASSES.join(", ")}, or dynamic)` });
361
909
  else console.error(`candor: \`unknown-alias ${name}\` names unknown reason-class \`${cn}\` — skipped`);
362
910
  }
363
911
  if (classes.size === 0) console.error(`candor: ignoring \`unknown-alias ${name}\` — no valid reason-class`);