candor-ts 0.5.4 → 0.5.6

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/mcp.mjs CHANGED
@@ -45,6 +45,20 @@ function resolvePrefix(args) {
45
45
  if (!hasReport(p)) throw new Error(`no report at \`${p}\` (.json or .<crate>.scan.json) — run a candor scan first`);
46
46
  return p;
47
47
  }
48
+ // Truncate a caller-supplied value echoed back in an error (a multi-MB `fn` would otherwise be reflected
49
+ // verbatim — token/memory amplification over the agent transport, the opposite of the list-cap thrift).
50
+ const clip = (s, n = 120) => { s = String(s); return s.length > n ? s.slice(0, n) + "…" : s; };
51
+ // Read a caller-supplied policy file CONFINED to the report's directory tree. The MCP surface is
52
+ // report-query-only (spec §7.12); an arbitrary `policy` path (/etc/passwd, ~/.aws/credentials) whose
53
+ // parsed deny-rule scopes are reflected back in violations[].rule is an arbitrary-file-read exfiltration
54
+ // channel — tie the policy to the project it gates.
55
+ function confinedPolicyRead(policyPath, prefix) {
56
+ const root = nodePath.resolve(nodePath.dirname(prefix));
57
+ const abs = nodePath.resolve(policyPath);
58
+ if (abs !== root && !abs.startsWith(root + nodePath.sep))
59
+ throw new Error(`policy must be within the report's directory (${root}) — refusing to read \`${clip(policyPath)}\``);
60
+ return fs.readFileSync(abs, "utf8");
61
+ }
48
62
 
49
63
  // ---- the tools: name -> {description, schema, run} ------------------------------------------------
50
64
  const reportArg = { report: { type: "string", description: "report prefix (optional; defaults to $CANDOR_REPORT)" } };
@@ -110,9 +124,9 @@ const TOOLS = {
110
124
  description: "Hypothetically add `effect` to `fn` and report the blast radius; with `policy`, also the deny-rule violations it would cause. Pre-edit gate check.",
111
125
  schema: { type: "object", properties: { fn: { type: "string" }, effect: { type: "string" }, policy: { type: "string", description: "path to a CANDOR_POLICY file (optional)" }, ...reportArg }, required: ["fn", "effect"] },
112
126
  run: (a, p) => {
113
- const pol = a.policy && fs.existsSync(a.policy) ? parsePolicy(fs.readFileSync(a.policy, "utf8")) : null;
127
+ const pol = a.policy && fs.existsSync(a.policy) ? parsePolicy(confinedPolicyRead(a.policy, p)) : null;
114
128
  const r = Q.whatif(Q.loadCallgraph(p), a.fn, a.effect, pol, scopeMatches);
115
- if (r === null) throw new Error(`no function matching \`${a.fn}\` in the call graph`);
129
+ if (r === null) throw new Error(`no function matching \`${clip(a.fn)}\` in the call graph`);
116
130
  return r;
117
131
  },
118
132
  },
