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/AGENTS.md +54 -6
- package/README.md +12 -3
- package/lsp.mjs +103 -8
- package/mcp.mjs +141 -15
- package/package.json +8 -5
- package/policy.mjs +600 -52
- package/query-core.mjs +748 -52
- package/query.mjs +454 -20
- package/scan.mjs +1934 -155
- package/transitive-recall.mjs +385 -0
- package/verify-core.mjs +32 -9
- package/verify-emit.mjs +88 -3
- package/verify.mjs +8 -2
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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) {
|
|
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) {
|
|
108
|
-
|
|
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) {
|
|
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) {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
200
|
-
// EFFECT does
|
|
201
|
-
|
|
202
|
-
//
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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;
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
//
|
|
239
|
-
//
|
|
240
|
-
|
|
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
|
-
|
|
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`);
|