candor-ts 0.5.5 → 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.5",
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
@@ -577,8 +577,10 @@ function realDecl(sym) {
577
577
  // when the accessor's declaration lives in a project file (a UNIT we minted; edge to it). A resolved
578
578
  // accessor we CAN'T see (external/typed-only declaration) returns local:false so the caller follows
579
579
  // 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);
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" */) {
582
584
  if (!sym) return null;
583
585
  const want = kind === "get" ? ts.isGetAccessorDeclaration : ts.isSetAccessorDeclaration;
584
586
  // A symbol is an accessor only if its declarations include an accessor of the wanted kind.
@@ -586,6 +588,34 @@ function accessorAt(propNode, kind /* "get" | "set" */) {
586
588
  if (!decl) return null;
587
589
  return { decl, local: projectFiles.has(path.resolve(decl.getSourceFile().fileName)) };
588
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
+ }
589
619
 
590
620
  // nearest enclosing analyzed function (closures attribute to it — SEMANTICS §2)
591
621
  function enclosing(node) {
@@ -987,16 +1017,30 @@ function visitCalls(node) {
987
1017
  if (hit) {
988
1018
  const owner = enclosing(node);
989
1019
  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
- }
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);
1000
1044
  }
1001
1045
  }
1002
1046
  }