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 +17 -3
- package/package.json +1 -1
- package/scan-core.mjs +8 -0
- package/scan.mjs +56 -12
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
|
@@ -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
|
-
|
|
581
|
-
|
|
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
|
|
991
|
-
const
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
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
|
}
|