candor-ts 0.19.0 → 0.20.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 +1 -1
- package/README.md +2 -2
- package/package.json +2 -2
- package/policy.mjs +63 -7
- package/query-core.mjs +45 -4
- package/query.mjs +25 -7
- package/scan-core.mjs +59 -0
- package/scan.mjs +11 -4
package/AGENTS.md
CHANGED
|
@@ -12,7 +12,7 @@ chains by hand.
|
|
|
12
12
|
> **Already installed? Report the version and ask before upgrading — before you scan.** If this
|
|
13
13
|
> project already has candor (a `.candor/` report directory, or `candor-ts` reachable via `npx`/a
|
|
14
14
|
> global install), do this *first*: run `npx -y candor-ts --version` (offline) and **tell the user
|
|
15
|
-
> plainly which version they're on** — e.g. *"This project is on candor-ts `<version>` (spec 0.
|
|
15
|
+
> plainly which version they're on** — e.g. *"This project is on candor-ts `<version>` (spec 0.20)."*
|
|
16
16
|
> On a build too old for the flag, read `candor.version` / `candor.spec` from an existing
|
|
17
17
|
> `.candor/report*.json`, or `npm ls -g candor-ts`.
|
|
18
18
|
>
|
package/README.md
CHANGED
|
@@ -184,7 +184,7 @@ pure-vs-Unknown ruling (PART 16) — the engines must answer identically, on eve
|
|
|
184
184
|
| A call resolving to a *type* (function-typed field/param) → `Unknown`, never silent-pure | SPEC §4 |
|
|
185
185
|
| Unmatched external calls contribute nothing (curated-classifier caveat) | SEMANTICS §8 C1 |
|
|
186
186
|
| The literal surfaces `hosts`/`cmds`/`paths`/`tables`, literal-read only | SPEC §2 |
|
|
187
|
-
| `{ candor: { version, toolchain, spec: "0.
|
|
187
|
+
| `{ candor: { version, toolchain, spec: "0.20" }, functions }` envelope; pure fns omitted | SPEC §2/§2.1 |
|
|
188
188
|
| Call-graph sidecar with **every** analyzed function a key | SPEC §2.2 |
|
|
189
189
|
| The gate: AS-EFF-006 / 008 / 009, loud on an unreadable policy | SPEC §6.2 |
|
|
190
190
|
|
|
@@ -202,7 +202,7 @@ read the Rust source".
|
|
|
202
202
|
|
|
203
203
|
## Status
|
|
204
204
|
|
|
205
|
-
0.
|
|
205
|
+
0.19.x, speaking candor-spec 0.20: the analysis core, the gate (`--policy` / `--gate-json` /
|
|
206
206
|
`.candor/config`), the full §3.1 query surface (including `containment`, `blindspots`, the
|
|
207
207
|
`--include-unknown` dispatch frontier), the MCP server, the LSP server, and the watch loop are
|
|
208
208
|
real, behaviorally tested (`npm test` — the behavioral suite across six harnesses), **soundness-fuzzed
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "candor-ts",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "candor for TypeScript — per-function side effects, transitively, with a policy gate (candor-spec 0.
|
|
3
|
+
"version": "0.20.0",
|
|
4
|
+
"description": "candor for TypeScript — per-function side effects, transitively, with a policy gate (candor-spec 0.20)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"dependencies": {
|
|
7
7
|
"@types/node": "^25.9.2",
|
package/policy.mjs
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* engines follow (candor-classify::policy), so the TS gate can never disagree with its own whatif.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import { NET_DEST_CLASSES, netClassesOf } from "./scan-core.mjs";
|
|
8
|
+
|
|
7
9
|
export const EFFECTS = ["Net", "Fs", "Db", "Exec", "Env", "Clock", "Ipc", "Log", "Rand", "Clipboard", "Llm"];
|
|
8
10
|
|
|
9
11
|
// Reason-scoped Unknown (REASON-SCOPED-UNKNOWN-DESIGN.md): the CLOSED, cross-engine reason-class set a
|
|
@@ -52,7 +54,23 @@ export function parsePolicy(text, aliases = null) {
|
|
|
52
54
|
// form); non-empty ⇒ only those classes. `*` = all; `dynamic` = every genuine class.
|
|
53
55
|
const unknownClasses = new Set();
|
|
54
56
|
let unknownStar = false;
|
|
57
|
+
// Destination-class filter on a `Net` membership (NET-DESTINATION-CLASS-DESIGN.md): empty ⇒ `Net[*]`
|
|
58
|
+
// (any destination — the bare form); non-empty ⇒ only those classes. `*` = all.
|
|
59
|
+
const netClasses = new Set();
|
|
60
|
+
let netStar = false;
|
|
55
61
|
for (const tok of t.slice(1)) {
|
|
62
|
+
const nm = /^Net\[(.*)\]$/.exec(tok);
|
|
63
|
+
if (nm) {
|
|
64
|
+
effects.push("Net");
|
|
65
|
+
for (let cn of nm[1].split(",")) {
|
|
66
|
+
cn = cn.trim();
|
|
67
|
+
if (!cn) continue;
|
|
68
|
+
if (cn === "*") netStar = true;
|
|
69
|
+
else if (NET_DEST_CLASSES.includes(cn)) netClasses.add(cn);
|
|
70
|
+
else warn(`unknown Net destination-class \`${cn}\` (known: ${NET_DEST_CLASSES.join(",")}, or *)`);
|
|
71
|
+
}
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
56
74
|
const m = /^Unknown\[(.*)\]$/.exec(tok);
|
|
57
75
|
if (m) {
|
|
58
76
|
effects.push("Unknown");
|
|
@@ -70,18 +88,21 @@ export function parsePolicy(text, aliases = null) {
|
|
|
70
88
|
if (EFFECTS.includes(tok) || tok === "Unknown") {
|
|
71
89
|
effects.push(tok);
|
|
72
90
|
if (tok === "Unknown") unknownStar = true; // bare Unknown ⇒ all classes
|
|
91
|
+
if (tok === "Net") netStar = true; // bare Net ⇒ all destinations
|
|
73
92
|
} else { scope = tok; break; }
|
|
74
93
|
}
|
|
75
94
|
if (effects.length === 0) { warn("deny names no known effect"); continue; }
|
|
76
95
|
// `*` (or bare Unknown) means all classes ⇒ empty filter (matches any Unknown).
|
|
77
96
|
let uc = unknownStar ? [] : [...unknownClasses].sort();
|
|
97
|
+
// `*` (or bare Net) means all destinations ⇒ empty filter (matches any Net).
|
|
98
|
+
const nc = netStar ? [] : [...netClasses].sort();
|
|
78
99
|
// A2 under-gating lint: a narrowed scope omitting `unresolved` (the catch-all for holes the engine
|
|
79
100
|
// couldn't classify) may silently tolerate exactly those — flag it (advisory, non-fatal).
|
|
80
101
|
if (uc.length && !uc.includes("unresolved"))
|
|
81
102
|
console.error(`candor: policy rule narrows \`Unknown[…]\` but omits \`unresolved\` — may UNDER-gate on holes the engine couldn't classify; add \`unresolved\` (or use \`dynamic\`): ${line}`);
|
|
82
|
-
deny.push({ effects: [...new Set(effects)].sort(), scope, unknownClasses: uc, raw: line }); // dedup: a set, like rust/java
|
|
103
|
+
deny.push({ effects: [...new Set(effects)].sort(), scope, unknownClasses: uc, netClasses: nc, raw: line }); // dedup: a set, like rust/java
|
|
83
104
|
} else if (t[0] === "pure") {
|
|
84
|
-
deny.push({ effects: [], scope: t[1] ?? "", unknownClasses: [], raw: line });
|
|
105
|
+
deny.push({ effects: [], scope: t[1] ?? "", unknownClasses: [], netClasses: [], raw: line });
|
|
85
106
|
} else if (t[0] === "allow") {
|
|
86
107
|
if (t.length < 3) { warn("allow names no values"); continue; }
|
|
87
108
|
if (!ALLOW_EFFECTS.has(t[1])) { warn("allow supports only Net hosts / Llm hosts / Exec commands / Fs paths / Db tables"); continue; }
|
|
@@ -163,13 +184,18 @@ export function literalAllowed(effect, reached, values) {
|
|
|
163
184
|
// is the specific denied/allowed effect set the violation concerns ([] for the 009 layer-flow, which has
|
|
164
185
|
// no single effect); `detail` is the message BODY (no `[AS-EFF-00x]` prefix — the rule carries the code).
|
|
165
186
|
// The console gate renders `[${rule}] ${detail}`; --gate-json emits the records verbatim.
|
|
166
|
-
export function evaluatePolicy(pol, functions, callgraph, incomplete = new Map()) {
|
|
187
|
+
export function evaluatePolicy(pol, functions, callgraph, incomplete = new Map(), partners = new Set()) {
|
|
167
188
|
const out = [];
|
|
168
189
|
// `Llm` ⟨0.13⟩ reaches the SAME hosts surface as Net (an Llm host WAS captured as a Net host literal).
|
|
169
190
|
const surfaces = { Net: "hosts", Llm: "hosts", Exec: "cmds", Fs: "paths", Db: "tables" };
|
|
170
|
-
// §6.2 ⟨0.19⟩: `reasonClass` (all classes on the fn) rides an AS-EFF-006 Unknown violation;
|
|
171
|
-
|
|
172
|
-
|
|
191
|
+
// §6.2 ⟨0.19⟩: `reasonClass` (all classes on the fn) rides an AS-EFF-006 Unknown violation; ⟨0.20⟩ `netClass`
|
|
192
|
+
// (all destination classes on the fn) rides a Net violation. Both omitted when empty (byte-identical verdict).
|
|
193
|
+
const push = (rule, fn, effects, detail, reasonClass, netClass) => {
|
|
194
|
+
const rec = { rule, fn, effects, detail };
|
|
195
|
+
if (reasonClass && reasonClass.length) rec.reasonClass = reasonClass;
|
|
196
|
+
if (netClass && netClass.length) rec.netClass = netClass;
|
|
197
|
+
out.push(rec);
|
|
198
|
+
};
|
|
173
199
|
// Reason-scoped Unknown: the Unknown reason CLASS must travel the call graph the same way the Unknown
|
|
174
200
|
// EFFECT does (unknownWhy in the report is direct-only). Classify each fn's DIRECT reasons to class
|
|
175
201
|
// tokens, then propagate transitively over `callgraph` to a fixpoint — so `deny E Unknown[reflect]` at a
|
|
@@ -209,10 +235,22 @@ export function evaluatePolicy(pol, functions, callgraph, incomplete = new Map()
|
|
|
209
235
|
const fnClasses = cs && cs.size ? [...cs] : ["unresolved"];
|
|
210
236
|
if (!fnClasses.some((c) => r.unknownClasses.includes(c))) kept = hits.filter((e) => e !== "Unknown");
|
|
211
237
|
}
|
|
238
|
+
// Net destination-class: a `deny Net[dest…]` keeps its Net hit only for a fn reaching one of those
|
|
239
|
+
// destination classes; else tolerate (only asserted-safe destinations). Fail-closed: a masked surface /
|
|
240
|
+
// a Net with no visible host is unknown-host (netClassesOf). The class travels the call graph via
|
|
241
|
+
// f.hosts + f.incomplete, both propagated transitively before the gate (scan.mjs).
|
|
242
|
+
if (kept.includes("Net") && (r.netClasses?.length)) {
|
|
243
|
+
const fnNet = netClassesOf(f.hosts ?? [], incomplete.get(f.fn)?.has("Net") ?? false, partners);
|
|
244
|
+
if (!fnNet.some((c) => r.netClasses.includes(c))) kept = kept.filter((e) => e !== "Net");
|
|
245
|
+
}
|
|
212
246
|
if (kept.length) {
|
|
213
247
|
// When Unknown is denied, report ALL reason classes on the fn (transitive) — every reason the gate bit.
|
|
214
248
|
const rc = kept.includes("Unknown") ? [...(reasonAcc.get(f.fn) ?? [])].sort() : undefined;
|
|
215
|
-
|
|
249
|
+
// ⟨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;
|
|
253
|
+
push("AS-EFF-006", f.fn, kept, `\`${f.fn}\` performs { ${kept.join(", ")} }, forbidden by policy: \`${r.raw}\``, rc, ncv);
|
|
216
254
|
}
|
|
217
255
|
}
|
|
218
256
|
for (const r of pol.allow) {
|
|
@@ -327,3 +365,21 @@ export function parseUnknownAliases(configText) {
|
|
|
327
365
|
}
|
|
328
366
|
return out;
|
|
329
367
|
}
|
|
368
|
+
|
|
369
|
+
// ⟨0.20⟩ Parse `net-partner <host>` lines (NET-DESTINATION-CLASS-DESIGN.md) into a Set of host-normalized
|
|
370
|
+
// partner hosts — the per-project `known-partner` set for the Net destination-class classifier. Multi-value
|
|
371
|
+
// (repeatable key); the value's `:port` is stripped + lowercased like MODEL_HOSTS. Case-insensitive key,
|
|
372
|
+
// mirroring parseUnknownAliases + the java/rust config loaders. A partner is per-project — never universal.
|
|
373
|
+
export function parseNetPartners(configText) {
|
|
374
|
+
const out = new Set();
|
|
375
|
+
if (!configText) return out;
|
|
376
|
+
for (const raw of configText.split(/\r?\n/)) {
|
|
377
|
+
const line = raw.split("#", 1)[0].trim();
|
|
378
|
+
if (!line) continue;
|
|
379
|
+
const m = line.match(/^(\S+)\s+(.*)$/);
|
|
380
|
+
if (!m || m[1].toLowerCase() !== "net-partner") continue;
|
|
381
|
+
const val = m[2].trim();
|
|
382
|
+
if (val) out.add(hostPart(val).toLowerCase());
|
|
383
|
+
}
|
|
384
|
+
return out;
|
|
385
|
+
}
|
package/query-core.mjs
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import fs from "node:fs";
|
|
12
12
|
import nodePath from "node:path";
|
|
13
|
+
import { reasonClass } from "./policy.mjs";
|
|
13
14
|
|
|
14
15
|
// Sibling report/callgraph files of a multi-report prefix (candor-scan writes <prefix>.<crate>.scan.json,
|
|
15
16
|
// one per workspace member) — so the loaders read ANY engine's output, not just candor-ts's <prefix>.json.
|
|
@@ -498,13 +499,32 @@ export function impact(fns, cg, q) {
|
|
|
498
499
|
// Unknown through it). The actionable inverse of a widely-propagated Unknown: a report can read mostly
|
|
499
500
|
// Unknown from a handful of root causes — this names them, ranked, to declare/resolve/accept. Matches
|
|
500
501
|
// candor-java/candor-query: { sources:[{fn,why,reaches,affected}], totalUnknown }.
|
|
501
|
-
|
|
502
|
+
// ⟨0.20⟩ `--class <c,…>` filter: the six reason classes, `dynamic` (every genuine class), or `*` (all).
|
|
503
|
+
// null spec ⇒ no filter; an unknown token warns; an all-unknown spec ⇒ an empty set that matches nothing.
|
|
504
|
+
const ALL_CLASSES = ["reflect", "dispatch", "indirect", "native", "unresolved", "setup"];
|
|
505
|
+
export function parseClassFilter(spec) {
|
|
506
|
+
if (!spec) return null;
|
|
507
|
+
const out = new Set();
|
|
508
|
+
for (let t of spec.split(",")) {
|
|
509
|
+
t = t.trim();
|
|
510
|
+
if (!t) continue;
|
|
511
|
+
if (t === "*") return new Set(ALL_CLASSES);
|
|
512
|
+
if (t === "dynamic") { for (const c of ALL_CLASSES) if (c !== "setup") out.add(c); continue; }
|
|
513
|
+
if (ALL_CLASSES.includes(t)) out.add(t);
|
|
514
|
+
else console.error(`candor-ts: --class ignores unknown reason-class \`${t}\` (known: ${ALL_CLASSES.join(",")}; aliases: dynamic,*)`);
|
|
515
|
+
}
|
|
516
|
+
return out;
|
|
517
|
+
}
|
|
518
|
+
const classMatches = (cf, why) => cf === null || (why ?? []).some((w) => cf.has(reasonClass(w)));
|
|
519
|
+
|
|
520
|
+
export function blindspots(fns, cg, classSpec = null) {
|
|
521
|
+
const cf = parseClassFilter(classSpec);
|
|
502
522
|
const rev = reverseGraph(cg);
|
|
503
523
|
const totalUnknown = fns.filter((e) => (e.inferred ?? []).includes("Unknown")).length;
|
|
504
524
|
const sources = [];
|
|
505
525
|
for (const e of fns) {
|
|
506
526
|
const why = e.unknownWhy ?? [];
|
|
507
|
-
if (why.length === 0) continue; // a SOURCE
|
|
527
|
+
if (why.length === 0 || !classMatches(cf, why)) continue; // a SOURCE of a matching reason class
|
|
508
528
|
const reached = new Set();
|
|
509
529
|
const queue = [e.fn];
|
|
510
530
|
const seen = new Set([e.fn]);
|
|
@@ -519,6 +539,26 @@ export function blindspots(fns, cg) {
|
|
|
519
539
|
return { sources, totalUnknown };
|
|
520
540
|
}
|
|
521
541
|
|
|
542
|
+
// `blindspots --stats` (SPEC §3.1 ⟨0.20⟩): the reason-class DISTRIBUTION over the Unknown SOURCES — how
|
|
543
|
+
// much Unknown, by class {reflect,dispatch,indirect,native,unresolved,setup} — so a team can SIZE the
|
|
544
|
+
// blind-spot cost (and separate genuine dynamism from `setup` mis-config) BEFORE `deny E Unknown`. Counts
|
|
545
|
+
// SOURCE functions per class (a multi-reason fn counts in each class it has). Matches candor-java/rust/swift.
|
|
546
|
+
export function blindspotsStats(fns, classSpec = null) {
|
|
547
|
+
const cf = parseClassFilter(classSpec);
|
|
548
|
+
const ORDER = ["reflect", "dispatch", "indirect", "native", "unresolved", "setup"];
|
|
549
|
+
const byClass = Object.fromEntries(ORDER.map((c) => [c, 0]));
|
|
550
|
+
const totalUnknown = fns.filter((e) => (e.inferred ?? []).includes("Unknown")).length;
|
|
551
|
+
let sources = 0;
|
|
552
|
+
for (const e of fns) {
|
|
553
|
+
const why = e.unknownWhy ?? [];
|
|
554
|
+
if (why.length === 0 || !classMatches(cf, why)) continue;
|
|
555
|
+
sources++;
|
|
556
|
+
const classes = new Set(why.map(reasonClass));
|
|
557
|
+
for (const c of classes) byClass[c]++;
|
|
558
|
+
}
|
|
559
|
+
return { byClass, sources, totalUnknown };
|
|
560
|
+
}
|
|
561
|
+
|
|
522
562
|
// path: the FORWARD provenance — a shortest BFS over the calls graph from `fn` to the nearest unit
|
|
523
563
|
// that performs `eff` DIRECTLY (the source). Matches candor-query's {effect, fn, path:[{fn,loc,source}]}.
|
|
524
564
|
export function path(fns, cg, fnQ, eff) {
|
|
@@ -806,12 +846,13 @@ export function unverifiedHoleRule(fn, inferred, policyParsed, scopeMatches) {
|
|
|
806
846
|
return null;
|
|
807
847
|
}
|
|
808
848
|
|
|
809
|
-
export function unverified(fns, policyParsed, scopeMatches) {
|
|
849
|
+
export function unverified(fns, policyParsed, scopeMatches, classSpec = null) {
|
|
850
|
+
const cf = parseClassFilter(classSpec); // ⟨0.20⟩ --class: keep only holes of a matching reason class
|
|
810
851
|
const holes = [];
|
|
811
852
|
for (const e of fns) {
|
|
812
853
|
// Same predicate + upgrade as the gate note (scan.mjs) — one source of truth for a hole.
|
|
813
854
|
const r = unverifiedHoleRule(e.fn, e.inferred, policyParsed, scopeMatches);
|
|
814
|
-
if (!r) continue;
|
|
855
|
+
if (!r || !classMatches(cf, e.unknownWhy)) continue;
|
|
815
856
|
const [rule, upgrade] = ruleUpgrade(r);
|
|
816
857
|
holes.push({ fn: e.fn, rule, unknownWhy: e.unknownWhy ?? [], upgrade });
|
|
817
858
|
}
|
package/query.mjs
CHANGED
|
@@ -33,7 +33,7 @@ import { isTestPath } from "./scan-core.mjs";
|
|
|
33
33
|
// a `matchTier` missing `#` (so the SAME query resolved differently between `impact` and `callers` on a
|
|
34
34
|
// JVM `Type#method` report). Importing the shared functions removes all three divergences (review find).
|
|
35
35
|
import { impact as coreImpact, path as corePath, gains as coreGains,
|
|
36
|
-
show as coreShow, blindspots as coreBlindspots,
|
|
36
|
+
show as coreShow, blindspots as coreBlindspots, blindspotsStats as coreBlindspotsStats,
|
|
37
37
|
callers as coreCallers, callersFrontier, loadHierarchy,
|
|
38
38
|
containment as coreContainment, diff as coreDiff,
|
|
39
39
|
where as coreWhere, map as coreMap, whatif as coreWhatif,
|
|
@@ -46,7 +46,7 @@ const emit = (v) => console.log(JSON.stringify(v, null, 1));
|
|
|
46
46
|
const KNOWN_EFFECTS = ["Net", "Fs", "Db", "Llm", "Exec", "Env", "Clock", "Ipc", "Log", "Rand", "Clipboard", "Unknown"];
|
|
47
47
|
// Suggest the nearest known flag for a typo (longest shared prefix ≥3): `--polciy` → `--policy` (#2).
|
|
48
48
|
function didYouMeanFlag(unknown) {
|
|
49
|
-
const known = ["--report", "--policy", "--json", "--text", "--strict", "--include-unknown"];
|
|
49
|
+
const known = ["--report", "--policy", "--json", "--text", "--strict", "--include-unknown", "--stats", "--class"];
|
|
50
50
|
const u = unknown.replace(/^-+/, "").toLowerCase();
|
|
51
51
|
let best = null, bestLen = 2;
|
|
52
52
|
for (const k of known) {
|
|
@@ -132,6 +132,12 @@ const P = {
|
|
|
132
132
|
console.log(`candor blindspots — ${d.sources.length} Unknown source${d.sources.length === 1 ? "" : "s"} (of ${d.totalUnknown} function(s) carrying Unknown), most-smearing first:`);
|
|
133
133
|
for (const s of d.sources) console.log(` \`${s.fn}\` — ${csv(s.why)}; reaches ${s.reaches} caller(s)`);
|
|
134
134
|
},
|
|
135
|
+
blindspotsStats: (d) => {
|
|
136
|
+
if (!d.sources) { console.log("candor blindspots --stats — no Unknown sources (nothing to classify). ✓"); return; }
|
|
137
|
+
console.log(`candor blindspots --stats — ${d.sources} Unknown source(s) by reason class (of ${d.totalUnknown} function(s) carrying Unknown) — size the blind-spot cost before \`deny E Unknown[…]\`:`);
|
|
138
|
+
Object.entries(d.byClass).filter(([, v]) => v > 0).sort((a, b) => b[1] - a[1])
|
|
139
|
+
.forEach(([k, v]) => console.log(` ${k.padEnd(12)} ${String(v).padStart(4)}${k === "setup" ? " ← fixable: the scan isn't configured, not a real blind spot" : ""}`));
|
|
140
|
+
},
|
|
135
141
|
gains: (d) => {
|
|
136
142
|
if (!d.gained.length) { console.log("candor gains — no newly-reached effects vs the baseline. ✓"); return; }
|
|
137
143
|
console.log(`candor gains — the surface newly reaches: ${d.gained.join(", ")}`);
|
|
@@ -196,7 +202,7 @@ function renderPathHuman(fns, cg, fnQ, eff) {
|
|
|
196
202
|
// package.json; SPEC_VERSION is the spec contract this build speaks. Reused, never re-littered.
|
|
197
203
|
const QUERY_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
198
204
|
const PKG_VERSION = JSON.parse(fs.readFileSync(path.join(QUERY_DIR, "package.json"), "utf8")).version;
|
|
199
|
-
const SPEC_VERSION = "0.
|
|
205
|
+
const SPEC_VERSION = "0.20";
|
|
200
206
|
|
|
201
207
|
// ---- the §3.3.1 canonical query grammar (⟨0.10⟩, additive over 0.9) --------------------------------
|
|
202
208
|
// One shape for every verb: `<verb> <verb-args…> [--report <locator>] [--policy <file>] [--json]
|
|
@@ -292,11 +298,16 @@ function parseCanonical(rawArgs, { policy = false, strict = false, includeUnknow
|
|
|
292
298
|
// wantJsonOut(rawArgs), never a positional
|
|
293
299
|
if (a === "--strict") { if (strict) wantStrict = true; continue; } // vocabulary — tolerated everywhere,
|
|
294
300
|
if (a === "--include-unknown") { if (includeUnknown) wantIncludeUnknown = true; continue; } // used only by the verb that reads it
|
|
301
|
+
if (a === "--stats") { continue; } // ⟨0.20⟩ tolerated everywhere; read by the `blindspots` case via args.includes
|
|
302
|
+
if (a === "--class") { // ⟨0.20⟩ value flag; the value is read by the `blindspots` case
|
|
303
|
+
if (i + 1 >= rawArgs.length) { console.error("candor-ts: --class requires a <class,…> value (reflect,dispatch,indirect,native,unresolved,setup; aliases: dynamic,*)"); process.exit(2); }
|
|
304
|
+
i++; continue;
|
|
305
|
+
}
|
|
295
306
|
if (a.startsWith("-") && a.length > 1) {
|
|
296
307
|
// An unrecognized flag is a TYPO, not a positional — reject it LOUD (exit 2), never silently swallow.
|
|
297
308
|
// A swallowed `--polciy` runs the query with NO policy and exits green: a CI author who typos --policy
|
|
298
309
|
// ships a gate that never fires (corpus re-audit cardinal sin — a loud error, never a silent guess).
|
|
299
|
-
console.error(`candor-ts-query: unknown flag '${a}'${didYouMeanFlag(a)}\n known flags: --report, --policy, --json, --text, --strict, --include-unknown`);
|
|
310
|
+
console.error(`candor-ts-query: unknown flag '${a}'${didYouMeanFlag(a)}\n known flags: --report, --policy, --json, --text, --strict, --include-unknown, --stats`);
|
|
300
311
|
process.exit(2);
|
|
301
312
|
}
|
|
302
313
|
positionals.push(a);
|
|
@@ -401,7 +412,7 @@ const SUBCOMMANDS = [
|
|
|
401
412
|
["diff", "<current> <baseline> [--json]", "per-function effect delta vs a baseline: {changes:[{fn,gained,lost}]} (exit 1 on a gain)"],
|
|
402
413
|
["reachable", REPORT_TAIL, "effects unioned over the entry points: what the app DOES at runtime"],
|
|
403
414
|
["impact", `<query> ${REPORT_TAIL}`, "blast radius of a function (backward dual of reachable)"],
|
|
404
|
-
["blindspots", REPORT_TAIL
|
|
415
|
+
["blindspots", `${REPORT_TAIL} [--stats] [--class <c,…>]`, "the Unknown sources ranked by blast radius; --stats: reason-class distribution; --class: drill down"],
|
|
405
416
|
["tour", `[<N>] ${REPORT_TAIL}`, "the N most surprising transitive reaches — the guided cold-repo poke (no re-scan)"],
|
|
406
417
|
["gains", "<current> <baseline> [--json] [--strict]", "the supply-chain alarm: what the surface gained between two reports (--strict: exit 1 on ANY gain)"],
|
|
407
418
|
["path", `<fn> <Effect> ${REPORT_TAIL}`, "a call path from a function to where an effect enters"],
|
|
@@ -656,7 +667,13 @@ switch (cmd) {
|
|
|
656
667
|
// the Unknown SOURCES, ranked by blast radius — the actionable inverse of a widely-propagated
|
|
657
668
|
// Unknown (SPEC §3.1 ⟨0.6⟩): { sources:[{fn,why,reaches,affected}], totalUnknown }.
|
|
658
669
|
const { prefix } = resolveReportVerb(args, 0);
|
|
659
|
-
|
|
670
|
+
const ci = args.indexOf("--class");
|
|
671
|
+
const classFilter = ci >= 0 ? args[ci + 1] : null; // ⟨0.20⟩ drill-down by reason class
|
|
672
|
+
if (args.includes("--stats")) { // ⟨0.20⟩ the reason-class distribution, not the source list
|
|
673
|
+
put(args, coreBlindspotsStats(loadReportOrDie(prefix), classFilter), P.blindspotsStats);
|
|
674
|
+
} else {
|
|
675
|
+
put(args, coreBlindspots(loadReportOrDie(prefix), loadCallgraph(prefix), classFilter), P.blindspots);
|
|
676
|
+
}
|
|
660
677
|
break;
|
|
661
678
|
}
|
|
662
679
|
case "tour": {
|
|
@@ -894,7 +911,8 @@ switch (cmd) {
|
|
|
894
911
|
let ptext;
|
|
895
912
|
try { ptext = fs.readFileSync(policyFile, "utf8"); }
|
|
896
913
|
catch { console.error(`candor: policy ${policyFile} could not be read`); process.exit(2); }
|
|
897
|
-
const
|
|
914
|
+
const uci = args.indexOf("--class"); // ⟨0.20⟩ drill-down by reason class
|
|
915
|
+
const r = coreUnverified(loadReportOrDie(prefix), parsePolicy(ptext), scopeMatches, uci >= 0 ? args[uci + 1] : null);
|
|
898
916
|
emit(r);
|
|
899
917
|
process.exit(strict && !r.ok ? 1 : 0);
|
|
900
918
|
break; // unreachable
|
package/scan-core.mjs
CHANGED
|
@@ -293,6 +293,65 @@ export function isModelHost(hostLiteral) {
|
|
|
293
293
|
export function modelHostEffects(hostLiteral) {
|
|
294
294
|
return isModelHost(hostLiteral) ? ["Llm"] : [];
|
|
295
295
|
}
|
|
296
|
+
// ⟨0.20⟩ Curated telemetry / analytics / APM hosts — the `Net` destination-class `known-telemetry` set
|
|
297
|
+
// (NET-DESTINATION-CLASS-DESIGN.md), shared VERBATIM with the sibling engines (java Literals.TELEMETRY_HOSTS
|
|
298
|
+
// / rust TELEMETRY_HOSTS), like MODEL_HOSTS. A benign observability endpoint. Matched by host,
|
|
299
|
+
// case-insensitive; a SUBDOMAIN of a listed host counts. Tight, high-precision STARTER set — mis-including
|
|
300
|
+
// an exfil-capable host would under-gate `deny Net[unknown-host]`.
|
|
301
|
+
export const TELEMETRY_HOSTS = new Set([
|
|
302
|
+
"sentry.io",
|
|
303
|
+
"bugsnag.com",
|
|
304
|
+
"rollbar.com",
|
|
305
|
+
"segment.io", "segment.com",
|
|
306
|
+
"mixpanel.com",
|
|
307
|
+
"amplitude.com",
|
|
308
|
+
"google-analytics.com", "analytics.google.com",
|
|
309
|
+
"datadoghq.com", "datadoghq.eu",
|
|
310
|
+
"newrelic.com", "nr-data.net",
|
|
311
|
+
"honeycomb.io",
|
|
312
|
+
"logtail.com",
|
|
313
|
+
]);
|
|
314
|
+
// Normalize a `host[:port]` literal to a bare lowercase hostname (the MODEL_HOSTS stripping), for the
|
|
315
|
+
// destination-class membership tests.
|
|
316
|
+
function normHost(hostLiteral) {
|
|
317
|
+
if (hostLiteral == null) return "";
|
|
318
|
+
let host = hostLiteral;
|
|
319
|
+
if (host.startsWith("[")) { const e = host.indexOf("]"); if (e >= 0) host = host.slice(1, e); }
|
|
320
|
+
else if ((host.match(/:/g) ?? []).length === 1) host = host.split(":")[0];
|
|
321
|
+
return host.toLowerCase();
|
|
322
|
+
}
|
|
323
|
+
// Subdomain-aware membership of `hostLiteral` in a host `set` (mirrors java Literals.hostInSet).
|
|
324
|
+
function hostInSet(hostLiteral, set) {
|
|
325
|
+
const host = normHost(hostLiteral);
|
|
326
|
+
if (set.has(host)) return true;
|
|
327
|
+
for (const e of set) if (host.endsWith("." + e)) return true;
|
|
328
|
+
return false;
|
|
329
|
+
}
|
|
330
|
+
export function isTelemetryHost(hostLiteral) { return hostInSet(hostLiteral, TELEMETRY_HOSTS); }
|
|
331
|
+
// ⟨0.20⟩ The `Net` DESTINATION CLASS of a host literal (NET-DESTINATION-CLASS-DESIGN.md): `known-telemetry`
|
|
332
|
+
// (curated), `known-partner` (config `net-partner` OR a model host — a declared-ish external API), else
|
|
333
|
+
// `unknown-host` — the HONEST default (candor makes no claim; the security gate bites this). `partners` is a
|
|
334
|
+
// per-project Set (config-declared). Never fabricated: a null/unresolved host is unknown-host. Mirrors java
|
|
335
|
+
// Literals.netDestClass.
|
|
336
|
+
export function netDestClass(hostLiteral, partners) {
|
|
337
|
+
if (isTelemetryHost(hostLiteral)) return "known-telemetry";
|
|
338
|
+
const host = normHost(hostLiteral);
|
|
339
|
+
const partnerMatch = partners && (partners.has(host) || [...partners].some((p) => host.endsWith("." + p)));
|
|
340
|
+
if (partnerMatch || isModelHost(hostLiteral)) return "known-partner";
|
|
341
|
+
return "unknown-host";
|
|
342
|
+
}
|
|
343
|
+
// ⟨0.20⟩ The closed `Net` destination-class vocabulary, for the `deny Net[<dest…>]` policy filter.
|
|
344
|
+
export const NET_DEST_CLASSES = ["known-telemetry", "known-partner", "unknown-host"];
|
|
345
|
+
// ⟨0.20⟩ The `Net` destination classes an fn reaches — the SINGLE derivation shared by the report's
|
|
346
|
+
// `netClass` field (scan.mjs) and the gate (policy.mjs), so they can never drift: an exact host-literal
|
|
347
|
+
// match (netDestClass) for the visible (transitive) hosts, plus the fail-closed `unknown-host` when the Net
|
|
348
|
+
// surface is masked (`netIncomplete`) OR carries no visible host (a runtime endpoint). `hostsArr` is an
|
|
349
|
+
// array; call only for an fn with Net. Returns sorted. Mirrors java Policy.netClassesOf / rust net_classes_of.
|
|
350
|
+
export function netClassesOf(hostsArr, netIncomplete, partners) {
|
|
351
|
+
const classes = new Set(hostsArr.map((h) => netDestClass(h, partners)));
|
|
352
|
+
if (netIncomplete || hostsArr.length === 0) classes.add("unknown-host");
|
|
353
|
+
return [...classes].sort();
|
|
354
|
+
}
|
|
296
355
|
// Table-position identifiers in a SQL string literal (SPEC §2 `tables`). Mirrors the Rust
|
|
297
356
|
// tables_in_sql exactly: must open with a statement keyword; FROM/JOIN/INTO anywhere,
|
|
298
357
|
// statement-leading UPDATE/TRUNCATE, TABLE (skipping ONLY/IF NOT EXISTS); a FOR UPDATE locking
|
package/scan.mjs
CHANGED
|
@@ -26,11 +26,11 @@ import fs from "node:fs";
|
|
|
26
26
|
import path from "node:path";
|
|
27
27
|
import { fileURLToPath } from "node:url";
|
|
28
28
|
import { createRequire } from "node:module";
|
|
29
|
-
import { parsePolicy, evaluatePolicy, scopeMatches, parseUnknownAliases, discoverConfigText, reasonClass } from "./policy.mjs";
|
|
29
|
+
import { parsePolicy, evaluatePolicy, scopeMatches, parseUnknownAliases, parseNetPartners, discoverConfigText, reasonClass } from "./policy.mjs";
|
|
30
30
|
import { unverifiedHoleRule, ruleUpgrade } from "./query-core.mjs";
|
|
31
31
|
import { printAgents } from "./contract.mjs";
|
|
32
32
|
import { isTestPath, kappa, kappaKnows, commandHeadEffects, hostLiteral, tablesInSql,
|
|
33
|
-
modelHostEffects, isModelHost, isModelSdkPackage } from "./scan-core.mjs";
|
|
33
|
+
modelHostEffects, isModelHost, isModelSdkPackage, netClassesOf } from "./scan-core.mjs";
|
|
34
34
|
import { emitSurface } from "./surface.mjs";
|
|
35
35
|
|
|
36
36
|
const ENGINE_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -41,7 +41,7 @@ const ENGINE_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
|
41
41
|
// literal stamped into the envelope's `spec` field, so the doc lines and the report can never drift.
|
|
42
42
|
// Reused, never re-littered.
|
|
43
43
|
const PKG_VERSION = JSON.parse(fs.readFileSync(path.join(ENGINE_DIR, "package.json"), "utf8")).version;
|
|
44
|
-
const SPEC_VERSION = "0.
|
|
44
|
+
const SPEC_VERSION = "0.20";
|
|
45
45
|
|
|
46
46
|
// --version: a print-and-exit MODE, handled before the main arg walk so it never depends on a target.
|
|
47
47
|
// Fully OFFLINE — candor never phones home. Staying current is the AGENT's job: read the installed
|
|
@@ -2563,6 +2563,9 @@ for (const m of ["hosts", "tables", "cmds", "paths", "blind", "incomplete"]) {
|
|
|
2563
2563
|
}
|
|
2564
2564
|
|
|
2565
2565
|
// ---- emit: the §2 envelope (effect-free items omitted) + the §2.2 sidecar (EVERY fn a key) --------
|
|
2566
|
+
// ⟨0.20⟩ Net destination-class partners from `.candor/config` — read ONCE here, used by the report's per-fn
|
|
2567
|
+
// `netClass` field (below) and the gate (deny Net[unknown-host]); the SAME set both surfaces resolve.
|
|
2568
|
+
const netPartners = parseNetPartners(discoverConfigText(target));
|
|
2566
2569
|
const functions = [];
|
|
2567
2570
|
for (const [name, rec] of fns) {
|
|
2568
2571
|
const inf = [...inferred.get(name)].sort();
|
|
@@ -2586,6 +2589,10 @@ for (const [name, rec] of fns) {
|
|
|
2586
2589
|
// entries carry `calls`); omitted when a fn has no outgoing edges to keep pure leaves lean.
|
|
2587
2590
|
if (rec.edges.size) entry.calls = [...rec.edges].sort();
|
|
2588
2591
|
if (inf.includes("Net") && rec.hosts.size) entry.hosts = [...rec.hosts].sort();
|
|
2592
|
+
// ⟨0.20⟩ Net destination-class (NET-DESTINATION-CLASS-DESIGN.md): the classes present in this fn's
|
|
2593
|
+
// transitive Net surface — exact host-literal match, fail-closed unknown-host on a masked surface (rec
|
|
2594
|
+
// .incomplete has Net) OR a Net with no visible host. The class travels the call graph like the effect.
|
|
2595
|
+
if (inf.includes("Net")) entry.netClass = netClassesOf([...rec.hosts], rec.incomplete.has("Net"), netPartners);
|
|
2589
2596
|
if (inf.includes("Db") && rec.tables.size) entry.tables = [...rec.tables].sort();
|
|
2590
2597
|
if (inf.includes("Exec") && rec.cmds.size) entry.cmds = [...rec.cmds].sort();
|
|
2591
2598
|
if (inf.includes("Fs") && rec.paths.size) entry.paths = [...rec.paths].sort();
|
|
@@ -2875,7 +2882,7 @@ if (policyPath !== null) {
|
|
|
2875
2882
|
for (const [name, rec] of fns) if (rec.incomplete.size) incompleteMap.set(name, rec.incomplete);
|
|
2876
2883
|
// ⟨0.19⟩ reason-class aliases (SPEC §6.2) from `.candor/config`, so `Unknown[<alias>]` resolves at the gate.
|
|
2877
2884
|
const unknownAliases = parseUnknownAliases(discoverConfigText(target));
|
|
2878
|
-
gateViolations = gateViolations.concat(evaluatePolicy(parsePolicy(text, unknownAliases), functions, cg, incompleteMap));
|
|
2885
|
+
gateViolations = gateViolations.concat(evaluatePolicy(parsePolicy(text, unknownAliases), functions, cg, incompleteMap, netPartners));
|
|
2879
2886
|
// Provable-purity DISCLOSURE (advisory — NEVER a violation, so the exit/verdict are untouched): functions
|
|
2880
2887
|
// in a pure/deny scope that PASS but are Unknown (the Unknown could hide the forbidden effect — a
|
|
2881
2888
|
// fn/closure-injected port). Surfaces the gap automatically (eval/fixloop/DISPATCH-NOTE.md).
|