@@ -156,7 +170,7 @@ function handle(msg) {
156
170
  if (args.fn !== undefined) {
157
171
  const names = [...new Set([...Object.keys(Q.loadCallgraph(prefix)), ...Q.loadReport(prefix).map((e) => e.fn)])];
158
172
  if (Q.matches(names, args.fn).length === 0)
159
- return result(id, { content: [{ type: "text", text: `candor: no function matching \`${args.fn}\` in this report` }], isError: true });
173
+ return result(id, { content: [{ type: "text", text: `candor: no function matching \`${clip(args.fn)}\` in this report` }], isError: true });
160
174
  }
161
175
  const out = t.run(args, prefix);
162
176
  // Minified, not pretty-printed: the consumer is an AGENT (it parses the JSON), so the indentation
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "candor-ts",
3
- "version": "0.5.4",
3
+ "version": "0.5.6",
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/scan-core.mjs CHANGED
@@ -40,6 +40,14 @@ export const KAPPA_RULES = [
40
40
  // these three named validators are freed; every genuine verb (connect/createConnection/createServer…)
41
41
  // stays Net (the matcher excludes ONLY new + the three validators, nothing else).
42
42
  [/^(node:)?(net|dgram|tls|http2?|https)$/, /^(?!(new|isIP|isIPv4|isIPv6)$)/, "Net"],
43
+ // node:dns — name resolution is NETWORK I/O (lookup/lookupService hit the OS resolver; resolve*/
44
+ // reverse query DNS servers directly). Was unclassified, so a `dns.resolve(...)` read silently pure.
45
+ // Same construction-and-pure-accessor carve-out as the net cluster: `new dns.Resolver()` ("new") is
46
+ // inert, and the SERVER-CONFIG accessors getServers/setServers/get|setDefaultResultOrder touch no
47
+ // network (in-process config) — classifying them Net would FABRICATE the cardinal sin. Every genuine
48
+ // resolver verb (lookup/resolve4/resolveMx/reverse/…) stays Net. Covers node:dns/promises too.
49
+ [/^(node:)?dns(\/promises)?$/,
50
+ /^(?!(new|getServers|setServers|getDefaultResultOrder|setDefaultResultOrder)$)/, "Net"],
43
51
  [/^(node:)?child_process$/, null, "Exec"],
44
52
  [/^(node:)?sqlite$/, null, "Db"],
45
53
  // the curated npm tier
package/scan.mjs CHANGED
@@ -323,6 +323,32 @@ const nodeName = new WeakMap(); // declaration node -> qualified name
323
323
  const entityTables = new Map(); // ClassDeclaration node -> table name
324
324
  const interfaceImpls = new Map(); // InterfaceDeclaration node -> implementing ClassDeclarations (CHA universe)
325
325
  const classOverrides = new Map(); // base-method MemberDeclaration node -> overriding subclass member nodes (class-CHA)
326
+ // Resolve `extends X` to X's LOCAL ClassDeclaration (through an import alias), or null. Module-level
327
+ // so both the class-CHA INDEX (below) and the dispatch site's RECEIVER-SUBTREE scoping share one
328
+ // definition of the local inheritance edge.
329
+ function localBaseClassOf(cls) {
330
+ for (const h of cls.heritageClauses ?? []) {
331
+ if (h.token !== ts.SyntaxKind.ExtendsKeyword) continue;
332
+ const t = h.types?.[0];
333
+ if (!t) continue;
334
+ let sym = checker.getSymbolAtLocation(t.expression);
335
+ if (sym && sym.flags & ts.SymbolFlags.Alias) { try { sym = checker.getAliasedSymbol(sym); } catch { /* keep */ } }
336
+ const bd = (sym?.declarations ?? []).find((d) => ts.isClassDeclaration(d));
337
+ if (bd && projectFiles.has(path.resolve(bd.getSourceFile().fileName))) return bd;
338
+ }
339
+ return null;
340
+ }
341
+ // Is `cls` in the subtree rooted at `root` (i.e. cls === root, or cls transitively `extends` root
342
+ // through LOCAL classes)? Used to scope a base-member override fan-out to the RECEIVER's static type
343
+ // — a sibling subclass's override lives OUTSIDE this subtree and must not contaminate the verdict.
344
+ function classInSubtree(cls, root) {
345
+ let cur = cls, guard = 0;
346
+ while (cur && guard++ < 64) {
347
+ if (cur === root) return true;
348
+ cur = localBaseClassOf(cur);
349
+ }
350
+ return false;
351
+ }
326
352
  function moduleOf(sf) {
327
353
  const rel = path.relative(rootDir, path.resolve(sf.fileName)).replace(/\.[mc]?[tj]sx?$/, "");
328
354
  return rel.split(path.sep).join(".");
@@ -467,19 +493,7 @@ for (const sf of sources) {
467
493
  {
468
494
  const memberName = (m) => (ts.isMethodDeclaration(m) || ts.isGetAccessorDeclaration(m)
469
495
  || ts.isSetAccessorDeclaration(m) || ts.isPropertyDeclaration(m)) && m.name?.getText?.();
470
- // Resolve `extends X` to X's LOCAL ClassDeclaration (through an import alias), or null.
471
- const baseClassOf = (cls) => {
472
- for (const h of cls.heritageClauses ?? []) {
473
- if (h.token !== ts.SyntaxKind.ExtendsKeyword) continue;
474
- const t = h.types?.[0];
475
- if (!t) continue;
476
- let sym = checker.getSymbolAtLocation(t.expression);
477
- if (sym && sym.flags & ts.SymbolFlags.Alias) { try { sym = checker.getAliasedSymbol(sym); } catch { /* keep */ } }
478
- const bd = (sym?.declarations ?? []).find((d) => ts.isClassDeclaration(d));
479
- if (bd && projectFiles.has(path.resolve(bd.getSourceFile().fileName))) return bd;
480
- }
481
- return null;
482
- };
496
+ const baseClassOf = localBaseClassOf;
483
497
  for (const sf of sources) {
484
498
  (function scan(node) {
485
499
  if (ts.isClassDeclaration(node)) {
@@ -563,8 +577,10 @@ function realDecl(sym) {
563
577
  // when the accessor's declaration lives in a project file (a UNIT we minted; edge to it). A resolved
564
578
  // accessor we CAN'T see (external/typed-only declaration) returns local:false so the caller follows
565
579
  // the existing Unknown/curated-κ posture — never silent-pure for a resolved-but-unseen accessor.
566
- function accessorAt(propNode, kind /* "get" | "set" */) {
567
- const sym = checker.getSymbolAtLocation(propNode.name ?? propNode);
580
+ // A property SYMBOL its accessor declaration of the wanted kind (or null). `local` is true when that
581
+ // declaration lives in a project file (a unit we minted; edge to it). Shared by every property-read
582
+ // shape: dot access, element access, and object destructuring.
583
+ function accessorFromSym(sym, kind /* "get" | "set" */) {
568
584
  if (!sym) return null;
569
585
  const want = kind === "get" ? ts.isGetAccessorDeclaration : ts.isSetAccessorDeclaration;
570
586
  // A symbol is an accessor only if its declarations include an accessor of the wanted kind.
@@ -572,6 +588,34 @@ function accessorAt(propNode, kind /* "get" | "set" */) {
572
588
  if (!decl) return null;
573
589
  return { decl, local: projectFiles.has(path.resolve(decl.getSourceFile().fileName)) };
574
590
  }
591
+ function accessorAt(propNode, kind /* "get" | "set" */) {
592
+ let sym;
593
+ if (ts.isElementAccessExpression(propNode)) {
594
+ // `c["prop"]` carries no `.name`; resolve the LITERAL key as a property on the receiver's type.
595
+ // A dynamic key (`c[k]`) can't be pinned to one property — leave it unresolved (the existing
596
+ // dynamic-access posture stands; resolving it would guess, never fabricate here).
597
+ const arg = propNode.argumentExpression;
598
+ sym = arg && ts.isStringLiteralLike(arg)
599
+ ? checker.getTypeAtLocation(propNode.expression)?.getProperty?.(arg.text)
600
+ : null;
601
+ } else {
602
+ sym = checker.getSymbolAtLocation(propNode.name ?? propNode);
603
+ }
604
+ return accessorFromSym(sym, kind);
605
+ }
606
+ // Record a resolved accessor HIT (read or write) as an edge from `owner`: into the accessor UNIT when
607
+ // it's a local declaration we minted; otherwise Unknown (a resolved-but-unseen accessor body — never
608
+ // silent-pure, SPEC §4). `label` tags the §-why disclosure.
609
+ function recordAccessorHit(owner, hit, label) {
610
+ const rec = fns.get(owner);
611
+ const t = nodeName.get(hit.decl);
612
+ if (hit.local && t) {
613
+ rec.edges.add(t); // (EDGE) into the accessor unit — effects propagate
614
+ } else {
615
+ rec.direct.add("Unknown");
616
+ rec.why.add(`accessor:${label}`);
617
+ }
618
+ }
575
619
 
576
620
  // nearest enclosing analyzed function (closures attribute to it — SEMANTICS §2)
577
621
  function enclosing(node) {
@@ -697,24 +741,48 @@ function visitCalls(node) {
697
741
  // receiver already resolved to the leaf (`new Dog()` -> Dog.speak, no overrides) so this is
698
742
  // inert there — no double-count. A base method NO subclass overrides has no entry: today's
699
743
  // behavior (just the base) is preserved exactly.
700
- const overrides = classOverrides.get(decl);
701
- if (overrides && overrides.length > 0) {
702
- if (overrides.length <= 12) {
703
- let allResolved = true;
704
- const oTargets = [];
705
- for (const om of overrides) {
706
- const ot = nodeName.get(om);
707
- if (ot) oTargets.push(ot);
708
- else allResolved = false;
709
- }
710
- for (const ot of oTargets) rec.edges.add(ot);
711
- if (!allResolved) {
712
- rec.direct.add("Unknown");
744
+ const allOverrides = classOverrides.get(decl);
745
+ if (allOverrides && allOverrides.length > 0) {
746
+ // PRECISION: scope the fan-out to the RECEIVER's static-type subtree. A base-member
747
+ // dispatch on a receiver statically typed as subclass `Cat` can only ever bind to a
748
+ // `Cat`-subtree body — a SIBLING `Dog.speak` override is type-impossible on this path,
749
+ // so propagating its effect over-reports on an unreachable receiver (fabrication-
750
+ // adjacent). When we can pin the receiver's static class (a property/element access
751
+ // whose receiver-expression type is a LOCAL class), keep only overrides whose owning
752
+ // class lies in that class's subtree; `viaBase(a: Animal)` keeps Dog (Dog ∈ Animal-
753
+ // subtree, the soundness edge), `noOverride(c: Cat)` drops Dog (Dog ∉ Cat-subtree) and
754
+ // stays pure. SOUNDNESS-PRESERVING FALLBACK: if the receiver's static class can't be
755
+ // pinned to a LOCAL class (no property access, a union/interface/`any` receiver, an
756
+ // external/unresolved type), we do NOT narrow — the full override set is kept, exactly
757
+ // the pre-precision behavior, so we never silently drop an effect we can't rule out.
758
+ let overrides = allOverrides;
759
+ const recvExpr = (ts.isPropertyAccessExpression(node.expression)
760
+ || ts.isElementAccessExpression(node.expression)) ? node.expression.expression : null;
761
+ if (recvExpr) {
762
+ const rt = checker.getTypeAtLocation(recvExpr);
763
+ const rootClass = (rt?.symbol?.declarations ?? []).find((d) =>
764
+ ts.isClassDeclaration(d) && projectFiles.has(path.resolve(d.getSourceFile().fileName)));
765
+ if (rootClass) overrides = allOverrides.filter((om) =>
766
+ ts.isClassDeclaration(om.parent) && classInSubtree(om.parent, rootClass));
767
+ }
768
+ if (overrides.length > 0) {
769
+ if (overrides.length <= 12) {
770
+ let allResolved = true;
771
+ const oTargets = [];
772
+ for (const om of overrides) {
773
+ const ot = nodeName.get(om);
774
+ if (ot) oTargets.push(ot);
775
+ else allResolved = false;
776
+ }
777
+ for (const ot of oTargets) rec.edges.add(ot);
778
+ if (!allResolved) {
779
+ rec.direct.add("Unknown");
780
+ rec.why.add(`override:${decl.name?.getText?.() ?? "member"}`);
781
+ }
782
+ } else {
783
+ rec.direct.add("Unknown"); // override family too wide to enumerate soundly
713
784
  rec.why.add(`override:${decl.name?.getText?.() ?? "member"}`);
714
785
  }
715
- } else {
716
- rec.direct.add("Unknown"); // override family too wide to enumerate soundly
717
- rec.why.add(`override:${decl.name?.getText?.() ?? "member"}`);
718
786
  }
719
787
  }
720
788
  // record what each argument position received (callback-flow, see callbackArgs)
@@ -949,16 +1017,30 @@ function visitCalls(node) {
949
1017
  if (hit) {
950
1018
  const owner = enclosing(node);
951
1019
  if (owner) {
952
- const rec = fns.get(owner);
953
- const t = nodeName.get(hit.decl);
954
- if (hit.local && t) {
955
- rec.edges.add(t); // (EDGE) into the accessor unit — effects propagate
956
- } else {
957
- // resolved to an accessor whose body we can't see → Unknown, never silent-pure (SPEC §4)
958
- rec.direct.add("Unknown");
959
- const an = hit.decl.parent?.name?.getText?.() ?? "?";
960
- rec.why.add(`accessor:${an}.${node.name?.getText?.() ?? node.argumentExpression?.getText?.() ?? "?"}`);
961
- }
1020
+ const an = hit.decl.parent?.name?.getText?.() ?? "?";
1021
+ const pn = node.name?.getText?.() ?? node.argumentExpression?.getText?.() ?? "?";
1022
+ recordAccessorHit(owner, hit, `${an}.${pn}`);
1023
+ }
1024
+ }
1025
+ }
1026
+ // OBJECT-DESTRUCTURING getter read (`const { prop } = obj`): each bound property is a READ that may
1027
+ // resolve to a getter whose body does I/O — the binding-pattern analog of `obj.prop`, invisible to
1028
+ // the property-access arm above because there is no PropertyAccess/ElementAccess node. (ARRAY
1029
+ // destructuring is ITERATION, handled below; object destructuring copies named own/inherited props,
1030
+ // invoking each getter.) Resolve every bound key as a property on the initializer's type; a rest
1031
+ // element / computed key can't be pinned to one accessor, so it's skipped (no fabrication).
1032
+ if (ts.isVariableDeclaration(node) && ts.isObjectBindingPattern(node.name) && node.initializer) {
1033
+ const owner = enclosing(node);
1034
+ if (owner) {
1035
+ const recvType = checker.getTypeAtLocation(node.initializer);
1036
+ for (const el of node.name.elements) {
1037
+ if (el.dotDotDotToken) continue; // `...rest` collects the remaining props, not one getter
1038
+ const key = el.propertyName ?? el.name; // `{prop}` shorthand, or `{prop: alias}`
1039
+ const keyName = ts.isIdentifier(key) ? key.text
1040
+ : ts.isStringLiteralLike(key) ? key.text : null;
1041
+ if (keyName === null) continue; // computed key (`{[k]: v}`) — unresolvable to one property
1042
+ const hit = accessorFromSym(recvType?.getProperty?.(keyName), "get");
1043
+ if (hit) recordAccessorHit(owner, hit, keyName);
962
1044
  }
963
1045
  }
964
1046
  }