candor-ts 0.5.5 → 0.5.7

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.
Files changed (4) hide show
  1. package/mcp.mjs +17 -3
  2. package/package.json +1 -1
  3. package/scan-core.mjs +13 -1
  4. package/scan.mjs +134 -16
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.5",
3
+ "version": "0.5.7",
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
@@ -52,7 +60,11 @@ export const KAPPA_RULES = [
52
60
  // entropy: node:crypto's random surface + the password-hashing libs (salted -> Rand). Found by
53
61
  // the CTA dogfood on a Nest app: argon2.hash came out SILENTLY PURE (the curated-kappa caveat
54
62
  // landing on exactly the call a security review cares about).
55
- [/^(node:)?crypto$/, /^random/, "Rand"],
63
+ [/^(node:)?crypto$/, /^(random|getRandomValues)/, "Rand"],
64
+ // node:os identity reads — userInfo (the OS user record) and hostname (the machine name) are
65
+ // environment/host reads (Env), like System.getenv's host-identity cousins. The rest of node:os
66
+ // (platform/arch/cpus/totalmem/…) is inert host introspection, left pure.
67
+ [/^(node:)?os$/, /^(userInfo|hostname)$/, "Env"],
56
68
  [/^(argon2|bcrypt|bcryptjs)$/, null, "Rand"],
57
69
  // The ORM tier — VERB-PRECISE (the CLASSIFIER discipline: tag the execution boundary, not
58
70
  // builders; `createQueryBuilder` is pure, its `getMany`/`execute` is the I/O). Found on the
package/scan.mjs CHANGED
@@ -242,7 +242,12 @@ const sources = program.getSourceFiles().filter((f) => projectFiles.has(path.res
242
242
  function declModule(decl) {
243
243
  const f = path.resolve(decl.getSourceFile().fileName);
244
244
  if (projectFiles.has(f)) return "<local>";
245
- let m = f.match(/@types\/node\/(\w+?)\.d\.ts$/);
245
+ // `(.+?)` not `(\w+?)`: a SUBPATH typing (`@types/node/fs/promises.d.ts`, `dns/promises.d.ts`) carries
246
+ // a `/` that `\w` can't cross, so the module collapsed to `@types/node` (via the node_modules branch
247
+ // below) and the `fs(\/promises)?` / `dns(\/promises)?` κ rules — written to cover exactly these — could
248
+ // never fire (`fs/promises` is the dominant modern Node FS API: a silent-pure under-report). Keep the
249
+ // slash so the module reads `fs/promises`, which the rules match.
250
+ let m = f.match(/@types\/node\/(.+?)\.d\.ts$/);
246
251
  if (m) return m[1];
247
252
  if (/typescript\/lib\/lib\..*\.d\.ts$/.test(f)) return "<es-lib>";
248
253
  m = f.match(/node_modules\/(@[^/]+\/[^/]+|[^/]+)\//);
@@ -577,8 +582,10 @@ function realDecl(sym) {
577
582
  // when the accessor's declaration lives in a project file (a UNIT we minted; edge to it). A resolved
578
583
  // accessor we CAN'T see (external/typed-only declaration) returns local:false so the caller follows
579
584
  // the existing Unknown/curated-κ posture — never silent-pure for a resolved-but-unseen accessor.
580
- function accessorAt(propNode, kind /* "get" | "set" */) {
581
- const sym = checker.getSymbolAtLocation(propNode.name ?? propNode);
585
+ // A property SYMBOL its accessor declaration of the wanted kind (or null). `local` is true when that
586
+ // declaration lives in a project file (a unit we minted; edge to it). Shared by every property-read
587
+ // shape: dot access, element access, and object destructuring.
588
+ function accessorFromSym(sym, kind /* "get" | "set" */) {
582
589
  if (!sym) return null;
583
590
  const want = kind === "get" ? ts.isGetAccessorDeclaration : ts.isSetAccessorDeclaration;
584
591
  // A symbol is an accessor only if its declarations include an accessor of the wanted kind.
@@ -586,10 +593,59 @@ function accessorAt(propNode, kind /* "get" | "set" */) {
586
593
  if (!decl) return null;
587
594
  return { decl, local: projectFiles.has(path.resolve(decl.getSourceFile().fileName)) };
588
595
  }
596
+ function accessorAt(propNode, kind /* "get" | "set" */) {
597
+ let sym;
598
+ if (ts.isElementAccessExpression(propNode)) {
599
+ // `c["prop"]` carries no `.name`; resolve the LITERAL key as a property on the receiver's type.
600
+ // A dynamic key (`c[k]`) can't be pinned to one property — leave it unresolved (the existing
601
+ // dynamic-access posture stands; resolving it would guess, never fabricate here).
602
+ const arg = propNode.argumentExpression;
603
+ sym = arg && ts.isStringLiteralLike(arg)
604
+ ? checker.getTypeAtLocation(propNode.expression)?.getProperty?.(arg.text)
605
+ : null;
606
+ } else {
607
+ sym = checker.getSymbolAtLocation(propNode.name ?? propNode);
608
+ }
609
+ return accessorFromSym(sym, kind);
610
+ }
611
+ // Record a resolved accessor HIT (read or write) as an edge from `owner`: into the accessor UNIT when
612
+ // it's a local declaration we minted; otherwise Unknown (a resolved-but-unseen accessor body — never
613
+ // silent-pure, SPEC §4). `label` tags the §-why disclosure.
614
+ function recordAccessorHit(owner, hit, label) {
615
+ const rec = fns.get(owner);
616
+ const t = nodeName.get(hit.decl);
617
+ if (hit.local && t) {
618
+ rec.edges.add(t); // (EDGE) into the accessor unit — effects propagate
619
+ } else {
620
+ rec.direct.add("Unknown");
621
+ rec.why.add(`accessor:${label}`);
622
+ }
623
+ }
624
+
625
+ // Object PROPERTY-ENUMERATION (`{...obj}`, `const {...rest} = obj`, `Object.assign(t, obj)`): copying an
626
+ // object's own enumerable props INVOKES each source getter — the whole-object analog of `obj.prop`,
627
+ // invisible to the property-access arm (no PropertyAccess node per key). Edge `owner` to every LOCAL
628
+ // getter on the source type. A rest/spread can't name one key, so ALL getters are enumerated (sound
629
+ // over-approximation); a plain prop resolves to no accessor and adds nothing (no fabrication).
630
+ function enumerateGetters(owner, type) {
631
+ if (!owner || !type || !type.getProperties) return;
632
+ for (const p of type.getProperties()) {
633
+ const hit = accessorFromSym(p, "get");
634
+ if (hit) recordAccessorHit(owner, hit, p.getName());
635
+ }
636
+ }
589
637
 
590
638
  // nearest enclosing analyzed function (closures attribute to it — SEMANTICS §2)
591
639
  function enclosing(node) {
592
640
  for (let p = node; p; p = p.parent) {
641
+ // A call/effect lexically inside a DECORATOR (`@factory(arg)`) runs at class-DEFINITION time, NOT in
642
+ // the decorated declaration's body. The parent chain of a decorator's expression is
643
+ // CallExpression → Decorator → MethodDeclaration/ClassDeclaration/Parameter, so `enclosing` otherwise
644
+ // lands on the decorated unit and FABRICATES the factory's effects onto that method/class/param and
645
+ // every transitive caller (a cardinal sin — @Entity/@Injectable factories that touch I/O would
646
+ // poison every decorated handler). Stop at the Decorator: the factory's own effects live in its own
647
+ // function unit; the application site attributes to nothing (load-time, like a no-arg decorator).
648
+ if (ts.isDecorator(p)) return null;
593
649
  const n = nodeName.get(p);
594
650
  if (n) return n;
595
651
  }
@@ -697,6 +753,22 @@ function visitCalls(node) {
697
753
  }
698
754
  } else {
699
755
  const mod = declModule(decl);
756
+ // A LOCAL function/method passed BY REFERENCE to a NON-LOCAL (opaque) callee — `xs.map(loadFree)`,
757
+ // `arr.forEach(this.m)`, `setTimeout(handler)`, `external(cb)` — may be INVOKED by that callee, so
758
+ // its effects are reachable here. The precise callback-flow below only resolves a LOCAL callee's
759
+ // params; a non-local HOF dropped the reference entirely (a silent-pure hole — confirmed for
760
+ // `map`/`forEach`/`reduce`). Edge to the referenced unit: the sound over-approximation, matching
761
+ // the Rust engine's fn-as-value edge. An inline closure is already charged lexically; a non-fn
762
+ // argument resolves to no minted unit (`nodeName` miss) and adds nothing — no fabrication. Gated
763
+ // on a non-local callee so a local callee that merely STORES (never invokes) keeps its precision.
764
+ if (mod !== "<local>") {
765
+ for (const a of node.arguments ?? []) {
766
+ if (!ts.isIdentifier(a) && !ts.isPropertyAccessExpression(a)) continue;
767
+ const d2 = realDecl(checker.getSymbolAtLocation(a));
768
+ const t = d2 && nodeName.get(d2);
769
+ if (t) rec.edges.add(t);
770
+ }
771
+ }
700
772
  if (mod === "<local>") {
701
773
  const targetName = nodeName.get(decl);
702
774
  if (targetName) {
@@ -971,6 +1043,34 @@ function visitCalls(node) {
971
1043
  const owner = enclosing(node);
972
1044
  if (owner) fns.get(owner).direct.add("Env");
973
1045
  }
1046
+ // Runtime GLOBALS reached as CALLS with no import for the κ resolver to classify: `process.hrtime()`/
1047
+ // `.hrtime.bigint()` is a monotonic clock read (Clock); `process.send(...)` is the child↔parent IPC
1048
+ // channel (Ipc); the global `fetch(...)` is the standard modern HTTP client (Net). Matched on the
1049
+ // callee — `process.*` by exact text (mirroring the process.env match), `fetch` by identifier whose
1050
+ // symbol is NOT a local declaration (so a project's own `fetch` shadow never fabricates Net).
1051
+ if (ts.isCallExpression(node)) {
1052
+ const callee = node.expression;
1053
+ const ctext = callee.getText().replace(/\s+/g, "");
1054
+ let geff = null;
1055
+ if (ctext === "process.hrtime" || ctext === "process.hrtime.bigint") geff = "Clock";
1056
+ else if (ctext === "process.send") geff = "Ipc";
1057
+ else if (ts.isIdentifier(callee) && callee.text === "fetch"
1058
+ && !(checker.getSymbolAtLocation(callee)?.declarations ?? [])
1059
+ .some((d) => projectFiles.has(path.resolve(d.getSourceFile().fileName))))
1060
+ geff = "Net";
1061
+ if (geff) {
1062
+ const owner = enclosing(node);
1063
+ if (owner) fns.get(owner).direct.add(geff);
1064
+ }
1065
+ // Object.assign(target, ...sources) copies each SOURCE's own enumerable props → invokes their
1066
+ // getters (the object-spread twin). Enumerate the sources' local getters.
1067
+ if (callee.getText().replace(/\s+/g, "") === "Object.assign") {
1068
+ const owner = enclosing(node);
1069
+ for (const src of (node.arguments ?? []).slice(1)) {
1070
+ enumerateGetters(owner, checker.getTypeAtLocation(src));
1071
+ }
1072
+ }
1073
+ }
974
1074
  // GET/SET ACCESSOR access (the silent-pure-accessor fix): a property read that resolves to a
975
1075
  // getter, or a property assignment whose target resolves to a setter, is effectively a call into
976
1076
  // the accessor body — model it as a call EDGE so the accessor's effects propagate (like a method
@@ -987,16 +1087,31 @@ function visitCalls(node) {
987
1087
  if (hit) {
988
1088
  const owner = enclosing(node);
989
1089
  if (owner) {
990
- const rec = fns.get(owner);
991
- const t = nodeName.get(hit.decl);
992
- if (hit.local && t) {
993
- rec.edges.add(t); // (EDGE) into the accessor unit — effects propagate
994
- } else {
995
- // resolved to an accessor whose body we can't see → Unknown, never silent-pure (SPEC §4)
996
- rec.direct.add("Unknown");
997
- const an = hit.decl.parent?.name?.getText?.() ?? "?";
998
- rec.why.add(`accessor:${an}.${node.name?.getText?.() ?? node.argumentExpression?.getText?.() ?? "?"}`);
999
- }
1090
+ const an = hit.decl.parent?.name?.getText?.() ?? "?";
1091
+ const pn = node.name?.getText?.() ?? node.argumentExpression?.getText?.() ?? "?";
1092
+ recordAccessorHit(owner, hit, `${an}.${pn}`);
1093
+ }
1094
+ }
1095
+ }
1096
+ // OBJECT-DESTRUCTURING getter read (`const { prop } = obj`): each bound property is a READ that may
1097
+ // resolve to a getter whose body does I/O — the binding-pattern analog of `obj.prop`, invisible to
1098
+ // the property-access arm above because there is no PropertyAccess/ElementAccess node. (ARRAY
1099
+ // destructuring is ITERATION, handled below; object destructuring copies named own/inherited props,
1100
+ // invoking each getter.) Resolve every bound key as a property on the initializer's type; a rest
1101
+ // element / computed key can't be pinned to one accessor, so it's skipped (no fabrication).
1102
+ if (ts.isVariableDeclaration(node) && ts.isObjectBindingPattern(node.name) && node.initializer) {
1103
+ const owner = enclosing(node);
1104
+ if (owner) {
1105
+ const recvType = checker.getTypeAtLocation(node.initializer);
1106
+ for (const el of node.name.elements) {
1107
+ if (el.dotDotDotToken) { enumerateGetters(owner, recvType); continue; } // `...rest` copies every
1108
+ // remaining prop → invokes every (remaining) getter; enumerate all (the bound ones double-handle).
1109
+ const key = el.propertyName ?? el.name; // `{prop}` shorthand, or `{prop: alias}`
1110
+ const keyName = ts.isIdentifier(key) ? key.text
1111
+ : ts.isStringLiteralLike(key) ? key.text : null;
1112
+ if (keyName === null) continue; // computed key (`{[k]: v}`) — unresolvable to one property
1113
+ const hit = accessorFromSym(recvType?.getProperty?.(keyName), "get");
1114
+ if (hit) recordAccessorHit(owner, hit, keyName);
1000
1115
  }
1001
1116
  }
1002
1117
  }
@@ -1008,9 +1123,12 @@ function visitCalls(node) {
1008
1123
  let iterExpr = null, iterAsync = false;
1009
1124
  if (ts.isForOfStatement(node)) { iterExpr = node.expression; iterAsync = !!node.awaitModifier; }
1010
1125
  else if (ts.isSpreadElement(node)) iterExpr = node.expression; // [...bag] / f(...bag)
1011
- else if (ts.isSpreadAssignment(node)) iterExpr = node.expression; // {...bag} — object spread is NOT
1012
- // iteration (it copies own enumerable props, no [Symbol.iterator]); wellKnownSymbolMember simply
1013
- // finds none and edges nothing. Listed for clarity; the resolution self-guards.
1126
+ else if (ts.isSpreadAssignment(node)) {
1127
+ iterExpr = node.expression; // {...bag} object spread is NOT iteration (copies own enumerable
1128
+ // props, no [Symbol.iterator]); wellKnownSymbolMember finds none and edges nothing for iteration.
1129
+ // But the copy DOES invoke each source getter — enumerate them (the silent-pure object-spread hole).
1130
+ enumerateGetters(enclosing(node), checker.getTypeAtLocation(node.expression));
1131
+ }
1014
1132
  else if (ts.isVariableDeclaration(node) && ts.isArrayBindingPattern(node.name) && node.initializer)
1015
1133
  iterExpr = node.initializer; // const [a] = bag
1016
1134
  else if (ts.isCallExpression(node) && node.arguments?.[0]