candor-ts 0.5.11 → 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.11",
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)
@@ -788,6 +788,27 @@ function visitCalls(node) {
788
788
  if (t) rec.edges.add(t);
789
789
  }
790
790
  }
791
+ // `fn.call(thisArg, …)` / `fn.apply(thisArg, args)` INVOKE the receiver function reference, and
792
+ // `Reflect.apply(fn, …)` / `Reflect.construct(Ctor, …)` invoke their FIRST ARGUMENT. The resolved
793
+ // signature lands on the es-lib `CallableFunction.call/apply` / `Reflect.apply` member, so the
794
+ // function actually invoked (the receiver, or arg0) was never followed → the caller read
795
+ // silent-pure (HIGH: a common reflective-invoke shape). Edge to the referenced unit, mirroring the
796
+ // HOF-ref arm: a pure ref edges to a pure unit (no fabrication); a non-fn receiver/arg resolves to
797
+ // no minted unit (`nodeName` miss) and adds nothing; an unresolvable ref stays opaque/Unknown.
798
+ if (ts.isPropertyAccessExpression(node.expression)) {
799
+ const m = node.expression.name.text;
800
+ const recv = node.expression.expression;
801
+ const recvText = recv.getText().replace(/\s+/g, "");
802
+ let invokedRef = null;
803
+ if ((m === "call" || m === "apply") && recvText !== "Reflect") invokedRef = recv;
804
+ else if (recvText === "Reflect" && (m === "apply" || m === "construct"))
805
+ invokedRef = (node.arguments ?? [])[0] ?? null;
806
+ if (invokedRef && (ts.isIdentifier(invokedRef) || ts.isPropertyAccessExpression(invokedRef))) {
807
+ const d2 = realDecl(checker.getSymbolAtLocation(invokedRef));
808
+ const t = d2 && nodeName.get(d2);
809
+ if (t) rec.edges.add(t);
810
+ }
811
+ }
791
812
  if (mod === "<local>") {
792
813
  const targetName = nodeName.get(decl);
793
814
  if (targetName) {
@@ -933,6 +954,19 @@ function visitCalls(node) {
933
954
  if (ts.isNewExpression(node) && (node.arguments ?? []).length === 0
934
955
  && checker.getTypeAtLocation(node.expression)?.symbol?.name === "DateConstructor")
935
956
  rec.direct.add("Clock");
957
+ // Browser/runtime NETWORK globals declared in lib.dom — no importable module for the κ table to
958
+ // key on, so they read SILENT-PURE. `XMLHttpRequest.send`/`.open` issue the HTTP request; the
959
+ // `EventSource`/`WebSocket` constructors open a connection on construction. Net. (Found by a
960
+ // Net-deep sweep. The npm `ws` package is already κ-covered; this is the bare browser global.)
961
+ if (parent === "XMLHttpRequest" && (name === "send" || name === "open")) rec.direct.add("Net");
962
+ // `new EventSource(url)` / `new WebSocket(url)`: the constructor is declared on an anonymous
963
+ // `declare var` object type (symbol `__type`, no usable parent name), but reaching the es-lib
964
+ // branch already proves the ctor resolved to lib.dom (not a project class shadowing the name),
965
+ // so the constructed identifier is the real browser global.
966
+ if (ts.isNewExpression(node)) {
967
+ const ctorName = node.expression.getText();
968
+ if (ctorName === "EventSource" || ctorName === "WebSocket") rec.direct.add("Net");
969
+ }
936
970
  } else {
937
971
  // The member token κ matches: the resolved declaration's name, EXCEPT a `new X()` call,
938
972
  // whose declaration is a Constructor (empty name) — synthesize "new" so a rule can exempt
@@ -946,6 +980,9 @@ function visitCalls(node) {
946
980
  // needs no entry here. Inert ctors (Agent/Server/Socket/TLSSocket/Http2Server*/message shells)
947
981
  // still synthesize "new" and stay pure.
948
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"]);
949
986
  const ctorClassName = ts.isNewExpression(node)
950
987
  ? (ts.isConstructorDeclaration(decl) ? decl.parent?.name?.getText?.()
951
988
  : (decl.name ? decl.name.getText() : ""))
@@ -971,6 +1008,14 @@ function visitCalls(node) {
971
1008
  const lit = firstStringLiteral(node);
972
1009
  const h = lit && hostLiteral(lit);
973
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");
974
1019
  }
975
1020
  if (eff === "Db") {
976
1021
  const lit = firstStringLiteral(node);
@@ -1045,6 +1090,11 @@ function visitCalls(node) {
1045
1090
  } else if (!kappaKnows(pkg) && !depCoveredPkgs.has(pkg)
1046
1091
  && /node_modules\//.test(file) && !/node_modules\/(@types\/node|typescript)\//.test(file)) {
1047
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);
1048
1098
  }
1049
1099
  }
1050
1100
  }
@@ -1087,6 +1137,11 @@ function visitCalls(node) {
1087
1137
  && !(checker.getSymbolAtLocation(callee)?.declarations ?? [])
1088
1138
  .some((d) => projectFiles.has(path.resolve(d.getSourceFile().fileName))))
1089
1139
  geff = "Net";
1140
+ // the fully-qualified global fetch — `globalThis.fetch`/`window.fetch`/`self.fetch` — is a
1141
+ // PropertyAccess callee the bare-identifier guard above misses, so it read silent-pure. Mirror the
1142
+ // `eval` global-qualifier handling (a runtime global a project would not shadow).
1143
+ else if (ctext === "globalThis.fetch" || ctext === "window.fetch" || ctext === "self.fetch")
1144
+ geff = "Net";
1090
1145
  if (geff) {
1091
1146
  const owner = enclosing(node);
1092
1147
  if (owner) fns.get(owner).direct.add(geff);
@@ -1232,7 +1287,7 @@ while (changed) {
1232
1287
  if (!mine.has(e)) { mine.add(e); changed = true; }
1233
1288
  }
1234
1289
  }
1235
- for (const m of ["hosts", "tables", "cmds", "paths"]) {
1290
+ for (const m of ["hosts", "tables", "cmds", "paths", "blind", "incomplete"]) {
1236
1291
  let moved = true;
1237
1292
  while (moved) {
1238
1293
  moved = false;
@@ -1247,7 +1302,9 @@ for (const m of ["hosts", "tables", "cmds", "paths"]) {
1247
1302
  const functions = [];
1248
1303
  for (const [name, rec] of fns) {
1249
1304
  const inf = [...inferred.get(name)].sort();
1250
- 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;
1251
1308
  const entry = {
1252
1309
  fn: name,
1253
1310
  loc: rec.loc,
@@ -1264,6 +1321,9 @@ for (const [name, rec] of fns) {
1264
1321
  if (inf.includes("Exec") && rec.cmds.size) entry.cmds = [...rec.cmds].sort();
1265
1322
  if (inf.includes("Fs") && rec.paths.size) entry.paths = [...rec.paths].sort();
1266
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();
1267
1327
  if (rec.entry) entry.entryPoint = true;
1268
1328
  if (rec.isCjsExport) entry.unitKind = "export"; // spec 0.5 draft, informative — per-unit, not by name
1269
1329
  functions.push(entry);
@@ -1300,7 +1360,11 @@ if (policyPath) {
1300
1360
  console.error(`candor-ts: policy ${policyPath} could not be read; gate NOT enforced`);
1301
1361
  process.exit(2);
1302
1362
  }
1303
- 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);
1304
1368
  for (const line of v) console.log(line);
1305
1369
  if (v.length) {
1306
1370
  console.error(`candor-ts: ${v.length} policy violation(s)`);