candor-ts 0.28.2 → 0.29.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/contract.mjs +68 -7
- package/lsp.mjs +15 -3
- package/mcp.mjs +19 -3
- package/package.json +2 -2
- package/policy.mjs +156 -5
- package/query.mjs +47 -22
- package/scan.mjs +490 -37
- package/sensitivity.mjs +2 -2
- package/transitive-recall.mjs +2 -2
package/scan.mjs
CHANGED
|
@@ -27,11 +27,12 @@ import path from "node:path";
|
|
|
27
27
|
import { fileURLToPath } from "node:url";
|
|
28
28
|
import { createRequire } from "node:module";
|
|
29
29
|
import { execFileSync } from "node:child_process";
|
|
30
|
+
import os from "node:os";
|
|
30
31
|
import { parsePolicy, evaluatePolicy, scopeMatches, parseUnknownAliases, parseNetPartners, discoverConfigText,
|
|
31
32
|
reasonClass, discoverConfigPath, policyVocabularyAnchor, policyErrorText, policyRefusalUnevaluated, policyUnreadable, policyZeroRules, fatalPolicyErrors, refusalVerdict,
|
|
32
33
|
netClassResolver, resolveReasonClasses } from "./policy.mjs";
|
|
33
34
|
import { unverifiedHoleRule, ruleUpgrade, byCodePoint, claimsToHaveJudgedNothing, reportCorruptKeys, entryCorruptKeys } from "./query-core.mjs";
|
|
34
|
-
import { printAgents } from "./contract.mjs";
|
|
35
|
+
import { printAgents, writeStdoutSync } from "./contract.mjs";
|
|
35
36
|
import { isTestPath, kappa, kappaKnows, fsKind, commandHeadEffects, hostLiteral, tablesInSql,
|
|
36
37
|
modelHostEffects, isModelHost, isModelSdkPackage, netClassesOf } from "./scan-core.mjs";
|
|
37
38
|
import { emitSurface } from "./surface.mjs";
|
|
@@ -44,7 +45,45 @@ const ENGINE_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
|
44
45
|
// literal stamped into the envelope's `spec` field, so the doc lines and the report can never drift.
|
|
45
46
|
// Reused, never re-littered.
|
|
46
47
|
const PKG_VERSION = JSON.parse(fs.readFileSync(path.join(ENGINE_DIR, "package.json"), "utf8")).version;
|
|
47
|
-
const SPEC_VERSION = "0.
|
|
48
|
+
const SPEC_VERSION = "0.29";
|
|
49
|
+
|
|
50
|
+
// A TREE TOO DEEP TO WALK IS "COULD NOT EVALUATE" (exit 2), NEVER "FOUND A VIOLATION" (exit 1).
|
|
51
|
+
//
|
|
52
|
+
// The AST passes recurse (`visitCalls` → `ts.forEachChild` → `visitCalls`), so a deeply nested
|
|
53
|
+
// expression exhausts the JS stack and node dies with an uncaught RangeError — a raw stack trace on
|
|
54
|
+
// stderr and exit 1. Exit 1 is the code for A VIOLATION WAS FOUND. A wrapper that reads the exit code
|
|
55
|
+
// (every CI gate does) is told the gate ran and failed the tree, when in fact nothing was analyzed and
|
|
56
|
+
// no report was written. That is the wrong side of the fail-closed line: loud, but loudly WRONG.
|
|
57
|
+
//
|
|
58
|
+
// MEASURED, this machine, `export function f(): number { return (((…1…))); }`:
|
|
59
|
+
// depth 400 → pre-⟨0.29⟩ walked it, ⟨0.29⟩ overflows the rung grew `visitCalls`'s frame
|
|
60
|
+
// depth 800 → BOTH overflow so the crash long predates the rung
|
|
61
|
+
// The rung did not introduce this; it lowered the ceiling past the fuzzer's fixed 400-deep bait and
|
|
62
|
+
// made a standing defect visible. Shrinking the frame back would return the fuzzer to green and leave
|
|
63
|
+
// the wrong exit code live one nesting level further down — the fix that deletes the evidence.
|
|
64
|
+
//
|
|
65
|
+
// Narrow ON PURPOSE: only the stack-exhaustion RangeError is translated. Every other uncaught error
|
|
66
|
+
// keeps the behaviour it had (stack on stderr, exit 1), because widening the net here would convert
|
|
67
|
+
// unknown crashes into a tidy "could not evaluate" and hide the next real bug behind a clean message.
|
|
68
|
+
//
|
|
69
|
+
// RESIDUAL, stated because it is real and NOT measured: runaway recursion in THIS code exhausts the
|
|
70
|
+
// stack exactly as a deep tree does, so such a bug would now be reported as "nests deeper than the
|
|
71
|
+
// engine can walk", and nothing here can tell the two apart. It stays LOUD either way — exit 2, no
|
|
72
|
+
// report, never a green — so the cost is a misleading message, not a wrong verdict. If this message
|
|
73
|
+
// ever appears on a SHALLOW tree, the message is the bug and the recursion is where to look.
|
|
74
|
+
process.on("uncaughtException", (e) => {
|
|
75
|
+
if (e instanceof RangeError && /Maximum call stack/i.test(e?.message ?? "")) {
|
|
76
|
+
console.error("candor-ts: this tree nests deeper than the engine can walk — the AST passes recurse, "
|
|
77
|
+
+ "and the JS stack ran out before the scan finished. NOTHING was analyzed and no report was "
|
|
78
|
+
+ "written, so this is 'could not evaluate' (exit 2), not a clean tree and not a violation. "
|
|
79
|
+
+ "Re-run with a larger stack — `node --stack-size=4000 scan.mjs …` — or scan the offending "
|
|
80
|
+
+ "file on its own to find it.");
|
|
81
|
+
process.exit(2);
|
|
82
|
+
}
|
|
83
|
+
console.error(e?.stack ?? String(e));
|
|
84
|
+
process.exit(1);
|
|
85
|
+
});
|
|
86
|
+
|
|
48
87
|
/** The `deps` / `CANDOR_DEPS` separator set — ASCII whitespace plus `:` and `,`.
|
|
49
88
|
*
|
|
50
89
|
* ONE CONSTANT BECAUSE TWO SPELLINGS WERE A SILENT GREEN. The §3.3.1 sink-over-input guard and the
|
|
@@ -130,6 +169,15 @@ See https://github.com/tombaldwin/candor`);
|
|
|
130
169
|
// value-consuming skip handles, nor produce a "lying unknown flag" error for a real flag given first.
|
|
131
170
|
const usage = "usage: candor-ts <dir | file.ts | tsconfig.json> [--out <prefix>] [--json] [--policy <file>] [--gate-json <file>] [--allow-js] [--workspace] [--agents] [--version] [--help]";
|
|
132
171
|
const argv = process.argv.slice(2);
|
|
172
|
+
// ⟨0.29⟩ THE PEEK, CHILD SIDE. Set only by this file on itself (see the peek block near the report
|
|
173
|
+
// write). A peek is handed an EXPLICIT file list by its parent — the very files the parent excluded —
|
|
174
|
+
// so it must analyse exactly those and apply NONE of the selection rules that excluded them.
|
|
175
|
+
//
|
|
176
|
+
// MEASURED before this existed: the parent handed the child a `*.test.ts` that runs `execSync("ls")`,
|
|
177
|
+
// the child's own `isTestPath` filter dropped it from `fileNames`, and the scan published
|
|
178
|
+
// `excluded: [{class:"test-file", peeked:true}]` with `outOfScope: []` under `deny Exec` — the peek
|
|
179
|
+
// reporting that it read the file and found nothing, about a file it refused to open.
|
|
180
|
+
const IS_PEEK = argv.includes("--peek-excluded");
|
|
133
181
|
// Declared HERE, above the sink guard, because the guard calls `loadCandorConfig` and that reads
|
|
134
182
|
// these: left below, they were in the temporal dead zone, the call threw, and the `catch` around it
|
|
135
183
|
// swallowed the throw — so the config channel the guard exists to enumerate was silently empty and a
|
|
@@ -279,7 +327,7 @@ const distinctOutPrefixes = (all) => {
|
|
|
279
327
|
// candor-ts: 1 source file(s) failed to parse — NOT analyzed …
|
|
280
328
|
// candor-ts: wrote 0 effectful functions (1 analyzed, 1 files) to .candor/report.json exit 0
|
|
281
329
|
// $ cat src/main.ts
|
|
282
|
-
// { "spec": "0.28", "ok": false, … } ← the operator's SOURCE, unrecoverably replaced
|
|
330
|
+
// { "spec": "0.28", "ok": false, … } ← the operator's SOURCE, unrecoverably replaced (measured at spec 0.28, informative)
|
|
283
331
|
//
|
|
284
332
|
// Unrecoverable loss of the operator's own code, reported as SUCCESS — the run destroyed the file,
|
|
285
333
|
// then dutifully disclosed the parse failure it had itself caused.
|
|
@@ -900,6 +948,11 @@ for (let i = 0; i < argv.length; i++) {
|
|
|
900
948
|
// sibling's effects instead of reading pure (the candor-ts analog of rust `--deps`, SPEC §2).
|
|
901
949
|
else if (a === "--workspace" || a === "--deps") wantWorkspace = true;
|
|
902
950
|
else if (a === "--dep-inits") wantDepInits = true;
|
|
951
|
+
// ⟨0.29⟩ INTERNAL — set only by this file's own peek spawn (see IS_PEEK). Accepted here so the child
|
|
952
|
+
// does not take the unknown-flag path, which prints a refusal and, on the `--json` route, still emits a
|
|
953
|
+
// report: the parent parsed THAT as a successful peek of an empty file set. Deliberately absent from
|
|
954
|
+
// the usage string — it is not a way to scan a file list, it is the marker that this run IS a peek.
|
|
955
|
+
else if (a === "--peek-excluded") { /* consumed; behaviour lives in IS_PEEK */ }
|
|
903
956
|
else if (a === "--out" || a === "--policy" || a === "--gate-json") {
|
|
904
957
|
const v = argv[i + 1];
|
|
905
958
|
if (v === undefined || v.startsWith("--")) {
|
|
@@ -1260,7 +1313,8 @@ function fromTsconfig(cfgPath, baseDir) {
|
|
|
1260
1313
|
}
|
|
1261
1314
|
names = [...new Set(names)];
|
|
1262
1315
|
}
|
|
1263
|
-
|
|
1316
|
+
// ⟨0.29⟩ a peek analyses the list it was handed — see IS_PEEK.
|
|
1317
|
+
return IS_PEEK ? names : names.filter((f) => !isTestPath(path.relative(baseDir, f)));
|
|
1264
1318
|
}
|
|
1265
1319
|
const stat = fs.existsSync(target) ? fs.statSync(target) : null;
|
|
1266
1320
|
if (!stat) {
|
|
@@ -1292,7 +1346,7 @@ if (stat.isFile() && /tsconfig.*\.json$/.test(path.basename(target))) {
|
|
|
1292
1346
|
(function walk(d) {
|
|
1293
1347
|
for (const ent of fs.readdirSync(d, { withFileTypes: true })) {
|
|
1294
1348
|
const p = path.join(d, ent.name);
|
|
1295
|
-
if (isTestPath(path.relative(rootDir, p))) continue;
|
|
1349
|
+
if (!IS_PEEK && isTestPath(path.relative(rootDir, p))) continue; // ⟨0.29⟩ see IS_PEEK
|
|
1296
1350
|
if (ent.isDirectory()) walk(p);
|
|
1297
1351
|
else if (/\.[mc]?tsx?$/.test(ent.name) && !ent.name.endsWith(".d.ts")) fileNames.push(p);
|
|
1298
1352
|
else if (allowJs && /\.[mc]?jsx?$/.test(ent.name) && !/\.min\.js$/.test(ent.name)) fileNames.push(p);
|
|
@@ -1300,6 +1354,49 @@ if (stat.isFile() && /tsconfig.*\.json$/.test(path.basename(target))) {
|
|
|
1300
1354
|
})(rootDir);
|
|
1301
1355
|
}
|
|
1302
1356
|
}
|
|
1357
|
+
// ── ⟨0.29⟩ THE SCOPE: what this run chose not to open ──────────────────────────────────────────────
|
|
1358
|
+
// `analyzed.count` is a numerator; the selection above produced it and appeared nowhere, so a consumer
|
|
1359
|
+
// could not tell whether the answer was to the question they asked. Every exclusion here is deliberate —
|
|
1360
|
+
// `isTestPath`, `.d.ts`, the tsconfig's own `include` — and being deliberate is precisely why none was
|
|
1361
|
+
// measured: a limitation written as a comment reads as considered.
|
|
1362
|
+
//
|
|
1363
|
+
// Walked ONCE from the same root, skipping the directories nobody means. CLASSES WITH COUNTS, never file
|
|
1364
|
+
// lists: an excluded set that can contain node_modules is unbounded, and a gate that prints thousands of
|
|
1365
|
+
// paths is one people scroll past.
|
|
1366
|
+
const excludedFiles = [];
|
|
1367
|
+
{
|
|
1368
|
+
const inSet = new Set(fileNames.map((f) => path.resolve(f)));
|
|
1369
|
+
const SKIP_DIR = new Set(["node_modules", ".git", "dist", "build", "out", "coverage", ".next"]);
|
|
1370
|
+
let rootIsDir = false;
|
|
1371
|
+
try { rootIsDir = fs.statSync(rootDir).isDirectory(); } catch { /* not walkable */ }
|
|
1372
|
+
if (rootIsDir) {
|
|
1373
|
+
(function walkAll(d) {
|
|
1374
|
+
let ents; try { ents = fs.readdirSync(d, { withFileTypes: true }); } catch { return; }
|
|
1375
|
+
for (const ent of ents) {
|
|
1376
|
+
const q = path.join(d, ent.name);
|
|
1377
|
+
if (ent.isDirectory()) { if (!SKIP_DIR.has(ent.name) && !ent.name.startsWith(".")) walkAll(q); continue; }
|
|
1378
|
+
if (!PARSED_SOURCE_EXT.test(ent.name)) continue; // another language is not this engine's claim
|
|
1379
|
+
if (inSet.has(path.resolve(q))) continue; // analyzed, therefore not excluded
|
|
1380
|
+
const rel = path.relative(rootDir, q);
|
|
1381
|
+
excludedFiles.push({
|
|
1382
|
+
path: rel,
|
|
1383
|
+
cls: ent.name.endsWith(".d.ts") ? "declaration-only"
|
|
1384
|
+
: isTestPath(rel) ? "test-file"
|
|
1385
|
+
: usedTsconfig ? "outside-the-tsconfig-program"
|
|
1386
|
+
: "not-a-parsed-source",
|
|
1387
|
+
});
|
|
1388
|
+
}
|
|
1389
|
+
})(rootDir);
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
const EXCLUDED_REASON = {
|
|
1393
|
+
"declaration-only": "a `.d.ts` declares types and carries no body, so there is nothing to judge in it",
|
|
1394
|
+
"test-file": "a test file describes what the HARNESS does, not what the project does",
|
|
1395
|
+
"outside-the-tsconfig-program":
|
|
1396
|
+
"the tsconfig's `include`/`exclude` did not name it, so it is not part of the program this scan "
|
|
1397
|
+
+ "analyzed — a file your build excludes may still run in CI or at install time",
|
|
1398
|
+
"not-a-parsed-source": "not in this run's parse set",
|
|
1399
|
+
};
|
|
1303
1400
|
if (fileNames.length === 0) {
|
|
1304
1401
|
console.error(`candor-ts: no TypeScript sources under ${target}`);
|
|
1305
1402
|
// An empty scan is an exit-2 cause like any other: a consumer reading the stream after it must not
|
|
@@ -1626,7 +1723,18 @@ if (wantDepInits) {
|
|
|
1626
1723
|
// banner's) that drifted from this would make the engine distrust its OWN reports at the §2.1
|
|
1627
1724
|
// staleness check (`d.candor?.version !== ENGINE_VERSION`), silently downgrading every chained dep.
|
|
1628
1725
|
const ENGINE_VERSION = `candor-ts-${PKG_VERSION}`;
|
|
1629
|
-
const crossDeps = new Map(); // hash -> {inferred:Set, hosts:[], cmds:[], paths:[], tables:[]}
|
|
1726
|
+
const crossDeps = new Map(); // hash -> {inferred:Set, hosts:[], cmds:[], paths:[], tables:[], incomplete:Set}
|
|
1727
|
+
// ⟨0.29⟩ WAS A DEPENDENCY REPORT CHAINED INTO THIS RUN? Read by the gate to disclose the bound on the
|
|
1728
|
+
// NAME-matching rule kinds. Keyed on the report being LOADED, not on a hit being applied: a dependency
|
|
1729
|
+
// whose reached function is PURE produces no hit at all, and that is precisely the fixture that caught
|
|
1730
|
+
// this gap — the operator chained a dep either way, so the name rules are blind to those crossings either
|
|
1731
|
+
// way.
|
|
1732
|
+
let depReportsRead = 0;
|
|
1733
|
+
// ⟨0.29⟩ Keyed on a report being READ, not on entries being ingested and not on a hit being applied: a
|
|
1734
|
+
// dependency whose reached function is PURE yields a report with NO entries and produces no hit, which is
|
|
1735
|
+
// exactly the fixture that caught this gap twice while I narrowed the predicate. The operator chained a
|
|
1736
|
+
// dep either way, so the name-matching rules are blind to those crossings either way.
|
|
1737
|
+
const depChained = () => depReportsRead > 0;
|
|
1630
1738
|
// Packages a loaded sibling report COVERS — exempt from the κ ledger even when a call joins no
|
|
1631
1739
|
// entry (reports omit pure functions: the silence is the purity claim, SPEC §2 rule 3 — the
|
|
1632
1740
|
// serde_json rule the Rust/JVM engines already carry; /code-review found TS missing it). Fed from
|
|
@@ -1908,6 +2016,7 @@ const corruptDepPkgs = new Set();
|
|
|
1908
2016
|
// coverage the first withheld.
|
|
1909
2017
|
const covers = stale ? staleDepPkgs : incomplete ? incompleteDepPkgs : corrupt ? corruptDepPkgs
|
|
1910
2018
|
: judgedNothing ? unjudgedDepPkgs : depCoveredPkgs; // an untrusted, self-declared-incomplete, corrupt or unjudged report grants no coverage
|
|
2019
|
+
depReportsRead += 1; // ⟨0.29⟩ see `depChained` — a dep was chained, whatever it contained
|
|
1911
2020
|
if (corrupt) console.error(`candor-ts: chained dependency report ${f} has ${corruptKeys.length} present-but-unparseable §2 key(s)`
|
|
1912
2021
|
+ ` — granted NO coverage, so calls into it read as INVISIBLE rather than pure (SPEC §2 ⟨0.24⟩): ${corruptKeys.join("; ")}`);
|
|
1913
2022
|
if (typeof d.package === "string" && d.package) covers.add(d.package);
|
|
@@ -1934,7 +2043,7 @@ const corruptDepPkgs = new Set();
|
|
|
1934
2043
|
if (!stale && entryCorruptKeys(e).length) continue;
|
|
1935
2044
|
const hashPkg = e.hash.split("#")[0];
|
|
1936
2045
|
if (hashPkg) covers.add(hashPkg);
|
|
1937
|
-
const cell = crossDeps.get(e.hash) ?? { inferred: new Set(), invisible: new Set(), why: new Set(), hosts: [], cmds: [], paths: [], tables: [], netIncomplete: false };
|
|
2046
|
+
const cell = crossDeps.get(e.hash) ?? { inferred: new Set(), invisible: new Set(), why: new Set(), hosts: [], cmds: [], paths: [], tables: [], incomplete: new Set(), netIncomplete: false };
|
|
1938
2047
|
for (const x of stale ? ["Unknown"] : strs(e.inferred)) cell.inferred.add(x);
|
|
1939
2048
|
// ⟨0.19⟩ THE REASON CLASS TRAVELS WITH THE UNKNOWN. Without this the join copied `inferred` and
|
|
1940
2049
|
// `invisible` only, so a dependency's `Unknown[reflect:eval]` arrived at the consumer as a bare
|
|
@@ -1976,6 +2085,11 @@ const corruptDepPkgs = new Set();
|
|
|
1976
2085
|
if (!stale) for (const b of strs(e.invisible)) cell.invisible.add(b);
|
|
1977
2086
|
if (!stale) for (const m of ["hosts", "cmds", "paths", "tables"])
|
|
1978
2087
|
for (const v of strs(e[m])) if (!cell[m].includes(v)) cell[m].push(v);
|
|
2088
|
+
// ⟨0.29⟩ …AND THE INCOMPLETENESS, which §2's chained-join clause names in the same breath as the
|
|
2089
|
+
// four surfaces above: "a join that carries the effect and drops `incomplete` lets a benign
|
|
2090
|
+
// literal in the consumer certify what the dependency declared uncertifiable". That is not a
|
|
2091
|
+
// hypothetical — it is the measured defect this field was added to close.
|
|
2092
|
+
if (!stale) for (const v of strs(e.incomplete)) cell.incomplete.add(v);
|
|
1979
2093
|
// ⟨0.20⟩ THE NET SURFACE'S INCOMPLETENESS TRAVELS WITH ITS HOSTS. `hosts` is a LOWER bound — the
|
|
1980
2094
|
// producer marks a masked/hostless Net internally (`rec.incomplete`) and publishes that judgment as
|
|
1981
2095
|
// `unknown-host` in `netClass`. The join copied the host LITERALS and not the judgment, so the
|
|
@@ -1986,8 +2100,15 @@ const corruptDepPkgs = new Set();
|
|
|
1986
2100
|
// lines up, one field over: a fail-CLOSED marker failing OPEN at the boundary. No format rung: the
|
|
1987
2101
|
// dependency already published the answer under the hash the consumer joins.
|
|
1988
2102
|
//
|
|
1989
|
-
// Read off `netClass` rather than
|
|
1990
|
-
//
|
|
2103
|
+
// Read off `netClass` rather than the incompleteness field because `unknown-host` IS Net's wire
|
|
2104
|
+
// form of it, and re-deriving one from the other would give the consumer two sources for one fact.
|
|
2105
|
+
//
|
|
2106
|
+
// ⟨0.29⟩ THE PARENTHETICAL THIS COMMENT USED TO CARRY WAS FALSE, and it cost a false all-clear.
|
|
2107
|
+
// It said `rec.incomplete` is "deliberately INTERNAL family-wide (java/rust keep it out of the
|
|
2108
|
+
// report too)". candor-rust has emitted a per-entry `incomplete` all along — SPEC §2 says so
|
|
2109
|
+
// explicitly, in the clause beside the one this engine was following — so the premise for keeping
|
|
2110
|
+
// it internal here was a statement about the family that was never true. The field is emitted and
|
|
2111
|
+
// joined now; see the `entry.incomplete` site and the join two lines below.
|
|
1991
2112
|
// Carries ONLY the incompleteness, never the classes: `known-telemetry`/`known-partner` are facts
|
|
1992
2113
|
// about the hosts already copied above, and re-deriving them from those literals is what keeps the
|
|
1993
2114
|
// consumer's `netClass` a function of the surface it can see (the property `netClassesOf` exists to
|
|
@@ -2214,13 +2335,17 @@ function packageManifestEffects(file) {
|
|
|
2214
2335
|
}
|
|
2215
2336
|
|
|
2216
2337
|
// ---- the literal surfaces (SPEC §2 hosts/cmds/paths/tables): the statically-decidable subset ------
|
|
2217
|
-
// Read ONLY from string literals at a classified call — informative, never complete, never inferred
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2338
|
+
// Read ONLY from string literals at a classified call — informative, never complete, never inferred,
|
|
2339
|
+
// and ALWAYS from the argument POSITION that names the locator.
|
|
2340
|
+
//
|
|
2341
|
+
// ⟨0.29⟩ `firstStringLiteral` — "the first string literal anywhere in the args" — is DELETED. It was the
|
|
2342
|
+
// reader for every locator surface except the `Exec` head, and it produced the same defect in each one it
|
|
2343
|
+
// touched: `write(userPath, "/tmp/lit")` published the bytes as the path, `send(msg, …, addr)` published
|
|
2344
|
+
// the payload as the host, `query(userSql, "SELECT * FROM audit_log")` published a parameter as the
|
|
2345
|
+
// table — each a fabricated locator that ALSO suppressed the masking guard, so a benign-looking literal
|
|
2346
|
+
// certified a destination nobody could see. Removed rather than left unused: a "first literal anywhere"
|
|
2347
|
+
// in scope is how this class comes back at the next call site somebody adds. Use
|
|
2348
|
+
// `programHeadLiteral` / `urlArgLiteral` / `fsPathLiteral` / argument 0 for SQL.
|
|
2224
2349
|
|
|
2225
2350
|
// Is this property/element access a SETTER target reached through a destructuring assignment —
|
|
2226
2351
|
// `({ k: x.prop } = src)` or `[x.prop] = arr` (sweep [32])? Walk up through PropertyAssignment /
|
|
@@ -2242,13 +2367,13 @@ function isDestructuringAssignTarget(node) {
|
|
|
2242
2367
|
}
|
|
2243
2368
|
|
|
2244
2369
|
// The literal PROGRAM head a subprocess call NAMES — argv[0] specifically, never a later argument.
|
|
2245
|
-
// Unlike
|
|
2370
|
+
// Unlike a "first literal ANYWHERE in the args" reader, this refuses to refine when
|
|
2246
2371
|
// the program (arg0) is a runtime value but a trailing arg is a literal whose basename hits the head
|
|
2247
2372
|
// table: `spawn(toolVar, "curl")` must NOT fabricate Net — the literal is an argument, not the
|
|
2248
2373
|
// program (spec §4 ⟨0.5⟩: the head is argv[0]). Mirrors candor-java programHeadLiteral and the Rust
|
|
2249
2374
|
// is_cmd_naming_method gate. Returns null when arg0 is not a static string literal — the safe
|
|
2250
|
-
// direction.
|
|
2251
|
-
//
|
|
2375
|
+
// direction. ⟨0.29⟩ the `cmds` SURFACE reads this too: it was documented as cosmetic, but `cmds` is what
|
|
2376
|
+
// `allow Exec <cmd>` gates on (AS-EFF-008).
|
|
2252
2377
|
function programHeadLiteral(node) {
|
|
2253
2378
|
const a0 = (node.arguments ?? [])[0];
|
|
2254
2379
|
return a0 && ts.isStringLiteralLike(a0) ? a0.text : null;
|
|
@@ -2262,6 +2387,54 @@ function programHeadLiteral(node) {
|
|
|
2262
2387
|
// options in the other overloads) — so those two members read arg0-or-arg1. Only STRING-LITERAL positions
|
|
2263
2388
|
// are considered; returns null when the URL slot is not a static string literal — the safe direction.
|
|
2264
2389
|
const NET_URL_ARG1_MEMBERS = new Set(["connect", "createConnection"]);
|
|
2390
|
+
// ⟨0.29⟩ dgram's `send` puts the DESTINATION ADDRESS at position 2 or 4, and position 0 is the MESSAGE.
|
|
2391
|
+
// Falling through to `litAt(0)` read the payload as the endpoint: MEASURED,
|
|
2392
|
+
// `sock.send("telemetry.example", 0, 17, 53, dst)` published `hosts: ["telemetry.example"]` with NO
|
|
2393
|
+
// `incomplete`, so `allow Net telemetry.example` answered `policy ✓` at exit 0 over a UDP send to a
|
|
2394
|
+
// runtime-controlled address. A fabricated destination masking a real one — the `Fs` content-literal
|
|
2395
|
+
// defect of this same rung, one effect over, in the one API whose locator is neither first nor second.
|
|
2396
|
+
//
|
|
2397
|
+
// Node documents exactly two overloads:
|
|
2398
|
+
// send(msg, port, address[, cb]) → address at 2, a numeric port at 1
|
|
2399
|
+
// send(msg, offset, length, port, address[, cb]) → address at 4, a numeric port at 3
|
|
2400
|
+
// so the position is recovered from the NUMERIC PORT that must precede it rather than guessed from the
|
|
2401
|
+
// argument count (a `cb` or an options object shifts nothing, and anything that does not match both
|
|
2402
|
+
// shapes returns null — the destination is then not statically visible and the caller fails closed).
|
|
2403
|
+
const isNumericLit = (a) => a && (ts.isNumericLiteral(a)
|
|
2404
|
+
|| (ts.isPrefixUnaryExpression(a) && ts.isNumericLiteral(a.operand)));
|
|
2405
|
+
function dgramSendAddressIndex(args) {
|
|
2406
|
+
if (isNumericLit(args[3])) return 4;
|
|
2407
|
+
if (isNumericLit(args[1])) return 2;
|
|
2408
|
+
return -1;
|
|
2409
|
+
}
|
|
2410
|
+
// ⟨0.29⟩ THE Fs PATH LITERAL, read from the PATH ARGUMENT POSITION — the third application of the
|
|
2411
|
+
// `programHeadLiteral` discipline, and the one that never got it. The comment above says that rule was
|
|
2412
|
+
// "generalized from Exec to Net"; it stopped there, and `Fs` went on reading the first literal ANYWHERE
|
|
2413
|
+
// in the call. MEASURED: `fs.writeFileSync(userPath, "/tmp/lit")` published `paths: ["/tmp/lit"]` — the
|
|
2414
|
+
// BYTES BEING WRITTEN — so `allow Fs /tmp/lit` certified a write to a runtime-controlled destination at
|
|
2415
|
+
// exit 0, while candor-java and candor-swift failed closed on identical code.
|
|
2416
|
+
//
|
|
2417
|
+
// Returns { lit, complete }: `lit` is the path literal at position 0 when there is one, and `complete` is
|
|
2418
|
+
// false when ANY path position is not a literal. Two-path operations are why that is not one boolean:
|
|
2419
|
+
// `fs.copyFile("/safe", userPath)` has a literal at position 0 and still writes somewhere nobody can see.
|
|
2420
|
+
const FS_TWO_PATH_MEMBERS = new Set([
|
|
2421
|
+
"copyFile", "copyFileSync", "cp", "cpSync", "rename", "renameSync",
|
|
2422
|
+
"link", "linkSync", "symlink", "symlinkSync",
|
|
2423
|
+
]);
|
|
2424
|
+
function fsPathLiteral(node, member) {
|
|
2425
|
+
const args = node.arguments ?? [];
|
|
2426
|
+
const at = (i) => (args[i] && ts.isStringLiteralLike(args[i]) ? args[i].text : null);
|
|
2427
|
+
const a0 = at(0);
|
|
2428
|
+
const needsTwo = FS_TWO_PATH_MEMBERS.has(member);
|
|
2429
|
+
const a1 = needsTwo ? at(1) : null;
|
|
2430
|
+
const complete = a0 !== null && (!needsTwo || a1 !== null);
|
|
2431
|
+
// EVERY literal path position, not just the first. Publishing position 0 and calling a
|
|
2432
|
+
// both-literal two-path call COMPLETE certified the position it never published:
|
|
2433
|
+
// `copyFileSync("/tmp/lit", "/tmp/dst")` under `allow Fs /tmp/lit` answered `policy ✓` at exit 0
|
|
2434
|
+
// while writing `/tmp/dst`. candor-java and candor-swift publish both. Found by generating a case
|
|
2435
|
+
// per node `fs` export and comparing against the four engines, not by re-reading this function.
|
|
2436
|
+
return { lits: [a0, a1].filter((x) => x !== null), complete };
|
|
2437
|
+
}
|
|
2265
2438
|
// CONST-STRING PROPAGATION (java constant-inlining parity): resolve a bare identifier that references a
|
|
2266
2439
|
// `const NAME = "literal"` string to its literal value, and ONLY then. Returns the string, or null. The
|
|
2267
2440
|
// soundness rule is strict: resolve ONLY when EVERY value-declaration of the symbol is an immutable
|
|
@@ -2368,7 +2541,7 @@ function literalHeadHostUrl(expr) {
|
|
|
2368
2541
|
}
|
|
2369
2542
|
return null;
|
|
2370
2543
|
}
|
|
2371
|
-
function urlArgLiteral(node, member) {
|
|
2544
|
+
function urlArgLiteral(node, member, mod) {
|
|
2372
2545
|
const args = node.arguments ?? [];
|
|
2373
2546
|
const litAt = (i) => {
|
|
2374
2547
|
const a = args[i];
|
|
@@ -2379,6 +2552,12 @@ function urlArgLiteral(node, member) {
|
|
|
2379
2552
|
return resolveConstUrlString(a) ?? literalHeadHostUrl(a);
|
|
2380
2553
|
};
|
|
2381
2554
|
if (member && NET_URL_ARG1_MEMBERS.has(member)) return litAt(0) ?? litAt(1); // (port, host) or (path)
|
|
2555
|
+
// ⟨0.29⟩ dgram `send` — keyed on the MODULE, because a bare `send` elsewhere legitimately takes its
|
|
2556
|
+
// URL first and reading position 2/4 there would invent a host out of an unrelated argument.
|
|
2557
|
+
if (member === "send" && /^(node:)?dgram$/.test(mod ?? "")) {
|
|
2558
|
+
const i = dgramSendAddressIndex(args);
|
|
2559
|
+
return i < 0 ? null : litAt(i);
|
|
2560
|
+
}
|
|
2382
2561
|
return litAt(0);
|
|
2383
2562
|
}
|
|
2384
2563
|
// Is arg0 a RUNTIME STRING expression whose host can't be known statically — a template, a string
|
|
@@ -3315,6 +3494,7 @@ function applyDepHit(rec, hit) {
|
|
|
3315
3494
|
// layer down. Additive: it can only ADD `unknown-host`, never remove a class, so it never turns a firing
|
|
3316
3495
|
// gate green.
|
|
3317
3496
|
if (hit.netIncomplete) rec.incomplete.add("Net");
|
|
3497
|
+
for (const eff of hit.incomplete ?? []) rec.incomplete.add(eff); // ⟨0.29⟩ see the sibling join site
|
|
3318
3498
|
}
|
|
3319
3499
|
function unanswerableKey(decl) {
|
|
3320
3500
|
if (!decl) return null;
|
|
@@ -4416,14 +4596,48 @@ function visitCalls(node) {
|
|
|
4416
4596
|
// key on, so they read SILENT-PURE. `XMLHttpRequest.send`/`.open` issue the HTTP request; the
|
|
4417
4597
|
// `EventSource`/`WebSocket` constructors open a connection on construction. Net. (Found by a
|
|
4418
4598
|
// Net-deep sweep. The npm `ws` package is already κ-covered; this is the bare browser global.)
|
|
4419
|
-
|
|
4599
|
+
// ⟨0.29⟩ …AND THE HOST SURFACE, which these branches did not touch. Adding `Net` here bypasses
|
|
4600
|
+
// the `eff === "Net"` block that captures the endpoint and — when it is a runtime value — marks
|
|
4601
|
+
// the surface `incomplete`. So an invisible endpoint reached this way was recorded as Net with
|
|
4602
|
+
// NO hedge, and any benign literal in the same function certified it. MEASURED, `policy ✓` at
|
|
4603
|
+
// exit 0 under `allow Net ok.example`:
|
|
4604
|
+
// await fetch("https://ok.example/a"); // captures ok.example
|
|
4605
|
+
// new WebSocket(u); // Net, no host, no `incomplete` ← goes anywhere
|
|
4606
|
+
// That is the masking evasion the `incomplete` machinery exists to prevent, reached through the
|
|
4607
|
+
// two Net APIs whose branches skip it. Same shape as the EventKit/`privacyKind` and
|
|
4608
|
+
// `FS_USE_VERBS` gaps: a refinement the general path performs and a carve-out does not.
|
|
4609
|
+
//
|
|
4610
|
+
// POSITION, per the DOM signatures: `xhr.open(method, url)` puts the URL at argument 1 — the
|
|
4611
|
+
// one API in this file whose locator is neither 0 nor a labelled argument. `send` carries NO
|
|
4612
|
+
// url (the endpoint was fixed at `open`), so it is a USE-VERB: it must not mark `incomplete`,
|
|
4613
|
+
// or every XHR with a perfectly visible `open` literal would fail closed.
|
|
4614
|
+
if (parent === "XMLHttpRequest" && (name === "send" || name === "open")) {
|
|
4615
|
+
rec.direct.add("Net");
|
|
4616
|
+
if (name === "open") {
|
|
4617
|
+
const u = (node.arguments ?? [])[1];
|
|
4618
|
+
const lit = u && ts.isStringLiteralLike(u)
|
|
4619
|
+
? u.text : (u ? (resolveConstUrlString(u) ?? literalHeadHostUrl(u)) : null);
|
|
4620
|
+
const h = lit ? hostLiteral(lit) : null;
|
|
4621
|
+
if (h) { rec.hosts.add(h); for (const e of modelHostEffects(h)) rec.direct.add(e); }
|
|
4622
|
+
else rec.incomplete.add("Net");
|
|
4623
|
+
}
|
|
4624
|
+
}
|
|
4420
4625
|
// `new EventSource(url)` / `new WebSocket(url)`: the constructor is declared on an anonymous
|
|
4421
4626
|
// `declare var` object type (symbol `__type`, no usable parent name), but reaching the es-lib
|
|
4422
4627
|
// branch already proves the ctor resolved to lib.dom (not a project class shadowing the name),
|
|
4423
4628
|
// so the constructed identifier is the real browser global.
|
|
4424
4629
|
if (ts.isNewExpression(node)) {
|
|
4425
4630
|
const ctorName = node.expression.getText();
|
|
4426
|
-
if (ctorName === "EventSource" || ctorName === "WebSocket")
|
|
4631
|
+
if (ctorName === "EventSource" || ctorName === "WebSocket") {
|
|
4632
|
+
rec.direct.add("Net");
|
|
4633
|
+
// The URL is argument 0 of both constructors — see the XHR note above for the measurement.
|
|
4634
|
+
const u = (node.arguments ?? [])[0];
|
|
4635
|
+
const lit = u && ts.isStringLiteralLike(u)
|
|
4636
|
+
? u.text : (u ? (resolveConstUrlString(u) ?? literalHeadHostUrl(u)) : null);
|
|
4637
|
+
const h = lit ? hostLiteral(lit) : null;
|
|
4638
|
+
if (h) { rec.hosts.add(h); for (const e of modelHostEffects(h)) rec.direct.add(e); }
|
|
4639
|
+
else rec.incomplete.add("Net");
|
|
4640
|
+
}
|
|
4427
4641
|
}
|
|
4428
4642
|
} else {
|
|
4429
4643
|
// The member token κ matches: the resolved declaration's name, EXCEPT a `new X()` call,
|
|
@@ -4450,9 +4664,23 @@ function visitCalls(node) {
|
|
|
4450
4664
|
// allowlist discipline, generalized from Net to all 4 effects; sweep [11]). Fs: the fd/FileHandle
|
|
4451
4665
|
// ops (fd came from open()); the path-taking fs.* fns are establishing. Exec: ChildProcess methods
|
|
4452
4666
|
// (the command was fixed at spawn); the spawn fns are establishing.
|
|
4667
|
+
// The node `fs` verbs whose FIRST argument is a DESCRIPTOR, not a path — the fd came from a
|
|
4668
|
+
// prior `open()` whose path this analysis already saw, so their invisible destination is not a
|
|
4669
|
+
// gap and marking them `incomplete` charges every buffered write in a real tree.
|
|
4670
|
+
//
|
|
4671
|
+
// ⟨0.29⟩ `readv`/`writev` (+Sync) were MISSING, and the ⟨0.29⟩ positional-literal fix is what
|
|
4672
|
+
// made it visible: before it, `writev(fd, "/tmp/lit")` had its literal found ANYWHERE in the
|
|
4673
|
+
// call and published as a path — a fabrication — so the set was never consulted for these four.
|
|
4674
|
+
// Killing the fabrication moved them into the other wrong bucket. Found by generating a case
|
|
4675
|
+
// per node `fs` export rather than reasoning about the list (24 fd verbs in node, 20 here).
|
|
4676
|
+
//
|
|
4677
|
+
// Forgetting a member here OVER-charges (safe); adding a path-taking verb by mistake
|
|
4678
|
+
// UNDER-reports. An allowlist is the right shape for exactly that reason — the inverse of
|
|
4679
|
+
// the denylist rule that governs the classifier surface.
|
|
4453
4680
|
const FS_USE_VERBS = new Set(["write", "writeSync", "read", "readSync", "close", "closeSync",
|
|
4454
4681
|
"fsync", "fsyncSync", "fdatasync", "fdatasyncSync", "ftruncate", "ftruncateSync", "fchmod",
|
|
4455
|
-
"fchmodSync", "fchown", "fchownSync", "futimes", "futimesSync", "fstat", "fstatSync"
|
|
4682
|
+
"fchmodSync", "fchown", "fchownSync", "futimes", "futimesSync", "fstat", "fstatSync",
|
|
4683
|
+
"readv", "readvSync", "writev", "writevSync"]);
|
|
4456
4684
|
const EXEC_USE_VERBS = new Set(["kill", "send", "disconnect", "ref", "unref"]);
|
|
4457
4685
|
const netEstablishing = (member) =>
|
|
4458
4686
|
CONNECTING_CTORS.has(ctorClassName) || NET_ESTABLISHING.has(member)
|
|
@@ -4516,7 +4744,7 @@ function visitCalls(node) {
|
|
|
4516
4744
|
// fetch/axios/the HTTP verbs), NEVER the first literal anywhere in the args: a trailing literal
|
|
4517
4745
|
// in headers/body/options must not be read as the host (FINDING 6). Ollama's model decision runs
|
|
4518
4746
|
// through the parsed host too, never a raw string that merely contains ":11434" (FINDING 1/9).
|
|
4519
|
-
const urlLit = urlArgLiteral(node, member);
|
|
4747
|
+
const urlLit = urlArgLiteral(node, member, mod);
|
|
4520
4748
|
const ollama = ollamaFromUrlArg(urlLit);
|
|
4521
4749
|
if (ollama === "capture-model" || ollama === "capture-plain") {
|
|
4522
4750
|
const h = hostLiteral(urlLit);
|
|
@@ -4544,7 +4772,16 @@ function visitCalls(node) {
|
|
|
4544
4772
|
// parity #1) — any call into these single-purpose clients is a model dispatch. Additive.
|
|
4545
4773
|
if (isModelSdkPackage(mod)) rec.direct.add("Llm");
|
|
4546
4774
|
if (eff === "Db") {
|
|
4547
|
-
|
|
4775
|
+
// ⟨0.29⟩ THE SQL SLOT, never the first literal anywhere. Every string-SQL client puts the
|
|
4776
|
+
// query at argument 0 (`query(text, values)`, `execute(sql, params)`, `raw(sql, bindings)`,
|
|
4777
|
+
// `prepare(sql)`), so a literal in a LATER position is a parameter, a fallback query, a
|
|
4778
|
+
// health-check string — data, not the statement being run. MEASURED:
|
|
4779
|
+
// `db.query(userSql, "SELECT * FROM audit_log")` published `tables: ["audit_log"]` and, because
|
|
4780
|
+
// a table HAD been captured, the masking guard below never fired — so `allow Db audit_log`
|
|
4781
|
+
// certified a query whose SQL is a runtime value. The `Fs` and `Net` defects of this rung, in
|
|
4782
|
+
// the fourth and last locator surface.
|
|
4783
|
+
const a0 = (node.arguments ?? [])[0];
|
|
4784
|
+
const lit = a0 && ts.isStringLiteralLike(a0) ? a0.text : null;
|
|
4548
4785
|
const before = rec.tables.size;
|
|
4549
4786
|
for (const t of lit ? tablesInSql(lit) : []) rec.tables.add(t);
|
|
4550
4787
|
// ORM route: `this.userRepository.find(…)` — the receiver's `Repository<UserEntity>`
|
|
@@ -4564,12 +4801,27 @@ function visitCalls(node) {
|
|
|
4564
4801
|
if (rec.tables.size === before && member !== "new") rec.incomplete.add("Db");
|
|
4565
4802
|
}
|
|
4566
4803
|
if (eff === "Exec") {
|
|
4567
|
-
|
|
4568
|
-
|
|
4804
|
+
// ⟨0.29⟩ `cmds` reads argv[0] too. It was documented as "the cosmetic cmds surface (any
|
|
4805
|
+
// literal)", but `cmds` is precisely what `allow Exec <cmd>` gates on (AS-EFF-008) — nothing
|
|
4806
|
+
// cosmetic about it. No node API places a bare string after the head (args are an array,
|
|
4807
|
+
// options an object), so this changes no measured behaviour today; it removes the hazard for
|
|
4808
|
+
// the next exec-like wrapper whose second argument is a string, which is how the identical
|
|
4809
|
+
// defect reached `Fs`, `Net` and `Db` in this same rung.
|
|
4810
|
+
// ⟨0.29⟩ A USE-VERB NAMES NO PROGRAM, and its argument 0 is not a head. `EXEC_USE_VERBS`
|
|
4811
|
+
// already says so — it is consulted for the `incomplete` branch below and was NOT consulted
|
|
4812
|
+
// here, so `child.send(msg)` (IPC to an ALREADY-spawned child) read its MESSAGE as argv[0].
|
|
4813
|
+
// MEASURED: `ch.send("ls")` published `cmds: ["ls"]` and certified under `allow Exec ls`
|
|
4814
|
+
// though the function executes nothing, and `ch.send("curl")` FABRICATED `Net` — a function
|
|
4815
|
+
// that makes no network call reported as performing one, which `deny Net` then fires on.
|
|
4816
|
+
// That is exactly what the doc comment above forbids ("`spawn(toolVar, "curl")` must NOT
|
|
4817
|
+
// fabricate Net — the literal is an argument, not the program"); the rule was written for the
|
|
4818
|
+
// argument POSITION and never covered the same hazard reached through the RECEIVER.
|
|
4819
|
+
const lit = EXEC_USE_VERBS.has(member) ? null : programHeadLiteral(node);
|
|
4820
|
+
if (lit) rec.cmds.add(lit.trim().split(/\s+/)[0]);
|
|
4569
4821
|
// a known literal head refines the cliff (curl→Net, candor→Fs/Env); Exec stays. The head
|
|
4570
4822
|
// MUST be argv[0] (programHeadLiteral), NOT any literal arg: `spawn(toolVar, "curl")`
|
|
4571
4823
|
// names no static program, so its trailing literal must not fabricate Net (spec §4).
|
|
4572
|
-
const head =
|
|
4824
|
+
const head = lit;
|
|
4573
4825
|
if (head) for (const e of commandHeadEffects(head)) rec.direct.add(e);
|
|
4574
4826
|
// masking (sweep [11]): an Exec call whose program head is NOT a static literal (runtime
|
|
4575
4827
|
// command) leaves the command invisible. Establishing = the spawn fns; ChildProcess use-verbs
|
|
@@ -4577,12 +4829,16 @@ function visitCalls(node) {
|
|
|
4577
4829
|
else if (!EXEC_USE_VERBS.has(member)) rec.incomplete.add("Exec");
|
|
4578
4830
|
}
|
|
4579
4831
|
if (eff === "Fs") {
|
|
4580
|
-
|
|
4581
|
-
const
|
|
4582
|
-
|
|
4832
|
+
// ⟨0.29⟩ the PATH POSITION, never the first literal anywhere — see fsPathLiteral.
|
|
4833
|
+
const { lits, complete } = fsPathLiteral(node, member);
|
|
4834
|
+
const captured = lits.filter((l) => /[/\\]|^[.~]/.test(l)); // path-shaped literals only
|
|
4835
|
+
const pathCaptured = captured.length > 0 && captured.length === lits.length;
|
|
4836
|
+
for (const l of captured) rec.paths.add(l);
|
|
4583
4837
|
// masking (sweep [11]): a path-taking fs.* call whose path is NOT a captured literal (runtime
|
|
4584
4838
|
// path) leaves it invisible. fd/FileHandle USE-verbs (fd came from a prior open()) are excluded.
|
|
4585
|
-
|
|
4839
|
+
// ⟨0.29⟩ `complete` also covers a two-path op whose SECOND path is runtime, which a captured
|
|
4840
|
+
// position-0 literal would otherwise certify.
|
|
4841
|
+
if (!(pathCaptured && complete) && !FS_USE_VERBS.has(member)) rec.incomplete.add("Fs");
|
|
4586
4842
|
}
|
|
4587
4843
|
// CANDOR_DEPS: an unclassified call into a package with a loaded sibling report inherits
|
|
4588
4844
|
// that function's recorded transitive effects (+ literal surfaces) by `hash`.
|
|
@@ -5285,7 +5541,7 @@ function depInitCell(pkg, subpath) {
|
|
|
5285
5541
|
let cell = null;
|
|
5286
5542
|
for (const [h, c] of crossDeps) {
|
|
5287
5543
|
if (!h.startsWith(`${pkg}#`) || !h.endsWith(".<module>")) continue;
|
|
5288
|
-
cell ??= { inferred: new Set(), invisible: new Set(), why: new Set(), hosts: [], cmds: [], paths: [], tables: [], netIncomplete: false };
|
|
5544
|
+
cell ??= { inferred: new Set(), invisible: new Set(), why: new Set(), hosts: [], cmds: [], paths: [], tables: [], incomplete: new Set(), netIncomplete: false };
|
|
5289
5545
|
for (const e of c.inferred) cell.inferred.add(e);
|
|
5290
5546
|
for (const b of c.invisible) cell.invisible.add(b);
|
|
5291
5547
|
for (const w of c.why ?? []) cell.why.add(w);
|
|
@@ -5398,6 +5654,9 @@ function resolveDepEntryKey(pkg, subpath) {
|
|
|
5398
5654
|
// arm copies no `hosts` (a module unit's literals are not this caller's), so today it can only turn a
|
|
5399
5655
|
// hostless Net that a sibling literal in THIS body would have certified back into `unknown-host`.
|
|
5400
5656
|
if (cell.netIncomplete) rec.incomplete.add("Net");
|
|
5657
|
+
// ⟨0.29⟩ every OTHER effect the dependency could not locate, carried the same way. Without this the
|
|
5658
|
+
// join copies the dep's Fs and drops its "I could not see where", which is the fail-open above.
|
|
5659
|
+
for (const eff of cell.incomplete ?? []) rec.incomplete.add(eff);
|
|
5401
5660
|
}
|
|
5402
5661
|
for (let changed = true; changed; ) {
|
|
5403
5662
|
changed = false;
|
|
@@ -5557,7 +5816,11 @@ for (const m of ["hosts", "tables", "cmds", "paths", "blind", "incomplete", "fsK
|
|
|
5557
5816
|
// ---- emit: the §2 envelope (effect-free items omitted) + the §2.2 sidecar (EVERY fn a key) --------
|
|
5558
5817
|
// ⟨0.20⟩ Net destination-class partners from `.candor/config` — read ONCE here, used by the report's per-fn
|
|
5559
5818
|
// `netClass` field (below) and the gate (deny Net[unknown-host]); the SAME set both surfaces resolve.
|
|
5560
|
-
|
|
5819
|
+
// ⟨0.29⟩ …and a malformed `net-partner` line is DISCLOSED rather than kept as a junk host that matches
|
|
5820
|
+
// nothing for the rest of the run. Printed once, here, beside the other config diagnostics.
|
|
5821
|
+
const netPartnerErrs = [];
|
|
5822
|
+
const netPartners = parseNetPartners(discoverConfigText(target), netPartnerErrs);
|
|
5823
|
+
for (const e of netPartnerErrs) console.error(`candor-ts: ${e.why} — ${e.raw}`);
|
|
5561
5824
|
const functions = [];
|
|
5562
5825
|
for (const [name, rec] of fns) {
|
|
5563
5826
|
const inf = [...inferred.get(name)].sort();
|
|
@@ -5592,6 +5855,19 @@ for (const [name, rec] of fns) {
|
|
|
5592
5855
|
if (inf.includes("Db") && rec.tables.size) entry.tables = [...rec.tables].sort();
|
|
5593
5856
|
if (inf.includes("Exec") && rec.cmds.size) entry.cmds = [...rec.cmds].sort();
|
|
5594
5857
|
if (inf.includes("Fs") && rec.paths.size) entry.paths = [...rec.paths].sort();
|
|
5858
|
+
// ⟨0.29⟩ SPEC §2 `incomplete` — the effects whose LOCATOR this fn could not determine. Omitted when
|
|
5859
|
+
// empty, so a scan that determined everything is byte-identical to a pre-rung report.
|
|
5860
|
+
//
|
|
5861
|
+
// THIS ENGINE USED TO KEEP IT INTERNAL, on a premise about the family that was FALSE. The comment at the
|
|
5862
|
+
// dep-join said `rec.incomplete` is "deliberately INTERNAL family-wide (java/rust keep it out of the
|
|
5863
|
+
// report too)" — candor-rust has emitted it all along, and SPEC §2 says so in the clause right beside
|
|
5864
|
+
// the one this engine was following. MEASURED, and the cost is a false all-clear on a configured gate:
|
|
5865
|
+
// a dependency whose `Fs` path is a runtime value published NOTHING to say so, so a consumer that also
|
|
5866
|
+
// writes ONE allowed literal joined `paths: ['/tmp/lit']` with no incompleteness marker and
|
|
5867
|
+
// `allow Fs /tmp/lit` answered `policy ✓`. candor-rust, over the identical shape, charges AS-EFF-008.
|
|
5868
|
+
// An absent `paths` is overloaded between "reaches no path" and "reaches a path I could not see", and
|
|
5869
|
+
// this field is the only thing that separates them.
|
|
5870
|
+
if (rec.incomplete.size) entry.incomplete = [...rec.incomplete].sort();
|
|
5595
5871
|
// SPEC §2 `fs` — the read/write kinds this fn's OWN Fs calls revealed. Gated on `inferred` carrying Fs
|
|
5596
5872
|
// (the spec: "applies only when `inferred` contains `Fs`") and omitted when empty.
|
|
5597
5873
|
//
|
|
@@ -6051,7 +6327,10 @@ function fnv1aHex(sortedQuals) {
|
|
|
6051
6327
|
// so it says so. A producer MUST NOT list a surface it does not compute — that turns "unimplemented" into a
|
|
6052
6328
|
// false "undetermined", which is the inversion the field exists to prevent.
|
|
6053
6329
|
const envelope = { candor: { version: ENGINE_VERSION, toolchain: `node-${process.versions.node}`, spec: SPEC_VERSION },
|
|
6054
|
-
resolves
|
|
6330
|
+
// ⟨0.29⟩ `incomplete` joins `resolves`: an optional per-function refinement surface
|
|
6331
|
+
// whose absence is overloaded the way `fs`'s was — "does not compute undetermined
|
|
6332
|
+
// locators" vs "computed them and found none". This engine computes it, so it says so.
|
|
6333
|
+
resolves: ["fs", "incomplete"], package: pkgName, functions };
|
|
6055
6334
|
// ⟨0.15 staged⟩ the κ-coverage ledger as DATA (COVERAGE-DESIGN.md §1): ONE sorted form (count desc,
|
|
6056
6335
|
// name asc — exactly the stderr line's order) feeds the envelope field, the stderr receipt below, and
|
|
6057
6336
|
// the --gate-json advisory, so the three can never tell different stories.
|
|
@@ -6093,9 +6372,153 @@ for (const [name, rec] of fns) cg[name] = [...rec.edges].sort();
|
|
|
6093
6372
|
// ⟨0.28⟩ …and through `writeSinkAtomic`, so a symlinked or multiply-linked destination is written where
|
|
6094
6373
|
// the operator points rather than replaced. Reports have the same layout exposure as verdicts.
|
|
6095
6374
|
const writeAtomic = (file, text) => writeSinkAtomic(file, text);
|
|
6375
|
+
// ── ⟨0.29⟩ THE PEEK ───────────────────────────────────────────────────────────────────────────────
|
|
6376
|
+
// Read the files this run deliberately did NOT judge, and say so when they hold an effect the policy
|
|
6377
|
+
// DENIES. The verdict does not move: `outOfScope` is its own kind and never a violation, because a file
|
|
6378
|
+
// the gate declined to judge must not decide an exit code.
|
|
6379
|
+
//
|
|
6380
|
+
// A CHILD `scan.mjs`, not a second analysis path. candor-rust buys this by recursing into `scan_one`;
|
|
6381
|
+
// this engine is a script rather than a callable function, so the same guarantee comes from the same
|
|
6382
|
+
// BINARY over a different file set. That identity is the design constraint, not a convenience: a bespoke
|
|
6383
|
+
// second pass would be a SECOND OPINION, and a drifted second opinion reported as a warning is worse
|
|
6384
|
+
// than no warning — the reader cannot tell a real finding from two code paths disagreeing. The child is
|
|
6385
|
+
// given no policy, so it cannot gate and cannot recurse.
|
|
6386
|
+
//
|
|
6387
|
+
// PLACED HERE, ABOVE THE WRITE, on purpose. The gate block runs AFTER the envelope is serialised, so
|
|
6388
|
+
// computing this there would have put the finding on stderr and left it out of the artifact — the split
|
|
6389
|
+
// ⟨0.26⟩ calls worse than saying nothing. It reads the policy itself rather than borrowing the gate's
|
|
6390
|
+
// parse: one extra read of the same bytes through the same `parsePolicy`, not a second interpretation.
|
|
6391
|
+
//
|
|
6392
|
+
// POLICY-SCOPED AND POLICY-BOUNDED, which is the whole reason it stays quiet. No policy ⇒ the key is
|
|
6393
|
+
// ABSENT, because nothing was asked and `[]` would be a claim. With a policy, only effects that policy
|
|
6394
|
+
// DENIES are reported — otherwise the noise floor is "everything you excluded".
|
|
6395
|
+
let outOfScopeFindings = null;
|
|
6396
|
+
// ⟨0.29⟩ DID THE PEEK ACTUALLY READ ANYTHING? `peeked` was a constant of the exclusion CLASS, so a peek
|
|
6397
|
+
// that never ran, could not spawn, or produced unparseable output still published `peeked: true` beside
|
|
6398
|
+
// `outOfScope: []` — byte-identical to a clean peek, and the ⟨0.26⟩ partial-manifest failure inside the
|
|
6399
|
+
// rung built to prevent it. It is an outcome now.
|
|
6400
|
+
let peekRead = false;
|
|
6401
|
+
// ⟨0.29⟩ …AND DID IT READ THEM ALL? A child report the parent could PARSE is a different fact from every
|
|
6402
|
+
// excluded file having been opened. The child publishes its own ⟨0.21⟩ `unanalyzed` manifest and this
|
|
6403
|
+
// code read only `functions`, so an excluded file that failed to parse INSIDE the peek produced
|
|
6404
|
+
// `peeked: true` beside `outOfScope: []` — the same overclaim one comment up, one level down. `peeked` is
|
|
6405
|
+
// per CLASS, so the answer is too: a class is peeked only when no file of that class went unread.
|
|
6406
|
+
const peekUnread = new Set();
|
|
6407
|
+
let peekUnattributed = false;
|
|
6408
|
+
if (policyPath && excludedFiles.length) {
|
|
6409
|
+
let denied = new Set();
|
|
6410
|
+
try {
|
|
6411
|
+
const pol = parsePolicy(fs.readFileSync(policyPath, "utf8"), {});
|
|
6412
|
+
// ⟨0.29⟩ A REFUSED POLICY LEAVES THE KEY ABSENT (SPEC §2). The peek is a producer reading the policy,
|
|
6413
|
+
// so §3.1 binds it exactly as it binds the gate: over a policy no route will honour, `outOfScope: []`
|
|
6414
|
+
// claims a look taken against rules that never stood, and the `denied` set it would look for is the
|
|
6415
|
+
// parser's SALVAGE of an unhonourable file — the rewriting `fatalPolicyErrors` exists to refuse.
|
|
6416
|
+
// candor-java already withheld here; this engine, candor-rust and candor-swift did not.
|
|
6417
|
+
if (!fatalPolicyErrors(pol.errors).length) {
|
|
6418
|
+
denied = new Set((pol.deny ?? []).flatMap((r) => r.effects ?? []));
|
|
6419
|
+
}
|
|
6420
|
+
} catch { /* an unreadable policy is the gate's business to refuse, not the peek's */ }
|
|
6421
|
+
if (denied.size) {
|
|
6422
|
+
outOfScopeFindings = [];
|
|
6423
|
+
const peekDir = fs.mkdtempSync(path.join(os.tmpdir(), "candor-ts-peek-"));
|
|
6424
|
+
try {
|
|
6425
|
+
fs.writeFileSync(path.join(peekDir, "tsconfig.json"), JSON.stringify({
|
|
6426
|
+
compilerOptions: { target: "es2022", module: "esnext", allowJs: true, noEmit: true },
|
|
6427
|
+
files: excludedFiles.map((e) => path.resolve(rootDir, e.path)),
|
|
6428
|
+
}));
|
|
6429
|
+
const out = execFileSync(process.execPath,
|
|
6430
|
+
[fileURLToPath(import.meta.url), path.join(peekDir, "tsconfig.json"), "--json",
|
|
6431
|
+
"--peek-excluded"],
|
|
6432
|
+
// ⟨0.29⟩ `timeout` beside `maxBuffer`: the peek re-parses exactly the files this engine has never
|
|
6433
|
+
// parsed — vendored trees, generated code, declaration bundles — i.e. the inputs least likely to
|
|
6434
|
+
// have been exercised. Without a deadline one pathological file turns into a hung scan and a hung
|
|
6435
|
+
// CI job, which contradicts this feature's own rule that a peek which cannot run must not fail
|
|
6436
|
+
// the gate: hanging is the one failure that stops the gate completing at all. On timeout
|
|
6437
|
+
// `execFileSync` throws, the catch below leaves the findings as far as they got, and `peekRead`
|
|
6438
|
+
// stays false so no class claims to have been read.
|
|
6439
|
+
{ encoding: "utf8", maxBuffer: 1 << 28, timeout: 120_000,
|
|
6440
|
+
stdio: ["ignore", "pipe", "ignore"] });
|
|
6441
|
+
const doc = JSON.parse(out);
|
|
6442
|
+
peekRead = true; // the child returned a report this run could read — see `peekRead`
|
|
6443
|
+
for (const u of Array.isArray(doc.unanalyzed) ? doc.unanalyzed : []) {
|
|
6444
|
+
const where = String(u?.path ?? "");
|
|
6445
|
+
const hit = excludedFiles.find((e) => where.endsWith(e.path)
|
|
6446
|
+
|| where.endsWith(path.basename(e.path)));
|
|
6447
|
+
// The peek walks ONLY excluded files, so an unread path matching no exclusion is one this code
|
|
6448
|
+
// cannot attribute — fail closed across every class rather than let one unattributable file leave
|
|
6449
|
+
// all of them claiming completeness.
|
|
6450
|
+
if (hit) peekUnread.add(hit.cls); else peekUnattributed = true;
|
|
6451
|
+
}
|
|
6452
|
+
for (const f of doc.functions ?? []) {
|
|
6453
|
+
const hits = (f.inferred ?? []).filter((e) => denied.has(e));
|
|
6454
|
+
if (!hits.length) continue;
|
|
6455
|
+
// NAME IT FROM THE PROJECT, NOT FROM THE CHILD'S TEMP ROOT. The child's tsconfig lives in a
|
|
6456
|
+
// temp directory, so it derives module qualifiers from THAT root and the fn came out as a
|
|
6457
|
+
// dotted absolute path — accurate, unreadable, and pointing at a directory that no longer
|
|
6458
|
+
// exists by the time anyone reads it. The project-relative path is already known here (it is
|
|
6459
|
+
// what was excluded), so match on basename and report the path we already trust.
|
|
6460
|
+
const childLoc = (f.loc ?? "").split(":")[0] ?? "";
|
|
6461
|
+
const hit = excludedFiles.find((e) => childLoc.endsWith(e.path)
|
|
6462
|
+
|| childLoc.endsWith(path.basename(e.path)));
|
|
6463
|
+
const where = hit?.path ?? childLoc;
|
|
6464
|
+
const cls = hit?.cls ?? "excluded";
|
|
6465
|
+
outOfScopeFindings.push({
|
|
6466
|
+
fn: f.fn.split(".").pop() ?? f.fn, path: where, effects: hits, class: cls,
|
|
6467
|
+
reason: `OUTSIDE this scan's scope (${cls}) — the gate did NOT judge it. `
|
|
6468
|
+
+ "The effect is real; the verdict does not account for it.",
|
|
6469
|
+
});
|
|
6470
|
+
}
|
|
6471
|
+
outOfScopeFindings.sort((a, b) => (a.path + a.fn).localeCompare(b.path + b.fn));
|
|
6472
|
+
} catch {
|
|
6473
|
+
// A PEEK THAT CANNOT RUN MUST NOT FAIL THE GATE — it is advisory by construction, and turning a
|
|
6474
|
+
// child-process failure into a red gate would make the safest thing an operator can do (add a
|
|
6475
|
+
// policy) the thing that breaks their build. It stays `[]`: a policy WAS configured, so the key is
|
|
6476
|
+
// a real answer, and "we looked and found nothing" is what an empty list says.
|
|
6477
|
+
} finally {
|
|
6478
|
+
fs.rmSync(peekDir, { recursive: true, force: true });
|
|
6479
|
+
}
|
|
6480
|
+
}
|
|
6481
|
+
}
|
|
6482
|
+
if (outOfScopeFindings) {
|
|
6483
|
+
envelope.outOfScope = outOfScopeFindings;
|
|
6484
|
+
// SAY IT ON STDERR TOO. The report block is for machines; an operator reading `policy ✓` needs to know
|
|
6485
|
+
// in the same breath that a file this scan did not judge holds the effect they denied.
|
|
6486
|
+
for (const f of outOfScopeFindings) {
|
|
6487
|
+
console.error(`candor-ts: ⚠ ${f.fn} performs ${f.effects.join("+")} — OUTSIDE this scan's scope `
|
|
6488
|
+
+ `(${f.class}), so the gate did NOT judge it.`);
|
|
6489
|
+
if (f.path) console.error(` ${f.path}`);
|
|
6490
|
+
}
|
|
6491
|
+
if (outOfScopeFindings.length) {
|
|
6492
|
+
console.error(" The verdict does not account for "
|
|
6493
|
+
+ (outOfScopeFindings.length === 1 ? "it." : `these ${outOfScopeFindings.length}.`));
|
|
6494
|
+
}
|
|
6495
|
+
}
|
|
6496
|
+
|
|
6497
|
+
// ⟨0.29⟩ THE SCOPE, BUILT AFTER THE PEEK — `peeked` is an OUTCOME, so this block cannot be assembled
|
|
6498
|
+
// until the peek has run. It sat above the peek and read `peekRead` before anything could set it, which
|
|
6499
|
+
// is the ordering bug the flag's own purpose makes fatal: a field that exists to say whether a read
|
|
6500
|
+
// happened, computed before the read. Emitted even when EMPTY — ⟨0.27⟩ makes zero-match a positive
|
|
6501
|
+
// statement, and ⟨0.26⟩ makes an ABSENT key mean "this producer cannot answer".
|
|
6502
|
+
{
|
|
6503
|
+
const byClass = new Map();
|
|
6504
|
+
for (const e of excludedFiles) byClass.set(e.cls, (byClass.get(e.cls) ?? 0) + 1);
|
|
6505
|
+
envelope.excluded = [...byClass.entries()].sort((a, b) => a[0].localeCompare(b[0]))
|
|
6506
|
+
.map(([cls, count]) => ({ class: cls, count,
|
|
6507
|
+
// `peekUnread` subtracts the classes the peek RAN over and could not read:
|
|
6508
|
+
// parse failures are per file, so the claim is per class.
|
|
6509
|
+
peeked: peekRead && !peekUnattributed && !peekUnread.has(cls),
|
|
6510
|
+
reason: EXCLUDED_REASON[cls] ?? `excluded (${cls})` }));
|
|
6511
|
+
}
|
|
6512
|
+
|
|
6096
6513
|
// --json: print the §2 envelope to STDOUT instead of writing the report files (matches candor-scan/Rust).
|
|
6097
6514
|
if (wantJson) {
|
|
6098
|
-
console.log
|
|
6515
|
+
// writeStdoutSync, NOT console.log. MEASURED over a 400-file fixture with a violating policy: 95281
|
|
6516
|
+
// bytes to a FILE and valid JSON, **65536 bytes through a PIPE and a JSONDecodeError** — exactly the
|
|
6517
|
+
// pipe buffer, exit 1 either way, nothing on stderr. console.log is asynchronous on a pipe and the
|
|
6518
|
+
// process.exit() sites below discard what is still buffered. This is the machine-consumer path
|
|
6519
|
+
// (`candor-ts src --json --policy p | jq`), so the loss is silent and the document is invalid, which
|
|
6520
|
+
// is worse than the `--agents` truncation that got this class fixed in the first place.
|
|
6521
|
+
writeStdoutSync(JSON.stringify(envelope, null, 1) + "\n", "--json");
|
|
6099
6522
|
// ⟨0.28⟩ REPORT STREAM LATCH — a successful envelope went to stdout, so a later exit-2 site (baseline
|
|
6100
6523
|
// corrupt, policy refusal, gate NOT certified over unanalyzed) MUST NOT also write a fail-closed
|
|
6101
6524
|
// placeholder there. Two documents on one stream parses as neither — the same shape the two-stream
|
|
@@ -6509,7 +6932,10 @@ if (policyPath !== null) {
|
|
|
6509
6932
|
// `deny Fs`, absent from the exit-1 document's list, as evaluated-and-passed. The SHARED builder,
|
|
6510
6933
|
// so this document and `gate --report`'s stay byte-equal (§3.1's acceptance test for the routes).
|
|
6511
6934
|
policyRefusal = { why, unevaluated: policyRefusalUnevaluated(text, policyErrs) };
|
|
6512
|
-
|
|
6935
|
+
// ⟨0.29⟩ …counting `only` too: an `only`-only policy is a LIVE gate, and refusing it here would
|
|
6936
|
+
// be the ⟨0.28⟩ fail-closed guard turned into a false refusal by the rung that added the kind.
|
|
6937
|
+
} else if (!gatePolicy.deny.length && !gatePolicy.allow.length && !gatePolicy.forbid.length
|
|
6938
|
+
&& !(gatePolicy.only ?? []).length) {
|
|
6513
6939
|
// ⟨0.28⟩ A CONFIGURED POLICY THAT YIELDED ZERO RULES IS A BROKEN GATE CONFIG (SPEC §6.2) — the same
|
|
6514
6940
|
// refusal posture as the two branches above, and for the reason §6.2 already gives for an unreadable
|
|
6515
6941
|
// FILE: "a typo'd policy path that runs green is a gate that silently passes everything". MEASURED
|
|
@@ -6560,6 +6986,33 @@ if (policyPath !== null) {
|
|
|
6560
6986
|
// code is deliberately untouched: a zero-match rule is legitimate when one policy is shared across
|
|
6561
6987
|
// repositories and a layer exists in only some of them, so refusal would make a shared policy
|
|
6562
6988
|
// unusable. Printed before the violations so a typo'd layer name is visible above the verdict.
|
|
6989
|
+
// ⟨0.29⟩ THE NAME RULES STOP AT THE SCAN BOUNDARY, AND NOW SAY SO. `forbid A -> B` and
|
|
6990
|
+
// `only A -> B …` are matched over the call graph; a chained dependency contributes EFFECTS, not
|
|
6991
|
+
// EDGES, so a function that calls into a dep has an EMPTY adjacency and the crossing is invisible.
|
|
6992
|
+
// MEASURED: with a dep chained, `only model -> util` answered `policy ✓` over
|
|
6993
|
+
// `model.viaDep() -> deplib.infra.dbRead()` while a LOCAL unpermitted scope in the same run fired
|
|
6994
|
+
// AS-EFF-011 — so the rule was armed and the boundary, not the rule, was the gap.
|
|
6995
|
+
//
|
|
6996
|
+
// WORSE FOR `only` THAN FOR `forbid`, which is why this is disclosed rather than left implicit:
|
|
6997
|
+
// `forbid` asks whether ONE named crossing is present, so missing a dep crossing under-reports one
|
|
6998
|
+
// prohibition; `only` asserts A reaches the listed scopes AND NOTHING ELSE — a COMPLETENESS claim —
|
|
6999
|
+
// and it exists precisely because `forbid` fails open. A package that calls a third-party library is
|
|
7000
|
+
// not a leaf, and without this line the gate called it one.
|
|
7001
|
+
//
|
|
7002
|
+
// DISCLOSURE, NOT A VERDICT CHANGE, and not a re-scoping either: making the rules cross would need
|
|
7003
|
+
// dep-report EDGES, and it would force operators to enumerate third-party scopes in an `only` list —
|
|
7004
|
+
// the enumeration-that-rots that form was designed to escape. The ⟨0.29⟩ `outOfScope` posture
|
|
7005
|
+
// exactly: say what was not judged, leave the exit code alone.
|
|
7006
|
+
if (depChained()) {
|
|
7007
|
+
const named = [...(gatePolicy.forbid ?? []), ...(gatePolicy.only ?? [])];
|
|
7008
|
+
if (named.length) {
|
|
7009
|
+
console.error(`candor-ts: ⚠ ${named.length} name-matching rule(s) (\`forbid\`/\`only\`) were `
|
|
7010
|
+
+ "matched over THIS scan's call graph only — a chained dependency contributes effects, not "
|
|
7011
|
+
+ "call edges, so a crossing INTO a dependency is invisible to them. `deny`/`allow` still "
|
|
7012
|
+
+ "cross (effects propagate); an `only` rule cannot certify that a package is a leaf when it "
|
|
7013
|
+
+ "calls into one of its dependencies.");
|
|
7014
|
+
}
|
|
7015
|
+
}
|
|
6563
7016
|
for (const raw of gateOut.zeroMatch ?? []) {
|
|
6564
7017
|
console.error(`candor: policy rule matched NO function — \`${raw}\`. It was evaluated and bound `
|
|
6565
7018
|
+ `nothing, so it cannot have caught anything. Legitimate when one policy is shared across `
|