candor-ts 0.5.12 → 0.5.13

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 CHANGED
@@ -144,7 +144,10 @@ curated-κ caveat cuts the other way:** a call into an npm package κ doesn't kn
144
144
  NOTHING — invisible, not `Unknown`. The scan's receipt now DISCLOSES these by name (`κ doesn't
145
145
  know N packages…`), so the blind spots are per-scan evidence, not a doc footnote: never conclude
146
146
  "no effect" through a package that line names (the documented weaker edge of the
147
- never-silently-pure promise, same as every candor engine's curated classifier). An uncurated
147
+ never-silently-pure promise, same as every candor engine's curated classifier). Each function ALSO
148
+ carries an `invisible` list — the κ-unknown packages it (transitively) reaches — so `inferred` is
149
+ never an unqualified claim PER FUNCTION: `inferred: []` with a non-empty `invisible` means "pure as
150
+ far as candor could see, but it could not see through these" (a LOWER bound), not "pure". An uncurated
148
151
  dependency can opt out of that blind spot by declaring `"candorEffects": ["Net", …]` in its
149
152
  `package.json` (the §5.1 effect manifest, read declared-not-verified) — its calls then classify to
150
153
  the declared set instead of contributing nothing.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "candor-ts",
3
- "version": "0.5.12",
3
+ "version": "0.5.13",
4
4
  "description": "candor for TypeScript — per-function side effects, transitively, with a policy gate (candor-spec 0.5)",
5
5
  "type": "module",
6
6
  "dependencies": {
package/policy.mjs CHANGED
@@ -103,7 +103,7 @@ export function literalAllowed(effect, reached, values) {
103
103
  * transitive inferred; AS-EFF-008 allowlists over the transitive literal surfaces, the no-visible-
104
104
  * literal case flagged as uncertifiable; AS-EFF-009 forbid by reachability). One line per violation.
105
105
  */
106
- export function evaluatePolicy(pol, functions, callgraph) {
106
+ export function evaluatePolicy(pol, functions, callgraph, incomplete = new Map()) {
107
107
  const out = [];
108
108
  const surfaces = { Net: "hosts", Exec: "cmds", Fs: "paths", Db: "tables" };
109
109
  for (const f of functions) {
@@ -116,7 +116,11 @@ export function evaluatePolicy(pol, functions, callgraph) {
116
116
  if (r.scope && !scopeMatches(f.fn, r.scope)) continue;
117
117
  if (!f.inferred.includes(r.effect)) continue;
118
118
  const reached = f[surfaces[r.effect]] ?? [];
119
- if (reached.length === 0) {
119
+ // An INCOMPLETE surface (a structurally-invisible reach — a host-establishing call with a runtime/
120
+ // invisible host) can't be certified even with visible hosts, else a benign literal masks the
121
+ // invisible forbidden endpoint (the masking evasion). Matches candor-java 0.5.29 / candor-rust.
122
+ const surfaceIncomplete = incomplete.get(f.fn)?.has(r.effect);
123
+ if (reached.length === 0 || surfaceIncomplete) {
120
124
  out.push(`[AS-EFF-008] \`${f.fn}\` performs ${r.effect} with no visible literal — the surface cannot be certified: \`${r.raw}\``);
