candor-ts 0.30.0 → 0.32.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/AGENTS.md +15 -3
- package/README.md +9 -3
- package/lsp.mjs +111 -4
- package/mcp.mjs +105 -35
- package/package.json +2 -2
- package/policy.mjs +304 -41
- package/query-core.mjs +249 -44
- package/query.mjs +273 -46
- package/scan-core.mjs +224 -4
- package/scan.mjs +601 -55
package/policy.mjs
CHANGED
|
@@ -17,6 +17,121 @@ export const REASON_CLASSES = ["reflect", "dispatch", "indirect", "native", "unr
|
|
|
17
17
|
// the flag and the policy filter name the same vocabulary, so `--class dynamic` and `Unknown[dynamic]`
|
|
18
18
|
// cannot drift into meaning different sets.
|
|
19
19
|
export const DYNAMIC_CLASSES = ["reflect", "dispatch", "indirect", "native", "unresolved"];
|
|
20
|
+
|
|
21
|
+
// ⟨0.32⟩ THE UNIT IDENTITY, AS A PLUGGABLE POLICY — `units`, threaded through every function on this page
|
|
22
|
+
// that keys an accumulator by a function.
|
|
23
|
+
//
|
|
24
|
+
// It exists because a MULTI-REPORT gate must join by `hash` and never by bare `fn` (SPEC §2.2: "names may
|
|
25
|
+
// legitimately repeat across packages"), and this engine joined by `fn`. Measured on candor-ts at spec
|
|
26
|
+
// 0.31: `gate --report` over member `a` REFUSED a scoped rule at exit 2, and gating that same member
|
|
27
|
+
// beside an unrelated sibling exited 0 with `policy ✓` — a false green produced by ADDING a report. The
|
|
28
|
+
// harm is not effects merging (union can only add violations); it is REASONS merging, because a reason
|
|
29
|
+
// set is what makes an `Unknown` ANSWERABLE. `a`'s reasonless Unknown borrowed `b`'s `callback:` class,
|
|
30
|
+
// the filter saw a class the rule does not deny, and tolerated: "I cannot say" became "I checked".
|
|
31
|
+
//
|
|
32
|
+
// AND THE KEY SWAP CANNOT BE MADE ALONE, which is why the identity is a PARAMETER rather than a hardcoded
|
|
33
|
+
// `e.hash`. A policy SCOPE is written against the NAME (`deny Exec app::`) and the verdict ROW must print
|
|
34
|
+
// the NAME (§3.3.1 byte-equality with `scan --policy`), so a key that is not a name has to travel BESIDE
|
|
35
|
+
// the name, never instead of it. Keying by hash without that separation is a false green introduced while
|
|
36
|
+
// fixing a false green — the shape where killing an over-charge introduces a silent under-report. Hence
|
|
37
|
+
// the split landing: this identity is plumbed and verified a NO-OP first, and only then swapped.
|
|
38
|
+
//
|
|
39
|
+
// `identityUnits()` is the SCAN route's identity and the default everywhere: a scan gates ONE analysis
|
|
40
|
+
// world, its keys are already names, so that route cannot change behaviour by construction.
|
|
41
|
+
export const AMBIGUOUS = Symbol("candor:ambiguous-callee");
|
|
42
|
+
|
|
43
|
+
export function identityUnits() {
|
|
44
|
+
return {
|
|
45
|
+
key: (e) => e?.fn,
|
|
46
|
+
/** A callee/caller NAME resolved to a unit key, `AMBIGUOUS`, or null. Under identity a name IS a key. */
|
|
47
|
+
resolveCall: (_callerKey, name) => name,
|
|
48
|
+
/** Every unit key declaring `name`. Under identity, the name itself. */
|
|
49
|
+
unitsNamed: (name) => [name],
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** `units.key` with the identity default applied, for the sites that take `units` as an option. */
|
|
54
|
+
const unitKey = (units, e) => (units ? units.key(e) : e?.fn);
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* ⟨0.32⟩ THE MULTI-REPORT UNIT IDENTITY (SPEC §2.2) — for `gate --report` and every verb that reads a
|
|
58
|
+
* report PREFIX, which may match several sibling files (a workspace writes one report per member).
|
|
59
|
+
*
|
|
60
|
+
* THE KEY IS `hash` REFINED BY `fn`, NOT `hash` ALONE, and that is an engine-MEASURED deviation from
|
|
61
|
+
* candor-rust rather than a hedge. A rust `hash` is a DefPathHash, unique per unit; THIS engine emits
|
|
62
|
+
* `<package>#<local tail>` (scan.mjs), which is not unique — measured on hono's own sources, 13 hashes are
|
|
63
|
+
* shared by two or more DISTINCT `fn`s, with five different `handle` functions all keying `src#handle`.
|
|
64
|
+
* Keying by bare `hash` would MERGE those five into one unit and let each borrow the others' reason
|
|
65
|
+
* classes: the very defect this closes, reopened inside a single report. `hash` refined by `fn` is
|
|
66
|
+
* strictly FINER than either bare key, so it never joins two entries that bare-`fn` kept apart, and it
|
|
67
|
+
* never joins across packages by name — which is what §2.2's MUST is about. No `hash`: the name is the key.
|
|
68
|
+
*
|
|
69
|
+
* The key is a JSON PAIR rather than a joined string, so there is no separator that could occur inside
|
|
70
|
+
* either field and no two distinct (hash, fn) pairs can collapse into one key. A SYNTHETIC key (a
|
|
71
|
+
* callgraph name no entry declares, see `unitsNamed`) is the bare name, which is never JSON-pair shaped.
|
|
72
|
+
*/
|
|
73
|
+
export function reportUnits(functions) {
|
|
74
|
+
const byName = new Map();
|
|
75
|
+
const pkgByKey = new Map();
|
|
76
|
+
const key = (e) => {
|
|
77
|
+
const h = typeof e?.hash === "string" ? e.hash : "";
|
|
78
|
+
return h ? JSON.stringify([h, String(e.fn)]) : String(e?.fn);
|
|
79
|
+
};
|
|
80
|
+
for (const f of functions ?? []) {
|
|
81
|
+
const k = key(f);
|
|
82
|
+
let s = byName.get(f.fn);
|
|
83
|
+
if (!s) { s = new Set(); byName.set(f.fn, s); }
|
|
84
|
+
s.add(k);
|
|
85
|
+
// The unit's PACKAGE, off the `hash` prefix and recorded here rather than parsed back out of the key
|
|
86
|
+
// — the key is an opaque identifier, and reading structure out of an identifier is how a rescue branch
|
|
87
|
+
// comes to depend on the shape a hash happens to have today.
|
|
88
|
+
const h = typeof f.hash === "string" ? f.hash : "";
|
|
89
|
+
const i = h.indexOf("#");
|
|
90
|
+
if (i > 0) pkgByKey.set(k, h.slice(0, i));
|
|
91
|
+
}
|
|
92
|
+
const pkgOf = (k) => pkgByKey.get(k) ?? "";
|
|
93
|
+
return {
|
|
94
|
+
key,
|
|
95
|
+
unitsNamed: (name) => {
|
|
96
|
+
const s = byName.get(name);
|
|
97
|
+
// A name NO entry declares is its own key. The §2.2 sidecar carries EVERY function while the report
|
|
98
|
+
// carries only the effectful ones, so an intermediate hop is routinely name-only — dropping it would
|
|
99
|
+
// break the chain that carries a callee's reason class up to its caller, and a LOST class is exactly
|
|
100
|
+
// how an unanswerable Unknown comes to look answerable.
|
|
101
|
+
return s ? [...s] : [name];
|
|
102
|
+
},
|
|
103
|
+
resolveCall: (callerKey, name) => {
|
|
104
|
+
const s = byName.get(name);
|
|
105
|
+
if (!s || s.size === 0) return name; // not an entry: its own key, as above
|
|
106
|
+
if (s.size === 1) return [...s][0];
|
|
107
|
+
// SAME PACKAGE FIRST, and only where it settles the question on its own: a member calling a name its
|
|
108
|
+
// OWN package declares means that one, which is the common case and unambiguous.
|
|
109
|
+
const mine = pkgOf(callerKey);
|
|
110
|
+
if (mine) {
|
|
111
|
+
const same = [...s].filter((k) => pkgOf(k) === mine);
|
|
112
|
+
if (same.length === 1) return same[0];
|
|
113
|
+
}
|
|
114
|
+
// Otherwise two or more units declare it and nothing here can say which is meant. Picking is what
|
|
115
|
+
// the bare-`fn` join did implicitly, and it is wrong both ways — right by luck when the guess lands,
|
|
116
|
+
// inventing a reach when it does not. The CALLER carries the ambiguity instead; see
|
|
117
|
+
// `resolveReasonClasses`, where dropping it silently was measured reopening this very defect.
|
|
118
|
+
return AMBIGUOUS;
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* ⟨0.32⟩ The effect set the GATE reads for one entry: the report's `inferred`, plus `Unknown` when the
|
|
125
|
+
* merge could not resolve one of this entry's callee names (see `reportUnits`). An unresolvable callee
|
|
126
|
+
* means the transitive set on the wire cannot be completed here, and `Unknown` is what this family says
|
|
127
|
+
* about a reach it cannot follow. Never subtracts.
|
|
128
|
+
*/
|
|
129
|
+
export function effectiveInferred(f, reasonAcc, units = null) {
|
|
130
|
+
const inf = f?.inferred ?? [];
|
|
131
|
+
const amb = reasonAcc?.ambiguous;
|
|
132
|
+
if (!amb || amb.size === 0 || inf.includes("Unknown")) return inf;
|
|
133
|
+
return amb.has(unitKey(units, f)) ? [...inf, "Unknown"] : inf;
|
|
134
|
+
}
|
|
20
135
|
/** Map a raw `unknownWhy` token (e.g. `reflect:eval`, `callback:fetch`) to its normative reason class. */
|
|
21
136
|
export function reasonClass(why) {
|
|
22
137
|
const w = String(why).trim().toLowerCase();
|
|
@@ -49,8 +164,11 @@ export function reasonClass(why) {
|
|
|
49
164
|
// function whose Unknown is purely INHERITED carries no reason of its own — 24% of Unknown-bearing entries
|
|
50
165
|
// on this engine's sources and 57-58% on execa/got. Matching against the direct field reads a field that
|
|
51
166
|
// answers a different question.
|
|
52
|
-
export function resolveReasonClasses(functions, callgraph = {}) {
|
|
167
|
+
export function resolveReasonClasses(functions, callgraph = {}, units = null) {
|
|
168
|
+
const U = units ?? identityUnits();
|
|
53
169
|
const acc = new Map();
|
|
170
|
+
// ⟨0.32⟩ the units whose OWN callee name the merge could not resolve — see the contribution below.
|
|
171
|
+
const ambiguous = new Set();
|
|
54
172
|
// ⟨0.24⟩ THE REACH IS THE REPORT'S OWN, not the sidecar's. §2 embeds the call edges per entry (`calls`)
|
|
55
173
|
// precisely so a consumer without the sidecar can reconstruct the graph, and rust (`reason_class_acc`),
|
|
56
174
|
// java (`gateInputFromReport`: `edges…addAll(e.calls())`) and swift all resolve the reason classes over
|
|
@@ -69,9 +187,37 @@ export function resolveReasonClasses(functions, callgraph = {}) {
|
|
|
69
187
|
if (!s) { s = new Set(); edges.set(caller, s); }
|
|
70
188
|
s.add(callee);
|
|
71
189
|
};
|
|
72
|
-
|
|
190
|
+
// ⟨0.32⟩ NODES AND EDGES. `calls` names a callee by BARE `fn`, so keying the accumulators by a unit key
|
|
191
|
+
// while joining the EDGES by name leaves the same defect one layer down — harder to see, because the
|
|
192
|
+
// node table looks right. Every edge endpoint therefore goes through `units.resolveCall`, which is
|
|
193
|
+
// identity on the scan route and the hash join on the report route.
|
|
194
|
+
const link = (callerKey, name) => {
|
|
195
|
+
const r = U.resolveCall(callerKey, name);
|
|
196
|
+
// AMBIGUOUS: two or more units declare this name and nothing here can say which is meant. Dropping
|
|
197
|
+
// the edge is right — picking would invent a reach — but dropping it SILENTLY is not, and that was
|
|
198
|
+
// measured on candor-rust: the caller lost the reason class it would have inherited, stayed
|
|
199
|
+
// ANSWERABLE through a reason of its own, and a RED verdict went GREEN by adding a report. So the
|
|
200
|
+
// ambiguity is CONTRIBUTED at the caller's entry instead (below), before the fixpoint.
|
|
201
|
+
if (r === AMBIGUOUS) { ambiguous.add(callerKey); return; }
|
|
202
|
+
if (r != null) addEdge(callerKey, r);
|
|
203
|
+
};
|
|
204
|
+
for (const f of functions ?? []) {
|
|
205
|
+
const ck = U.key(f);
|
|
206
|
+
for (const c of f.calls ?? []) link(ck, c);
|
|
207
|
+
}
|
|
73
208
|
for (const [caller, callees] of Object.entries(callgraph ?? {})) {
|
|
74
|
-
|
|
209
|
+
// The SIDECAR's caller is a NAME too, and it takes the SAME rule rather than a carve-out: one unit
|
|
210
|
+
// declaring it resolves, several means the edge cannot be attributed and each candidate carries the
|
|
211
|
+
// ambiguity. Attaching it to all of them would be the name join again, in the caller direction —
|
|
212
|
+
// and that direction is the harmful one, since a borrowed class is what makes an Unknown answerable.
|
|
213
|
+
// A name no entry declares keeps a key of its own (`unitsNamed` returns it), so an intermediate hop
|
|
214
|
+
// the report omits as effect-free still carries classes through, exactly as it did before.
|
|
215
|
+
const ckeys = U.unitsNamed(caller);
|
|
216
|
+
if (ckeys.length === 1) {
|
|
217
|
+
for (const c of Array.isArray(callees) ? callees : []) link(ckeys[0], c);
|
|
218
|
+
} else if (ckeys.length > 1) {
|
|
219
|
+
for (const k of ckeys) ambiguous.add(k);
|
|
220
|
+
}
|
|
75
221
|
}
|
|
76
222
|
for (const f of functions ?? []) {
|
|
77
223
|
const cs = new Set((f.unknownWhy ?? []).map(reasonClass));
|
|
@@ -88,7 +234,17 @@ export function resolveReasonClasses(functions, callgraph = {}) {
|
|
|
88
234
|
// direct Unknown it could not name (scan.mjs), which is the same rule one layer earlier, at the source.
|
|
89
235
|
// It fires for a FOREIGN report (java/rust/swift/an older build), which every query verb also reads.
|
|
90
236
|
if ((f.direct ?? []).includes("Unknown") && !(f.unknownWhy ?? []).length) cs.add("unresolved");
|
|
91
|
-
if (cs.size) acc.set(f
|
|
237
|
+
if (cs.size) acc.set(U.key(f), cs);
|
|
238
|
+
}
|
|
239
|
+
// ⟨0.32⟩ …and the AMBIGUITY CONTRIBUTES, exactly as the reasonless Unknown above it does — at the
|
|
240
|
+
// caller's own entry, before the fixpoint. `dispatch` is the right class by the vocabulary's own
|
|
241
|
+
// definition ("unresolved virtual/dynamic dispatch, SAME-NAME AMBIGUITY"), and it is evidence THIS
|
|
242
|
+
// merge holds — it saw two declarers — never a class borrowed from another function's body, so it
|
|
243
|
+
// cannot make some other function's Unknown answerable, which is what the original defect did.
|
|
244
|
+
for (const k of ambiguous) {
|
|
245
|
+
let s = acc.get(k);
|
|
246
|
+
if (!s) { s = new Set(); acc.set(k, s); }
|
|
247
|
+
s.add("dispatch");
|
|
92
248
|
}
|
|
93
249
|
// …then propagate over the call graph to a fixpoint, so `Unknown[reflect]` at a caller inheriting
|
|
94
250
|
// Unknown from a reflect-caused callee still fires (matches java/rust reasonClassAcc).
|
|
@@ -104,6 +260,10 @@ export function resolveReasonClasses(functions, callgraph = {}) {
|
|
|
104
260
|
}
|
|
105
261
|
}
|
|
106
262
|
}
|
|
263
|
+
// ⟨0.32⟩ The unresolvable-callee set rides the accumulator NON-ENUMERABLY (the `zeroMatch` precedent
|
|
264
|
+
// below), so every existing caller keeps a plain `Map` and nothing that serializes one acquires a field
|
|
265
|
+
// the spec has not pinned. `effectiveInferred` is the only reader.
|
|
266
|
+
Object.defineProperty(acc, "ambiguous", { value: ambiguous, enumerable: false });
|
|
107
267
|
return acc;
|
|
108
268
|
}
|
|
109
269
|
|
|
@@ -495,6 +655,29 @@ export function refusalVerdict(spec, reason, unevaluated = null) {
|
|
|
495
655
|
return o;
|
|
496
656
|
}
|
|
497
657
|
|
|
658
|
+
/** ⟨0.32⟩ **THE VERDICT'S ROW ORDER — SPEC §2: "the sort key MUST include that identity".**
|
|
659
|
+
*
|
|
660
|
+
* The other half of the ⟨0.32⟩ identity clause, stated separately in the spec because carrying `hash`
|
|
661
|
+
* in the ROW without carrying it in the KEY is half a fix. `(rule, detail)` TIES on two units that
|
|
662
|
+
* share a name — `detail` is built from the NAME — so the pair's order was whatever each route happened
|
|
663
|
+
* to accumulate in, and §3.3.1 makes the document's ORDER part of the byte-equality between
|
|
664
|
+
* `scan --policy` and `gate --report`. This engine had NO sort at all on either route: order was the
|
|
665
|
+
* `functions`-array order crossed with policy-rule order, which agrees between the two routes only for
|
|
666
|
+
* as long as the report is written in the order the scan discovered.
|
|
667
|
+
*
|
|
668
|
+
* ONE definition, called from both assembly sites — two copies of a rule is how this family ended up
|
|
669
|
+
* with two element rules for the manifest reader. Compared with `<`/`>` and never `localeCompare`:
|
|
670
|
+
* ⟨0.24⟩ §3.3.1 requires every ordering to be locale-INDEPENDENT. `Array.prototype.sort` has been
|
|
671
|
+
* stable since ES2019, so rows with an equal key keep their arrival order.
|
|
672
|
+
*/
|
|
673
|
+
export function sortViolations(violations) {
|
|
674
|
+
const c = (x, y) => (x < y ? -1 : x > y ? 1 : 0);
|
|
675
|
+
return violations.sort((a, b) =>
|
|
676
|
+
c(a?.rule ?? "", b?.rule ?? "")
|
|
677
|
+
|| c(a?.detail ?? "", b?.detail ?? "")
|
|
678
|
+
|| c(a?.hash ?? "", b?.hash ?? ""));
|
|
679
|
+
}
|
|
680
|
+
|
|
498
681
|
/** §6.2 scope match: by NAME SEGMENT, last segment a prefix.
|
|
499
682
|
* Segments split on BOTH "." and "::" — Rust/Java qualify with "::" while TS uses ".", and a shared
|
|
500
683
|
* policy must match across engines (a `Foo::bar` scope authored against Rust was inert in TS before). */
|
|
@@ -522,13 +705,26 @@ export function scopeMatchesPermitted(name, scope) {
|
|
|
522
705
|
}
|
|
523
706
|
|
|
524
707
|
export function scopeMatches(name, scope) {
|
|
708
|
+
// A TRAILING SEPARATOR MEANS EXACT SEGMENT — `app::` matches the segment `app` and nothing else,
|
|
709
|
+
// while bare `app` keeps the documented prefix behaviour and still matches `application_name`.
|
|
710
|
+
//
|
|
711
|
+
// REPORTED FROM THE FIELD (2026-08-23) and reproduced four-way: `forbid aws -> app` fired 14 times on
|
|
712
|
+
// honest AWS SDK calls, and writing `app::` did not help — the split drops empty parts, so `app::`
|
|
713
|
+
// became exactly ["app"] and the separator never survived to mean anything. The reporter deleted the
|
|
714
|
+
// rule, which is the real cost: the genuine violation it existed to catch will now never fire, and
|
|
715
|
+
// NOTHING in the policy file records that a boundary stopped being checked.
|
|
716
|
+
//
|
|
717
|
+
// ADDITIVE: bare `app` is unchanged, so no existing verdict moves. Only `app::` — which today
|
|
718
|
+
// silently behaves as `app` — starts meaning what everyone who writes it intends.
|
|
719
|
+
const exact = /(::|\.)\s*$/.test(scope);
|
|
525
720
|
const segs = name.split(/[.:]+/).filter(Boolean);
|
|
526
721
|
const parts = scope.split(/[.:]+/).filter(Boolean);
|
|
527
722
|
if (parts.length === 0 || parts.length > segs.length) return false;
|
|
528
723
|
const last = parts[parts.length - 1], init = parts.slice(0, -1);
|
|
529
724
|
outer: for (let i = 0; i + parts.length <= segs.length; i++) {
|
|
530
725
|
for (let k = 0; k < init.length; k++) if (segs[i + k] !== init[k]) continue outer;
|
|
531
|
-
|
|
726
|
+
const tail = segs[i + parts.length - 1];
|
|
727
|
+
if (exact ? tail === last : tail.startsWith(last)) return true;
|
|
532
728
|
}
|
|
533
729
|
return false;
|
|
534
730
|
}
|
|
@@ -593,15 +789,18 @@ export function literalAllowed(effect, reached, values) {
|
|
|
593
789
|
// entry (exit 2), so no gate DECISION rests on the empty set; what the mode additionally prevents is
|
|
594
790
|
// the class list a BARE `deny Net` violation REPORTS being re-derived from `hosts` — asserting a
|
|
595
791
|
// destination class the report never made, in a field a consumer reads as the producer's judgment.
|
|
596
|
-
export function reportNetClasses(functions, { authoritative = false } = {}) {
|
|
792
|
+
export function reportNetClasses(functions, { authoritative = false, units = null } = {}) {
|
|
597
793
|
const m = new Map();
|
|
598
794
|
for (const f of functions ?? []) {
|
|
599
795
|
const carried = Array.isArray(f.netClass) ? f.netClass : [];
|
|
600
796
|
if (carried.length === 0 && !(authoritative && (f.inferred ?? []).includes("Net"))) continue;
|
|
601
|
-
|
|
602
|
-
//
|
|
603
|
-
//
|
|
604
|
-
|
|
797
|
+
// ⟨0.32⟩ keyed by the UNIT, not by the bare name — two members of a workspace may legitimately share
|
|
798
|
+
// one (SPEC §2.2), and merging their destination classes is the same borrowing the reason-class merge
|
|
799
|
+
// was measured doing. A repeated KEY is one unit reported twice; UNION rather than overwrite — the
|
|
800
|
+
// direction that cannot turn a violation into a pass (java `gateInputFromReport` merges the same way).
|
|
801
|
+
const k = unitKey(units, f);
|
|
802
|
+
const prev = m.get(k);
|
|
803
|
+
m.set(k, prev ? [...new Set([...prev, ...carried])].sort() : carried);
|
|
605
804
|
}
|
|
606
805
|
return m;
|
|
607
806
|
}
|
|
@@ -730,11 +929,16 @@ export function wholePolicyUnanswerable(pol, verb = "this route") {
|
|
|
730
929
|
};
|
|
731
930
|
}
|
|
732
931
|
|
|
733
|
-
|
|
932
|
+
// ⟨0.32⟩ THE WITHHOLD IS KEYED BY UNIT; THE DISCLOSURE NAMES THE FUNCTION. Keying the held set by name
|
|
933
|
+
// would withhold the rule on EVERY same-named unit — and a withheld triple is a violation NOT reported,
|
|
934
|
+
// so that is the fail-OPEN direction: one member's missing evidence deleting another member's certain
|
|
935
|
+
// violation. `evaluatePolicy` and `narrowingContext` pass the unit key in turn. `why` still lists NAMES,
|
|
936
|
+
// because a key is not something an operator can look up in their own source.
|
|
937
|
+
export function unanswerableScoped(pol, functions, reasonAcc, netMap, units = null) {
|
|
734
938
|
const held = new Set(), byRule = new Map();
|
|
735
|
-
const key = (raw,
|
|
736
|
-
const note = (r, fn, eff, why) => {
|
|
737
|
-
held.add(key(r.raw,
|
|
939
|
+
const key = (raw, uk, eff) => `${raw}\u0000${uk}\u0000${eff}`;
|
|
940
|
+
const note = (r, uk, fn, eff, why) => {
|
|
941
|
+
held.add(key(r.raw, uk, eff));
|
|
738
942
|
let e = byRule.get(r.raw);
|
|
739
943
|
if (!e) { e = { rule: r.raw, fns: [], why }; byRule.set(r.raw, e); }
|
|
740
944
|
e.fns.push(fn);
|
|
@@ -742,15 +946,16 @@ export function unanswerableScoped(pol, functions, reasonAcc, netMap) {
|
|
|
742
946
|
for (const r of pol.deny) {
|
|
743
947
|
for (const f of functions) {
|
|
744
948
|
if (r.scope && !scopeMatches(f.fn, r.scope)) continue;
|
|
745
|
-
const
|
|
746
|
-
|
|
747
|
-
|
|
949
|
+
const uk = unitKey(units, f);
|
|
950
|
+
const inf = effectiveInferred(f, reasonAcc, units);
|
|
951
|
+
if (r.netClasses?.length && inf.includes("Net") && !(netMap.get(uk)?.length))
|
|
952
|
+
note(r, uk, f.fn, "Net", "narrows on the Net DESTINATION CLASS, but %F carr%S Net with no "
|
|
748
953
|
+ "`netClass` in this report — the field the filter reads is absent, so the narrowing would "
|
|
749
954
|
+ "succeed for lack of evidence and drop a Net the bare `deny Net` catches. NOT EVALUATED for "
|
|
750
955
|
+ "those functions rather than passed: an absent optional field must not relax a fail-closed "
|
|
751
956
|
+ "gate. Use the bare `deny Net`, or gate at scan time (candor-ts <src> --policy <file>).");
|
|
752
|
-
if (r.unknownClasses?.length && inf.includes("Unknown") && !(reasonAcc.get(
|
|
753
|
-
note(r, f.fn, "Unknown", "narrows on the Unknown REASON CLASS, but %F carr%S Unknown with no "
|
|
957
|
+
if (r.unknownClasses?.length && inf.includes("Unknown") && !(reasonAcc.get(uk)?.size))
|
|
958
|
+
note(r, uk, f.fn, "Unknown", "narrows on the Unknown REASON CLASS, but %F carr%S Unknown with no "
|
|
754
959
|
+ "reason reachable in this report — neither %P own `unknownWhy` nor a `calls` edge to one. "
|
|
755
960
|
+ "§6.2 resolves the class set TRANSITIVELY over the gate's reach; with the channel missing, "
|
|
756
961
|
+ "every narrowed filter silently tolerates while only the bare `deny Unknown` fires. NOT "
|
|
@@ -766,7 +971,7 @@ export function unanswerableScoped(pol, functions, reasonAcc, netMap) {
|
|
|
766
971
|
return { rule, why: `\`${rule}\` ` + why.replace("%F", list).replace("%S", names.length === 1 ? "ies" : "y")
|
|
767
972
|
.replace("%P", names.length === 1 ? "its" : "their") };
|
|
768
973
|
});
|
|
769
|
-
return { unevaluated, withhold: held.size ? (r,
|
|
974
|
+
return { unevaluated, withhold: held.size ? (r, uk, eff) => held.has(key(r.raw, uk, eff)) : null };
|
|
770
975
|
}
|
|
771
976
|
|
|
772
977
|
/**
|
|
@@ -805,12 +1010,17 @@ export function unanswerableScoped(pol, functions, reasonAcc, netMap) {
|
|
|
805
1010
|
/** ⟨0.24⟩ ONE definition of a function's Net destination classes for a run: the report's own field when it
|
|
806
1011
|
* carries one (reportNetClasses), else the derivation from the surfaces the caller supplied. Exported so
|
|
807
1012
|
* the ADVISORY verbs resolve the class the same way the gate does — see `classFilterExcludes`. */
|
|
808
|
-
export function netClassResolver(incomplete = new Map(), partners = new Set(), netClasses = null) {
|
|
1013
|
+
export function netClassResolver(incomplete = new Map(), partners = new Set(), netClasses = null, units = null) {
|
|
809
1014
|
// `has`, not `get(…) ?? derive`: an entry mapped to the EMPTY set is a report that carried no class, and
|
|
810
1015
|
// on the authoritative route that absence is the answer — deriving one there would re-classify.
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
1016
|
+
// ⟨0.32⟩ `netClasses` is keyed by UNIT (reportNetClasses); `incomplete` stays keyed by NAME, because it
|
|
1017
|
+
// is the SCAN route's own live structure and that route's keys are names by construction.
|
|
1018
|
+
return (f) => {
|
|
1019
|
+
const k = unitKey(units, f);
|
|
1020
|
+
return (netClasses && netClasses.has(k))
|
|
1021
|
+
? netClasses.get(k)
|
|
1022
|
+
: netClassesOf(f.hosts ?? [], incomplete.get(f.fn)?.has("Net") ?? false, partners);
|
|
1023
|
+
};
|
|
814
1024
|
}
|
|
815
1025
|
|
|
816
1026
|
/**
|
|
@@ -842,35 +1052,88 @@ export function netClassResolver(incomplete = new Map(), partners = new Set(), n
|
|
|
842
1052
|
* An entry ABSENT from the report excludes NOTHING: a missing entry is missing evidence, and the direction
|
|
843
1053
|
* that cannot turn a boundary crossing into a silent pass is "the rule still applies".
|
|
844
1054
|
*/
|
|
845
|
-
export function classFilterExcludes(r, entry, eff, reasonAcc, netClassOf) {
|
|
1055
|
+
export function classFilterExcludes(r, entry, eff, reasonAcc, netClassOf, units = null) {
|
|
846
1056
|
if (!entry) return false;
|
|
1057
|
+
// ⟨0.32⟩ the lookup key comes from the ENTRY, never from its name: a name-keyed lookup into a
|
|
1058
|
+
// unit-keyed map does not error, it returns `undefined`, and `undefined` reads here as "no classes" —
|
|
1059
|
+
// which `reasonClassesMatch` floors at `unresolved`. Silent, and in whichever direction the filter falls.
|
|
847
1060
|
if (eff === "Unknown" && r.unknownClasses?.length)
|
|
848
|
-
return !reasonClassesMatch(reasonAcc.get(entry
|
|
1061
|
+
return !reasonClassesMatch(reasonAcc.get(unitKey(units, entry)), r.unknownClasses);
|
|
849
1062
|
if (eff === "Net" && r.netClasses?.length)
|
|
850
1063
|
return !netClassOf(entry).some((c) => r.netClasses.includes(c));
|
|
851
1064
|
return false;
|
|
852
1065
|
}
|
|
853
1066
|
|
|
854
|
-
export function evaluatePolicy(pol, functions, callgraph, incomplete = new Map(), partners = new Set(), netClasses = null, withhold = null) {
|
|
1067
|
+
export function evaluatePolicy(pol, functions, callgraph, incomplete = new Map(), partners = new Set(), netClasses = null, withhold = null, units = null, hashByName = null) {
|
|
855
1068
|
const out = [];
|
|
856
1069
|
// `Llm` ⟨0.13⟩ reaches the SAME hosts surface as Net (an Llm host WAS captured as a Net host literal).
|
|
857
1070
|
const surfaces = { Net: "hosts", Llm: "hosts", Exec: "cmds", Fs: "paths", Db: "tables" };
|
|
858
1071
|
// §6.2 ⟨0.19⟩: `reasonClass` (all classes on the fn) rides an AS-EFF-006 Unknown violation; ⟨0.20⟩ `netClass`
|
|
859
1072
|
// (all destination classes on the fn) rides a Net violation. Both omitted when empty (byte-identical verdict).
|
|
860
|
-
|
|
861
|
-
|
|
1073
|
+
//
|
|
1074
|
+
// ⟨0.32⟩ **AND `hash` — THE UNIT THIS ROW IS ABOUT (SPEC §2).** *"A verdict row MUST carry enough
|
|
1075
|
+
// identity for a consumer to tell two units apart… the sort key MUST include that identity."* MEASURED
|
|
1076
|
+
// on this engine 2026-08-24, `gate --report` over two reports whose members both define `go` and both
|
|
1077
|
+
// violate `deny Exec`: two BYTE-IDENTICAL rows, `{rule, fn, effects, detail}`, with nothing
|
|
1078
|
+
// attributing either to a package. A reader cannot tell two broken members from one listed twice, and
|
|
1079
|
+
// a consumer that fingerprints on name alone — candor's own SARIF action did — hides one finding
|
|
1080
|
+
// behind the other.
|
|
1081
|
+
//
|
|
1082
|
+
// `hash` AND NOT `package`/`loc`, because §2.2 already binds a consumer to join a verdict row back to
|
|
1083
|
+
// its report entry BY HASH; a row that omits it forces exactly the name join that clause forbids.
|
|
1084
|
+
// BESIDE `fn`, NEVER INSTEAD OF IT: the NAME is what a policy scope matches and what a human reads,
|
|
1085
|
+
// and swapping in the qualified form would silently stop every scoped rule matching — a false green
|
|
1086
|
+
// introduced by fixing a false green. Omitted when the producer has none to give (a hand-authored
|
|
1087
|
+
// report with no `hash`, which §3.1 says this route serves): absent is ⟨0.26⟩'s *cannot answer*,
|
|
1088
|
+
// never a fabricated id. Positioned directly after `fn`, so the row reads `{rule, fn, hash, …}` in
|
|
1089
|
+
// every engine.
|
|
1090
|
+
//
|
|
1091
|
+
// **AND IN THIS ENGINE THE IDENTITY IS THE PAIR, NOT `hash` ALONE — which is not a weaker port, it is
|
|
1092
|
+
// the same key `reportUnits` already uses.** A rust `hash` is a DefPathHash, unique per unit; THIS
|
|
1093
|
+
// engine emits `<package>#<local tail>`, and that is measurably not unique — on hono's own sources 13
|
|
1094
|
+
// hashes are shared by two or more distinct `fn`s, five `handle` functions all keying `src#handle`
|
|
1095
|
+
// (see `reportUnits`, which keys by `hash` REFINED BY `fn` for exactly that reason). So a row here
|
|
1096
|
+
// carries the module-qualified `fn` AND the package-qualified `hash`, and the pair is what tells two
|
|
1097
|
+
// units apart. Emitting `hash` alone would be the identity §2.2 forbids joining on.
|
|
1098
|
+
const push = (rule, fn, effects, detail, reasonClass, netClass, hash) => {
|
|
1099
|
+
const rec = { rule, fn };
|
|
1100
|
+
if (hash) rec.hash = hash;
|
|
1101
|
+
rec.effects = effects;
|
|
1102
|
+
rec.detail = detail;
|
|
862
1103
|
if (reasonClass && reasonClass.length) rec.reasonClass = reasonClass;
|
|
863
1104
|
if (netClass && netClass.length) rec.netClass = netClass;
|
|
864
1105
|
out.push(rec);
|
|
865
1106
|
};
|
|
1107
|
+
// ⟨0.32⟩ NAME -> unit identity, for the two rule codes that iterate the CALL GRAPH rather than the
|
|
1108
|
+
// entry list (AS-EFF-009/011) and so hold a name where the others hold the entry itself. Scan-route
|
|
1109
|
+
// only in practice — `gate --report` passes an EMPTY callgraph, because a report's `calls` is
|
|
1110
|
+
// effect-relevant while those rules match on NAME, so they are refused as unevaluable there — and a
|
|
1111
|
+
// scan gates ONE analysis world, in which a name identifies a unit.
|
|
1112
|
+
//
|
|
1113
|
+
// THE CALLER'S MAP WINS, and that is not a convenience — `forbid`'s violator is TYPICALLY PURE (a
|
|
1114
|
+
// `model.outer` that merely CALLS across a layer performs nothing), and a pure function has NO REPORT
|
|
1115
|
+
// ENTRY, so the fallback below cannot see it. MEASURED 2026-08-24: the AS-EFF-009 row for exactly that
|
|
1116
|
+
// shape came out with no `hash` here while candor-java and candor-swift both emitted one, because they
|
|
1117
|
+
// build the identity from the scan's own name table rather than from the entry list. scan.mjs passes
|
|
1118
|
+
// `unitHash` over `fns`, which holds every function including the pure ones. The fallback stays for the
|
|
1119
|
+
// callers that have no such table (the MCP/LSP routes and the unit tests): FIRST writer wins rather
|
|
1120
|
+
// than last, so its value is a function of the entry list and not of iteration order.
|
|
1121
|
+
const hashOf = hashByName ?? new Map();
|
|
1122
|
+
if (!hashByName)
|
|
1123
|
+
for (const f of functions)
|
|
1124
|
+
if (typeof f?.hash === "string" && f.hash && !hashOf.has(f.fn)) hashOf.set(f.fn, f.hash);
|
|
866
1125
|
// Reason-scoped Unknown: the Unknown reason CLASS travels the call graph the same way the Unknown
|
|
867
1126
|
// EFFECT does. ONE copy of that resolution, shared with the disclosure side — see resolveReasonClasses.
|
|
868
|
-
const reasonAcc = resolveReasonClasses(functions, callgraph);
|
|
1127
|
+
const reasonAcc = resolveReasonClasses(functions, callgraph, units);
|
|
869
1128
|
// ⟨0.24⟩ ONE definition of a function's Net destination classes for this run (netClassResolver above):
|
|
870
1129
|
// the report's own field when it carries one, else the derivation from the surfaces the caller supplied.
|
|
871
1130
|
// The gate's TEST and the class list it REPORTS both read it, so the two can't disagree about one function.
|
|
872
|
-
const netClassOf = netClassResolver(incomplete, partners, netClasses);
|
|
1131
|
+
const netClassOf = netClassResolver(incomplete, partners, netClasses, units);
|
|
873
1132
|
for (const f of functions) {
|
|
1133
|
+
// ⟨0.32⟩ the KEY identifies the unit; `f.fn` is the NAME, and the name is what a policy SCOPE matches
|
|
1134
|
+
// and what the verdict row prints (§3.3.1 byte-equality with `scan --policy` rests on it).
|
|
1135
|
+
const uk = unitKey(units, f);
|
|
1136
|
+
const inferredOf = effectiveInferred(f, reasonAcc, units);
|
|
874
1137
|
for (const r of pol.deny) {
|
|
875
1138
|
if (r.scope && !scopeMatches(f.fn, r.scope)) continue;
|
|
876
1139
|
// `pure` (empty forbidden set) forbids every EFFECT — not `Unknown`, which is the §4 trust
|
|
@@ -878,8 +1141,8 @@ export function evaluatePolicy(pol, functions, callgraph, incomplete = new Map()
|
|
|
878
1141
|
// The reference engine (candor-java) and the rust deep engine exclude it identically; candor-ts
|
|
879
1142
|
// wrongly counted an Unknown-only fn as a `pure` violation until 2026-07-09.
|
|
880
1143
|
const hits = r.effects.length === 0
|
|
881
|
-
?
|
|
882
|
-
:
|
|
1144
|
+
? inferredOf.filter((e) => e !== "Unknown")
|
|
1145
|
+
: inferredOf.filter((e) => r.effects.includes(e));
|
|
883
1146
|
// Reason-scoped Unknown: a `deny E Unknown[classes]` keeps its Unknown hit only for a fn whose
|
|
884
1147
|
// TRANSITIVE reason classes include one of those; Net destination-class: a `deny Net[dest…]` keeps its
|
|
885
1148
|
// Net hit only for a fn reaching one of those destinations, else tolerates (only asserted-safe ones).
|
|
@@ -887,16 +1150,16 @@ export function evaluatePolicy(pol, functions, callgraph, incomplete = new Map()
|
|
|
887
1150
|
// travels the call graph via f.hosts + f.incomplete, propagated transitively before the gate (scan.mjs).
|
|
888
1151
|
// ⟨0.24⟩ BOTH now live in `classFilterExcludes`, so `fix-gate` and `unverified` apply the same filter
|
|
889
1152
|
// instead of computing from the effect set alone (see that function's measurement).
|
|
890
|
-
let kept = hits.filter((e) => !classFilterExcludes(r, f, e, reasonAcc, netClassOf));
|
|
1153
|
+
let kept = hits.filter((e) => !classFilterExcludes(r, f, e, reasonAcc, netClassOf, units));
|
|
891
1154
|
// ⟨0.24⟩ …and LAST, because it overrides the matchers rather than joining them: an effect whose match
|
|
892
1155
|
// this report cannot evidence is neither a violation nor a pass. The caller lists it as `unevaluated`.
|
|
893
|
-
if (withhold && kept.length) kept = kept.filter((e) => !withhold(r,
|
|
1156
|
+
if (withhold && kept.length) kept = kept.filter((e) => !withhold(r, uk, e));
|
|
894
1157
|
if (kept.length) {
|
|
895
1158
|
// When Unknown is denied, report ALL reason classes on the fn (transitive) — every reason the gate bit.
|
|
896
|
-
const rc = kept.includes("Unknown") ? [...(reasonAcc.get(
|
|
1159
|
+
const rc = kept.includes("Unknown") ? [...(reasonAcc.get(uk) ?? [])].sort() : undefined;
|
|
897
1160
|
// ⟨0.20⟩ when Net is denied, report ALL of the fn's destination classes (transitive).
|
|
898
1161
|
const ncv = kept.includes("Net") ? netClassOf(f) : undefined;
|
|
899
|
-
push("AS-EFF-006", f.fn, kept, `\`${f.fn}\` performs { ${kept.join(", ")} }, forbidden by policy: \`${r.raw}\``, rc, ncv);
|
|
1162
|
+
push("AS-EFF-006", f.fn, kept, `\`${f.fn}\` performs { ${kept.join(", ")} }, forbidden by policy: \`${r.raw}\``, rc, ncv, f.hash);
|
|
900
1163
|
}
|
|
901
1164
|
}
|
|
902
1165
|
for (const r of pol.allow) {
|
|
@@ -912,10 +1175,10 @@ export function evaluatePolicy(pol, functions, callgraph, incomplete = new Map()
|
|
|
912
1175
|
const surfaceIncomplete = incomplete.get(f.fn)?.has(r.effect)
|
|
913
1176
|
|| (r.effect === "Llm" && incomplete.get(f.fn)?.has("Net"));
|
|
914
1177
|
if (reached.length === 0 || surfaceIncomplete) {
|
|
915
|
-
push("AS-EFF-008", f.fn, [r.effect], `\`${f.fn}\` performs ${r.effect} with no visible literal — the surface cannot be certified: \`${r.raw}
|
|
1178
|
+
push("AS-EFF-008", f.fn, [r.effect], `\`${f.fn}\` performs ${r.effect} with no visible literal — the surface cannot be certified: \`${r.raw}\``, undefined, undefined, f.hash);
|
|
916
1179
|
} else {
|
|
917
1180
|
const bad = reached.filter((v) => !literalAllowed(r.effect, v, r.values));
|
|
918
|
-
if (bad.length) push("AS-EFF-008", f.fn, [r.effect], `\`${f.fn}\` reaches { ${bad.join(", ")} } outside the allowlist: \`${r.raw}
|
|
1181
|
+
if (bad.length) push("AS-EFF-008", f.fn, [r.effect], `\`${f.fn}\` reaches { ${bad.join(", ")} } outside the allowlist: \`${r.raw}\``, undefined, undefined, f.hash);
|
|
919
1182
|
}
|
|
920
1183
|
}
|
|
921
1184
|
}
|
|
@@ -933,7 +1196,7 @@ export function evaluatePolicy(pol, functions, callgraph, incomplete = new Map()
|
|
|
933
1196
|
queue.push(c);
|
|
934
1197
|
}
|
|
935
1198
|
}
|
|
936
|
-
if (hit) push("AS-EFF-009", fn, [], `\`${fn}\` reaches into a forbidden layer (via \`${hit}\`), violating policy: \`${r.raw}
|
|
1199
|
+
if (hit) push("AS-EFF-009", fn, [], `\`${fn}\` reaches into a forbidden layer (via \`${hit}\`), violating policy: \`${r.raw}\``, undefined, undefined, hashOf.get(fn));
|
|
937
1200
|
}
|
|
938
1201
|
}
|
|
939
1202
|
// ⟨0.29⟩ AS-EFF-011 — `only A -> B …`: a fn in A may reach A and the listed scopes, NOTHING else. The
|
|
@@ -963,7 +1226,7 @@ export function evaluatePolicy(pol, functions, callgraph, incomplete = new Map()
|
|
|
963
1226
|
// ⟨0.29⟩ ITS OWN CODE, not `forbid`'s — a rule code is what a CI suppression keys on, and these two
|
|
964
1227
|
// are opposite constructs. Sharing 009 would make an existing `forbid` suppression silently mute
|
|
965
1228
|
// `only` violations its author never accepted.
|
|
966
|
-
if (hit) push("AS-EFF-011", fn, [], `\`${fn}\` reaches \`${hit}\`, which this permission rule does not permit: \`${r.raw}
|
|
1229
|
+
if (hit) push("AS-EFF-011", fn, [], `\`${fn}\` reaches \`${hit}\`, which this permission rule does not permit: \`${r.raw}\``, undefined, undefined, hashOf.get(fn));
|
|
967
1230
|
}
|
|
968
1231
|
}
|
|
969
1232
|
// ⟨0.27⟩ SPEC §4 — A RULE WHOSE SCOPE BOUND NO FUNCTION IS UNANSWERABLE, AND IS DISCLOSED RATHER THAN
|