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 +17 -3
- package/package.json +1 -1
- package/scan-core.mjs +8 -0
- package/scan.mjs +123 -41
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(
|
|
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
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
|
-
|
|
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
|
-
|
|
567
|
-
|
|
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
|
|
701
|
-
if (
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
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
|
|
953
|
-
const
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
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
|
}
|