121
125
  } else {
122
126
  const bad = reached.filter((v) => !literalAllowed(r.effect, v, r.values));
package/scan.mjs CHANGED
@@ -464,7 +464,7 @@ for (const sf of sources) {
464
464
  const { line, character } = sf.getLineAndCharacterOfPosition(node.getStart());
465
465
  fns.set(ctorQual, { local: `${node.name.text}.constructor`, direct: new Set(), edges: new Set(),
466
466
  hosts: new Set(), tables: new Set(), cmds: new Set(), paths: new Set(),
467
- why: new Set(), entry: false,
467
+ blind: new Set(), incomplete: new Set(), why: new Set(), entry: false,
468
468
  loc: `${path.relative(rootDir, sf.fileName)}:${line + 1}:${character + 1}` });
469
469
  }
470
470
  nodeName.set(node, ctorQual);
@@ -475,7 +475,7 @@ for (const sf of sources) {
475
475
  const qual = `${mod}.${n}`;
476
476
  const { line, character } = sf.getLineAndCharacterOfPosition(node.getStart());
477
477
  fns.set(qual, { local: n, direct: new Set(), edges: new Set(), hosts: new Set(), tables: new Set(),
478
- cmds: new Set(), paths: new Set(), why: new Set(), entry: false, isCjsExport,
478
+ cmds: new Set(), paths: new Set(), blind: new Set(), incomplete: new Set(), why: new Set(), entry: false, isCjsExport,
479
479
  loc: `${path.relative(rootDir, sf.fileName)}:${line + 1}:${character + 1}` });
480
480
  nodeName.set(node, qual);
481
481
  if ((ts.isVariableDeclaration(node) || ts.isPropertyDeclaration(node)) && node.initializer)
@@ -980,6 +980,9 @@ function visitCalls(node) {
980
980
  // needs no entry here. Inert ctors (Agent/Server/Socket/TLSSocket/Http2Server*/message shells)
981
981
  // still synthesize "new" and stay pure.
982
982
  const CONNECTING_CTORS = new Set(["ClientRequest"]);
983
+ // Host-ESTABLISHING Net call names (the masking-fix allowlist): a Net call by one of these whose
984
+ // host is not a captured literal leaves the host invisible. Excludes use-verbs (write/end/send).
985
+ const NET_ESTABLISHING = new Set(["request", "get", "connect", "createConnection", "fetch"]);
983
986
  const ctorClassName = ts.isNewExpression(node)
984
987
  ? (ts.isConstructorDeclaration(decl) ? decl.parent?.name?.getText?.()
985
988
  : (decl.name ? decl.name.getText() : ""))
@@ -1005,6 +1008,14 @@ function visitCalls(node) {
1005
1008
  const lit = firstStringLiteral(node);
1006
1009
  const h = lit && hostLiteral(lit);
1007
1010
  if (h) rec.hosts.add(h);
1011
+ // MASKING fix: a host-ESTABLISHING Net call whose host is NOT a captured literal (runtime URL, or
1012
+ // built elsewhere) leaves the host invisible to the gate → mark the surface incomplete so a
1013
+ // benign literal can't mask it. ALLOWLIST of establishing forms only (request/get/connect/
1014
+ // createConnection/fetch + the connecting ctor) — NEVER use-calls (write/end/send), which would
1015
+ // false-positive on `socket.connect("h").write(data)` (the host is captured at connect). Under-
1016
+ // catches an unlisted establishing verb (safe direction); never over-flags a use-call.
1017
+ else if (NET_ESTABLISHING.has(member) || CONNECTING_CTORS.has(ctorClassName))
1018
+ rec.incomplete.add("Net");
1008
1019
  }
1009
1020
  if (eff === "Db") {
1010
1021
  const lit = firstStringLiteral(node);
@@ -1079,6 +1090,11 @@ function visitCalls(node) {
1079
1090
  } else if (!kappaKnows(pkg) && !depCoveredPkgs.has(pkg)
1080
1091
  && /node_modules\//.test(file) && !/node_modules\/(@types\/node|typescript)\//.test(file)) {
1081
1092
  unlistedSeen.set(pkg, (unlistedSeen.get(pkg) ?? 0) + 1);
1093
+ // Per-fn HONESTY: this fn calls into a genuinely-blind package (κ-unknown, not dep-covered).
1094
+ // Recorded per fn, propagated transitively, emitted as `invisible` — so `inferred` is never an
1095
+ // unqualified completeness claim. This branch already IS the global-blind condition, so no
1096
+ // post-filter is needed (κ either knows a package or it doesn't).
1097
+ rec.blind.add(pkg);
1082
1098
  }
1083
1099
  }
1084
1100
  }
@@ -1271,7 +1287,7 @@ while (changed) {
1271
1287
  if (!mine.has(e)) { mine.add(e); changed = true; }
1272
1288
  }
1273
1289
  }
1274
- for (const m of ["hosts", "tables", "cmds", "paths"]) {
1290
+ for (const m of ["hosts", "tables", "cmds", "paths", "blind", "incomplete"]) {
1275
1291
  let moved = true;
1276
1292
  while (moved) {
1277
1293
  moved = false;
@@ -1286,7 +1302,9 @@ for (const m of ["hosts", "tables", "cmds", "paths"]) {
1286
1302
  const functions = [];
1287
1303
  for (const [name, rec] of fns) {
1288
1304
  const inf = [...inferred.get(name)].sort();
1289
- if (inf.length === 0 && !rec.entry) continue; // entry points stay visible even when pure
1305
+ // entry points stay visible even when pure; a BLIND fn stays too, so the honesty disclosure survives
1306
+ // on exactly the `inferred: []` fns that need it.
1307
+ if (inf.length === 0 && !rec.entry && rec.blind.size === 0) continue;
1290
1308
  const entry = {
1291
1309
  fn: name,
1292
1310
  loc: rec.loc,
@@ -1303,6 +1321,9 @@ for (const [name, rec] of fns) {
1303
1321
  if (inf.includes("Exec") && rec.cmds.size) entry.cmds = [...rec.cmds].sort();
1304
1322
  if (inf.includes("Fs") && rec.paths.size) entry.paths = [...rec.paths].sort();
1305
1323
  if (rec.direct.has("Unknown") && rec.why.size) entry.unknownWhy = [...rec.why].sort();
1324
+ // HONESTY: the npm packages this fn transitively reaches that κ couldn't see through — effects through
1325
+ // them are NOT in `inferred`, so it is a LOWER BOUND when this is non-empty. Omitted when none.
1326
+ if (rec.blind.size) entry.invisible = [...rec.blind].sort();
1306
1327
  if (rec.entry) entry.entryPoint = true;
1307
1328
  if (rec.isCjsExport) entry.unitKind = "export"; // spec 0.5 draft, informative — per-unit, not by name
1308
1329
  functions.push(entry);
@@ -1339,7 +1360,11 @@ if (policyPath) {
1339
1360
  console.error(`candor-ts: policy ${policyPath} could not be read; gate NOT enforced`);
1340
1361
  process.exit(2);
1341
1362
  }
1342
- const v = evaluatePolicy(parsePolicy(text), functions, cg);
1363
+ // The masking-incompleteness map (fn -> effects whose surface is incomplete), kept INTERNAL like the
1364
+ // java/rust engines (not a report field) — passed to the gate so an incomplete surface fails closed.
1365
+ const incompleteMap = new Map();
1366
+ for (const [name, rec] of fns) if (rec.incomplete.size) incompleteMap.set(name, rec.incomplete);
1367
+ const v = evaluatePolicy(parsePolicy(text), functions, cg, incompleteMap);
1343
1368
  for (const line of v) console.log(line);
1344
1369
  if (v.length) {
1345
1370
  console.error(`candor-ts: ${v.length} policy violation(s)`);