candor-ts 0.11.0 → 0.13.0
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/AGENTS.md +9 -4
- package/README.md +2 -2
- package/lsp.mjs +145 -8
- package/mcp.mjs +136 -18
- package/package.json +2 -2
- package/policy.mjs +14 -5
- package/query-core.mjs +41 -8
- package/query.mjs +32 -6
- package/scan-core.mjs +65 -0
- package/scan.mjs +122 -16
- package/surface.mjs +1 -1
package/AGENTS.md
CHANGED
|
@@ -12,7 +12,7 @@ chains by hand.
|
|
|
12
12
|
> **Already installed? Report the version and ask before upgrading — before you scan.** If this
|
|
13
13
|
> project already has candor (a `.candor/` report directory, or `candor-ts` reachable via `npx`/a
|
|
14
14
|
> global install), do this *first*: run `npx -y candor-ts --version` (offline) and **tell the user
|
|
15
|
-
> plainly which version they're on** — e.g. *"This project is on candor-ts `<version>` (spec 0.
|
|
15
|
+
> plainly which version they're on** — e.g. *"This project is on candor-ts `<version>` (spec 0.13)."*
|
|
16
16
|
> On a build too old for the flag, read `candor.version` / `candor.spec` from an existing
|
|
17
17
|
> `.candor/report*.json`, or `npm ls -g candor-ts`.
|
|
18
18
|
>
|
|
@@ -86,7 +86,7 @@ downgraded to `Unknown` rather than silently trusted (spec §2.1). Caveat: a typ
|
|
|
86
86
|
## Query it (same names/shapes as the Rust and JVM engines — candor-spec §3.1)
|
|
87
87
|
|
|
88
88
|
```sh
|
|
89
|
-
Q() { npx -y candor-ts-query "$@"; }; P=".candor/report" # a function — works in bash AND zsh
|
|
89
|
+
Q() { npx -y -p candor-ts candor-ts-query "$@"; }; P=".candor/report" # a function — works in bash AND zsh
|
|
90
90
|
Q show $P <fn-query> 1 # a function's effects (+ hosts/tables when visible)
|
|
91
91
|
Q where $P <Effect> 1 # {effect, directly, inherited}
|
|
92
92
|
Q impact $P <fn-query> # THE BLAST RADIUS: {fn, affectedCount, affected, entryPoints}
|
|
@@ -110,9 +110,14 @@ Q parsepolicy <policy-file> # the canonical §6.2 parse (what the gate w
|
|
|
110
110
|
And as an MCP server, so an agent pulls these as tools instead of shelling out:
|
|
111
111
|
`CANDOR_REPORT=$P npx -y candor-ts-mcp` (tools `candor_impact`/`candor_reachable`/`candor_where`/…,
|
|
112
112
|
plus `candor_gate`/`candor_whatif`/`candor_fix` — a given-but-unreadable `policy` is a loud tool
|
|
113
|
-
error, never a clean verdict
|
|
113
|
+
error, never a clean verdict — and `candor_activity`: what the edit-time gate CAUGHT, measured from
|
|
114
|
+
`.candor/activity.jsonl` — edits checked, verdicts, violations by AS-EFF code, effects introduced,
|
|
115
|
+
largest blast radius, deepest propagation — so you can self-inspect the loop without shelling out;
|
|
116
|
+
a missing log is an empty result, not an error). `npx -y candor-ts-watch <dir>` keeps the report fresh as you edit (and
|
|
114
117
|
reports the edit-delta); `candor-lsp` serves the same report as CodeLens/hover/diagnostics in any LSP
|
|
115
|
-
editor
|
|
118
|
+
editor (and TAILS `.candor/activity.jsonl`: a new blocked gate record pushes the delta — gained
|
|
119
|
+
effects, blast radius, deepest propagation, the AS-EFF cause — as a showMessage + a transient
|
|
120
|
+
diagnostic on the edited files, cleared by the next clean record; `CANDOR_LSP_ACTIVITY=off` disables), plus two code actions (plain LSP — helix/neovim/VS Code/JetBrains-via-LSP4IJ all get them
|
|
116
121
|
without client code): the pre-edit whatif (`candor: what if <fn> performed <E>?` → the `candor.whatif`
|
|
117
122
|
command) and, when the cursor sits in a function that actually violates the policy, the boundary FIX
|
|
118
123
|
(`candor fix: hoist <E> out of <fn>` → the `candor.fix` command: where the effect belongs + the hoist
|
package/README.md
CHANGED
|
@@ -184,7 +184,7 @@ pure-vs-Unknown ruling (PART 16) — the engines must answer identically, on eve
|
|
|
184
184
|
| A call resolving to a *type* (function-typed field/param) → `Unknown`, never silent-pure | SPEC §4 |
|
|
185
185
|
| Unmatched external calls contribute nothing (curated-classifier caveat) | SEMANTICS §8 C1 |
|
|
186
186
|
| The literal surfaces `hosts`/`cmds`/`paths`/`tables`, literal-read only | SPEC §2 |
|
|
187
|
-
| `{ candor: { version, toolchain, spec: "0.
|
|
187
|
+
| `{ candor: { version, toolchain, spec: "0.13" }, functions }` envelope; pure fns omitted | SPEC §2/§2.1 |
|
|
188
188
|
| Call-graph sidecar with **every** analyzed function a key | SPEC §2.2 |
|
|
189
189
|
| The gate: AS-EFF-006 / 008 / 009, loud on an unreadable policy | SPEC §6.2 |
|
|
190
190
|
|
|
@@ -202,7 +202,7 @@ read the Rust source".
|
|
|
202
202
|
|
|
203
203
|
## Status
|
|
204
204
|
|
|
205
|
-
0.
|
|
205
|
+
0.13.x, speaking candor-spec 0.13: the analysis core, the gate (`--policy` / `--gate-json` /
|
|
206
206
|
`.candor/config`), the full §3.1 query surface (including `containment`, `blindspots`, the
|
|
207
207
|
`--include-unknown` dispatch frontier), the MCP server, the LSP server, and the watch loop are
|
|
208
208
|
real, behaviorally tested (`npm test` — the behavioral suite across six harnesses), **soundness-fuzzed
|
package/lsp.mjs
CHANGED
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
import fs from "node:fs";
|
|
44
44
|
import { createRequire } from "node:module";
|
|
45
45
|
import nodePath from "node:path";
|
|
46
|
-
import { fileURLToPath } from "node:url";
|
|
46
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
47
47
|
import * as Q from "./query-core.mjs";
|
|
48
48
|
import { discoverConfigPolicy, evaluatePolicy, parsePolicy, scopeMatches } from "./policy.mjs";
|
|
49
49
|
|
|
@@ -58,6 +58,118 @@ let reportPrefix = process.env.CANDOR_REPORT || process.argv[2] || null;
|
|
|
58
58
|
|
|
59
59
|
const hasReport = Q.hasReport; // single-sourced with the loader predicate (query-core) — see mcp.mjs
|
|
60
60
|
|
|
61
|
+
// ---- the activity push (AGENT-SURFACE-DESIGN.md P2) --------------------------------------------------
|
|
62
|
+
// The Stop hook / standalone reviews append to .candor/activity.jsonl (lib-candor-summary.sh's pinned
|
|
63
|
+
// record shape); the LSP tails it and surfaces each new BLOCKED record in-editor — the same payload the
|
|
64
|
+
// hook shows the agent, pushed to the human. This is the LSP's ONE watcher (everything else stays
|
|
65
|
+
// re-read-per-request): a small stat poll, unref'd so it never holds the process open, off-switchable
|
|
66
|
+
// (CANDOR_LSP_ACTIVITY=off). Only records appended AFTER startup push (no history replay); a SHRUNKEN
|
|
67
|
+
// log (the writer's cap trim-rewrite, or a rotation) skips to its end — never replays; a partial
|
|
68
|
+
// trailing line waits for its newline; corrupt lines are skipped.
|
|
69
|
+
let activityLog = null, activityOffset = 0, activityTimer = null;
|
|
70
|
+
// The activity gate overlay has its OWN store, SEPARATE from the whatif/fix `transient` map: the two
|
|
71
|
+
// are set by different actors (the tailer vs the client's executeCommand) and clear on different events
|
|
72
|
+
// (next clean record vs the file's next didOpen/didSave/didChange) — sharing one map let a blocked
|
|
73
|
+
// record clobber a live whatif overlay, and a clean record delete an unrelated whatif set afterwards.
|
|
74
|
+
// Keys are canonicalDocKey() paths, NOT uri strings: the setter's path is server-computed while the
|
|
75
|
+
// clearer's comes from the client's uri, and the two encodings diverge (Windows drive-case/%3A,
|
|
76
|
+
// symlinked workspaces) — a string-keyed overlay wedged, uncleanable by any didOpen/didSave.
|
|
77
|
+
const activityTransient = new Map(); // canonical doc key -> Diagnostic[] (the gate overlay)
|
|
78
|
+
const activityOverlaid = new Set(); // canonical doc keys carrying a gate overlay — cleared on the next clean record
|
|
79
|
+
// One canonical key for the activity overlay maps: the RESOLVED filesystem path — realpath when the
|
|
80
|
+
// file exists (symlinked workspaces: /var vs /private/var), case-folded on win32 (drive-letter case).
|
|
81
|
+
// Both the server-computed side (activity records' `edited` paths) and the client side (didOpen/
|
|
82
|
+
// didSave uris, via fileURLToPath) funnel through this, so an encoding divergence cannot wedge the
|
|
83
|
+
// overlay. The whatif/fix `transient` map deliberately does NOT get this treatment: its keys are only
|
|
84
|
+
// ever CLIENT-supplied uris on both sides (codeAction arguments echo the client's own uri back into
|
|
85
|
+
// executeCommand, and the clear reads the same client field), so set and clear already agree
|
|
86
|
+
// byte-for-byte — canonicalizing there would be motion without a divergence to fix.
|
|
87
|
+
function canonicalDocKey(p) {
|
|
88
|
+
let abs = nodePath.resolve(p);
|
|
89
|
+
try { abs = fs.realpathSync.native(abs); } catch { /* not on disk (yet) — resolve() is the best we have */ }
|
|
90
|
+
return process.platform === "win32" ? abs.toLowerCase() : abs;
|
|
91
|
+
}
|
|
92
|
+
function startActivityWatch() {
|
|
93
|
+
if ((process.env.CANDOR_LSP_ACTIVITY || "").toLowerCase() === "off") return;
|
|
94
|
+
const dir = rootPath ? nodePath.join(rootPath, ".candor")
|
|
95
|
+
: reportPrefix ? nodePath.dirname(reportPrefix) : null;
|
|
96
|
+
if (!dir) return;
|
|
97
|
+
activityLog = nodePath.join(dir, "activity.jsonl");
|
|
98
|
+
try { activityOffset = fs.statSync(activityLog).size; } catch { activityOffset = 0; }
|
|
99
|
+
const ms = Math.max(50, parseInt(process.env.CANDOR_LSP_ACTIVITY_POLL_MS || "2000", 10) || 2000);
|
|
100
|
+
activityTimer = setInterval(pollActivity, ms);
|
|
101
|
+
activityTimer.unref();
|
|
102
|
+
}
|
|
103
|
+
function pollActivity() {
|
|
104
|
+
let size;
|
|
105
|
+
try { size = fs.statSync(activityLog).size; } catch { return; } // absent — keep waiting
|
|
106
|
+
if (size < activityOffset) {
|
|
107
|
+
// Shrunk — NOT an exceptional rotation: the writer (lib-candor-summary.sh candor_log_activity)
|
|
108
|
+
// rewrites the log via tail+mv on EVERY append once past its line cap, so past that point every
|
|
109
|
+
// poll sees a smaller file. Restarting the tail at 0 replayed the whole trimmed rewrite (~cap
|
|
110
|
+
// lines) each poll — a showMessage flood of historical blocked records. Skip to the END instead:
|
|
111
|
+
// the rewrite's tail is overwhelmingly history we already pushed. Trade-off, made deliberately —
|
|
112
|
+
// we may MISS the few genuinely-new records that arrived in the same rewrite, and that beats
|
|
113
|
+
// flooding the editor with thousands of stale ones.
|
|
114
|
+
activityOffset = size;
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (size === activityOffset) return;
|
|
118
|
+
let text;
|
|
119
|
+
try {
|
|
120
|
+
const fd = fs.openSync(activityLog, "r");
|
|
121
|
+
const buf = Buffer.alloc(size - activityOffset);
|
|
122
|
+
fs.readSync(fd, buf, 0, buf.length, activityOffset);
|
|
123
|
+
fs.closeSync(fd);
|
|
124
|
+
text = buf.toString("utf8");
|
|
125
|
+
} catch { return; }
|
|
126
|
+
activityOffset = size;
|
|
127
|
+
const lastNl = text.lastIndexOf("\n");
|
|
128
|
+
if (lastNl < 0) { activityOffset -= Buffer.byteLength(text); return; } // mid-write — retry next poll
|
|
129
|
+
if (lastNl < text.length - 1) { activityOffset -= Buffer.byteLength(text.slice(lastNl + 1)); text = text.slice(0, lastNl + 1); }
|
|
130
|
+
for (const l of text.split("\n")) {
|
|
131
|
+
if (!l.trim()) continue;
|
|
132
|
+
let r; try { r = JSON.parse(l); } catch { continue; } // corrupt line — skipped, like stats
|
|
133
|
+
if (r && typeof r === "object" && !Array.isArray(r)) onActivityRecord(r);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
function onActivityRecord(r) {
|
|
137
|
+
if (r.verdict === "clean") {
|
|
138
|
+
// the gate went green again — drop the GATE overlays only (a live whatif/fix overlay on the same
|
|
139
|
+
// file is the client's own question, not the gate's — it clears on the file's next open/save/edit,
|
|
140
|
+
// never here). The message noise stays hook-side; quiet here.
|
|
141
|
+
for (const key of activityOverlaid) { activityTransient.delete(key); publishDiagnostics(pathToFileURL(key).href); }
|
|
142
|
+
activityOverlaid.clear();
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (r.verdict !== "blocked") return; // setup records aren't editor events
|
|
146
|
+
const parts = [];
|
|
147
|
+
if (Array.isArray(r.gained) && r.gained.length) parts.push(`introduces {${r.gained.join(", ")}}`);
|
|
148
|
+
if (Number.isInteger(r.blastRadius) && r.blastRadius > 0) parts.push(`blast radius ${r.blastRadius} fn(s)`);
|
|
149
|
+
if (Number.isInteger(r.maxHops)) parts.push(`deepest propagation ${r.maxHops} hop(s)`);
|
|
150
|
+
const codes = Array.isArray(r.violations) && r.violations.length ? ` [${r.violations.join(", ")}]` : "";
|
|
151
|
+
const msg = `candor gate: blocked — ${parts.join("; ") || "see the review output"}${codes}`;
|
|
152
|
+
showMessage(2, msg);
|
|
153
|
+
// pin the delta to the edited files as the gate's own transient overlay (activityTransient — cleared
|
|
154
|
+
// on the file's next open/save, or by the next clean record above; a whatif/fix overlay on the same
|
|
155
|
+
// file coexists rather than being overwritten). Hook records carry `edited`; standalone records have
|
|
156
|
+
// edited=null — the showMessage above is then the whole push.
|
|
157
|
+
for (const p of Array.isArray(r.edited) ? r.edited : []) {
|
|
158
|
+
if (typeof p !== "string" || !p) continue;
|
|
159
|
+
let key, uri;
|
|
160
|
+
try {
|
|
161
|
+
key = canonicalDocKey(nodePath.resolve(rootPath ?? process.cwd(), p));
|
|
162
|
+
uri = pathToFileURL(key).href;
|
|
163
|
+
} catch { continue; }
|
|
164
|
+
activityTransient.set(key, [{
|
|
165
|
+
range: { start: { line: 0, character: 0 }, end: { line: 0, character: 200 } },
|
|
166
|
+
severity: 2, source: "candor", code: "gate", message: msg,
|
|
167
|
+
}]);
|
|
168
|
+
activityOverlaid.add(key);
|
|
169
|
+
publishDiagnostics(uri);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
61
173
|
// ---- fn → document mapping --------------------------------------------------------------------------
|
|
62
174
|
// A report `loc` is `<file>:<line>[:col…]` where <file> is either a repo-relative PATH (the scan-source
|
|
63
175
|
// engines) or a BARE filename (JVM bytecode SourceFile) — for the bare form the path is rebuilt from the
|
|
@@ -205,7 +317,13 @@ function publishDiagnostics(uri) {
|
|
|
205
317
|
let docPath;
|
|
206
318
|
try { docPath = fileURLToPath(uri); } catch { return; }
|
|
207
319
|
try {
|
|
208
|
-
|
|
320
|
+
// three layers, merged: the standing gate verdict + the client's whatif/fix overlay (keyed by the
|
|
321
|
+
// client's uri string) + the activity gate overlay (keyed by the canonical path derived from the
|
|
322
|
+
// uri being published — so the lookup meets the tailer's server-computed key whatever the client's
|
|
323
|
+
// uri encoding looks like).
|
|
324
|
+
const diags = diagnosticsFor(docPath)
|
|
325
|
+
.concat(transient.get(uri) ?? [])
|
|
326
|
+
.concat(activityTransient.get(canonicalDocKey(docPath)) ?? []);
|
|
209
327
|
send({ jsonrpc: "2.0", method: "textDocument/publishDiagnostics", params: { uri, diagnostics: diags } });
|
|
210
328
|
} catch (e) {
|
|
211
329
|
logMessage(`candor-lsp: diagnostics failed for ${uri}: ${e.message}`);
|
|
@@ -263,13 +381,29 @@ function codeActions(docPath, uri, range) {
|
|
|
263
381
|
return out;
|
|
264
382
|
}
|
|
265
383
|
|
|
266
|
-
// Transient whatif diagnostics (Information severity, appended to the gate diagnostics on publish):
|
|
384
|
+
// Transient whatif/fix diagnostics (Information severity, appended to the gate diagnostics on publish):
|
|
267
385
|
// uri -> Diagnostic[]. Cleared on the next didOpen/didSave/didChange of that file; re-running the
|
|
268
386
|
// action replaces the previous answer (one live whatif overlay per file, not an accumulating pile).
|
|
387
|
+
// Keyed by the CLIENT's uri string on both sides (the set comes from executeCommand arguments that
|
|
388
|
+
// echo the client's own uri; the clear reads the same field) — no canonicalization needed here, unlike
|
|
389
|
+
// activityTransient whose setter computes its own paths (see canonicalDocKey).
|
|
269
390
|
const transient = new Map();
|
|
270
391
|
function clearTransient(uri) {
|
|
271
392
|
if (transient.delete(uri)) publishDiagnostics(uri); // republish without the overlay
|
|
272
393
|
}
|
|
394
|
+
// didOpen/didSave drop BOTH per-file overlays: the client's whatif/fix answer (a fresh look at the
|
|
395
|
+
// file invalidates a hypothetical answered against its previous state) and the activity gate overlay
|
|
396
|
+
// (same rationale — plus pruning activityOverlaid so a later clean record can't touch a file whose
|
|
397
|
+
// overlay the user already dismissed). The activity side goes through canonicalDocKey to meet the
|
|
398
|
+
// tailer's server-computed keys.
|
|
399
|
+
function clearOverlays(uri) {
|
|
400
|
+
transient.delete(uri);
|
|
401
|
+
try {
|
|
402
|
+
const key = canonicalDocKey(fileURLToPath(uri));
|
|
403
|
+
activityTransient.delete(key);
|
|
404
|
+
activityOverlaid.delete(key);
|
|
405
|
+
} catch { /* non-file uri — no activity overlay possible */ }
|
|
406
|
+
}
|
|
273
407
|
|
|
274
408
|
// The candor.whatif command: the SAME query-core whatif the CLI (`query.mjs whatif`) and MCP
|
|
275
409
|
// (`candor_whatif`) run — blast radius over the callgraph + the deny rules that WOULD fire, against the
|
|
@@ -379,6 +513,7 @@ function handle(msg) {
|
|
|
379
513
|
const cand = nodePath.join(rootPath, ".candor", "report");
|
|
380
514
|
if (hasReport(cand)) reportPrefix = cand;
|
|
381
515
|
}
|
|
516
|
+
startActivityWatch();
|
|
382
517
|
return result(id, {
|
|
383
518
|
capabilities: {
|
|
384
519
|
textDocumentSync: { openClose: true, save: true, change: 0 }, // report-backed: buffer edits don't move the map
|
|
@@ -391,11 +526,12 @@ function handle(msg) {
|
|
|
391
526
|
});
|
|
392
527
|
}
|
|
393
528
|
if (method === "initialized" || method === "$/cancelRequest" || method === "$/setTrace") return;
|
|
394
|
-
// didOpen/didSave
|
|
395
|
-
// edit) invalidates
|
|
396
|
-
// (change: 0) but is handled defensively for clients that send it anyway
|
|
397
|
-
|
|
398
|
-
if (method === "textDocument/
|
|
529
|
+
// didOpen/didSave drop the file's transient overlays (whatif/fix + activity gate — clearOverlays);
|
|
530
|
+
// a fresh look at the file (or an edit) invalidates an answer given against its previous state.
|
|
531
|
+
// didChange is not negotiated (change: 0) but is handled defensively for clients that send it anyway
|
|
532
|
+
// (whatif overlay only — the activity overlay clears on open/save or the next clean record).
|
|
533
|
+
if (method === "textDocument/didOpen") { clearOverlays(params.textDocument.uri); return publishDiagnostics(params.textDocument.uri); }
|
|
534
|
+
if (method === "textDocument/didSave") { clearOverlays(params.textDocument.uri); return publishDiagnostics(params.textDocument.uri); }
|
|
399
535
|
if (method === "textDocument/didChange") return clearTransient(params.textDocument.uri);
|
|
400
536
|
if (method === "textDocument/didClose")
|
|
401
537
|
return send({ jsonrpc: "2.0", method: "textDocument/publishDiagnostics", params: { uri: params.textDocument.uri, diagnostics: [] } });
|
|
@@ -421,6 +557,7 @@ function handle(msg) {
|
|
|
421
557
|
try { return result(id, run(params?.arguments?.[0])); }
|
|
422
558
|
catch (e) { logMessage(`candor-lsp: ${params?.command} failed: ${e.message}`); return result(id, null); }
|
|
423
559
|
}
|
|
560
|
+
if (method === "shutdown" && activityTimer) clearInterval(activityTimer);
|
|
424
561
|
if (method === "shutdown") return result(id, null);
|
|
425
562
|
if (method === "exit") process.exit(0);
|
|
426
563
|
if (id !== undefined) error(id, -32601, `method not found: ${method}`);
|
package/mcp.mjs
CHANGED
|
@@ -48,9 +48,32 @@ function resolvePrefix(args) {
|
|
|
48
48
|
if (!Q.hasReport(p)) throw new Error(`no report at \`${p}\` (.json or .<crate>.scan.json) — run a candor scan first`);
|
|
49
49
|
return p;
|
|
50
50
|
}
|
|
51
|
+
// Resolve a BASELINE prefix (candor_diff / candor_gains): the EXISTENCE check stays loud — a typo'd
|
|
52
|
+
// baseline that loads [] would diff/gain as an authoritative empty, a silent all-clear on the
|
|
53
|
+
// supply-chain alarm — but the --root confinement deliberately does NOT apply. A baseline is read-only
|
|
54
|
+
// comparison input the agent explicitly names (a prior-release report is routinely, and correctly, kept
|
|
55
|
+
// OUTSIDE the repo tree so the new scan can't clobber it), not a served-workspace resource: nothing is
|
|
56
|
+
// anchored to it that --root defends — its policy is never read, only its function/effect rows and
|
|
57
|
+
// callgraph sidecar are compared. Confining it broke that legitimate out-of-tree workflow.
|
|
58
|
+
function resolveBaseline(p) {
|
|
59
|
+
if (!Q.hasReport(p)) throw new Error(`no report at \`${clip(p)}\` (.json or .<crate>.scan.json) — run a candor scan first`);
|
|
60
|
+
return p;
|
|
61
|
+
}
|
|
51
62
|
// Truncate a caller-supplied value echoed back in an error (a multi-MB `fn` would otherwise be reflected
|
|
52
63
|
// verbatim — token/memory amplification over the agent transport, the opposite of the list-cap thrift).
|
|
53
64
|
const clip = (s, n = 120) => { s = String(s); return s.length > n ? s.slice(0, n) + "…" : s; };
|
|
65
|
+
// Load a report but FAIL LOUD (a thrown tool-level error) when files were FOUND yet nothing parsed —
|
|
66
|
+
// Q.loadReport discloses-and-tolerates, returning [] with the non-enumerable `hardFail` tag there, and
|
|
67
|
+
// an empty SUCCESSFUL result ({gained:[],byFunction:[]}, [] show, {} map) reads as an all-clear over a
|
|
68
|
+
// corrupt report — the §4 cardinal sin, exactly what the CLI's loadReportOrDie exits 2 on. The throw
|
|
69
|
+
// surfaces as the same isError result shape every other tool failure uses. EVERY tool that loads a
|
|
70
|
+
// report (main prefix or baseline) goes through this — never bare Q.loadReport.
|
|
71
|
+
function loadReportLoud(p) {
|
|
72
|
+
const fns = Q.loadReport(p);
|
|
73
|
+
if (fns.length === 0 && fns.hardFail)
|
|
74
|
+
throw new Error(`every report found at prefix \`${clip(p)}\` failed to load — refusing to report an empty (all-clear) answer over a corrupt report; re-run the scan`);
|
|
75
|
+
return fns;
|
|
76
|
+
}
|
|
54
77
|
// The confinement root for a caller-supplied policy path: the repo the report belongs to — the
|
|
55
78
|
// .candor/config-discovered repo root when there is one, else the parent of a `.candor/` report
|
|
56
79
|
// directory, else the report's own directory. The old default (always dirname(prefix)) was the
|
|
@@ -132,22 +155,22 @@ const TOOLS = {
|
|
|
132
155
|
candor_impact: {
|
|
133
156
|
description: "Backward blast radius: every effectful function that transitively calls `fn`, and which runtime entry points are downstream. Answers 'if I change this, what surfaces at runtime?' — the cheapest possible alternative to tracing callers by hand.",
|
|
134
157
|
schema: { type: "object", properties: { fn: { type: "string", description: "the function/unit to assess" }, ...reportArg }, required: ["fn"] },
|
|
135
|
-
run: (a, p) => capImpact(Q.impact(
|
|
158
|
+
run: (a, p) => capImpact(Q.impact(loadReportLoud(p), Q.loadCallgraph(p), a.fn)),
|
|
136
159
|
},
|
|
137
160
|
candor_where: {
|
|
138
161
|
description: "Which functions perform a given effect (e.g. Net, Db, Exec, Fs) — `directly` vs `inherited` via a callee. The effect-surface map.",
|
|
139
162
|
schema: { type: "object", properties: { effect: { type: "string", description: "Net|Fs|Db|Exec|Env|Clock|Ipc|Log|Rand|Clipboard|Unknown" }, ...reportArg }, required: ["effect"] },
|
|
140
|
-
run: (a, p) => capWhere(Q.where(
|
|
163
|
+
run: (a, p) => capWhere(Q.where(loadReportLoud(p), a.effect)),
|
|
141
164
|
},
|
|
142
165
|
candor_reachable: {
|
|
143
166
|
description: "What the program/fleet actually DOES at runtime: effects unioned over the entry points, with how many roots reach each and via which.",
|
|
144
167
|
schema: { type: "object", properties: { ...reportArg } },
|
|
145
|
-
run: (_a, p) => Q.reachable(
|
|
168
|
+
run: (_a, p) => Q.reachable(loadReportLoud(p)),
|
|
146
169
|
},
|
|
147
170
|
candor_path: {
|
|
148
171
|
description: "Forward provenance: the shortest call chain from `fn` to the nearest function that performs `effect` DIRECTLY — 'this reaches Net through WHAT?'.",
|
|
149
172
|
schema: { type: "object", properties: { fn: { type: "string" }, effect: { type: "string" }, ...reportArg }, required: ["fn", "effect"] },
|
|
150
|
-
run: (a, p) => Q.path(
|
|
173
|
+
run: (a, p) => Q.path(loadReportLoud(p), Q.loadCallgraph(p), a.fn, a.effect),
|
|
151
174
|
},
|
|
152
175
|
candor_callers: {
|
|
153
176
|
description: "Who calls `fn` — direct (one hop) and transitive callers over the effect-relevant call graph.",
|
|
@@ -157,12 +180,12 @@ const TOOLS = {
|
|
|
157
180
|
candor_show: {
|
|
158
181
|
description: "A function's effects (inferred = transitive, direct = own body) plus its literal surfaces (hosts/cmds/paths/tables) when present.",
|
|
159
182
|
schema: { type: "object", properties: { fn: { type: "string" }, ...reportArg }, required: ["fn"] },
|
|
160
|
-
run: (a, p) => Q.show(
|
|
183
|
+
run: (a, p) => Q.show(loadReportLoud(p), a.fn),
|
|
161
184
|
},
|
|
162
185
|
candor_map: {
|
|
163
186
|
description: "Per-module effect overview: each module's union of effects and function count. The architecture-at-a-glance.",
|
|
164
187
|
schema: { type: "object", properties: { ...reportArg } },
|
|
165
|
-
run: (_a, p) => Q.map(
|
|
188
|
+
run: (_a, p) => Q.map(loadReportLoud(p)),
|
|
166
189
|
},
|
|
167
190
|
candor_whatif: {
|
|
168
191
|
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.",
|
|
@@ -195,7 +218,7 @@ const TOOLS = {
|
|
|
195
218
|
// The sidecar is the only graph a candor-ts report carries — fail loud (tool error) when it's absent,
|
|
196
219
|
// never a degenerate empty-graph remedy. (/code-review.)
|
|
197
220
|
if (!cg || Object.keys(cg).length === 0) throw new Error(`no call-graph sidecar for the report — fix needs it (re-scan with --out)`);
|
|
198
|
-
const r = Q.fix(cg,
|
|
221
|
+
const r = Q.fix(cg, loadReportLoud(p), a.fn, a.effect, parsePolicy(text), scopeMatches);
|
|
199
222
|
if (r === null) throw new Error(`no function matching \`${clip(a.fn)}\` in the call graph`);
|
|
200
223
|
return r;
|
|
201
224
|
},
|
|
@@ -211,7 +234,7 @@ const TOOLS = {
|
|
|
211
234
|
if (!cfg) throw new Error("no policy: pass `policy`, or check one into the repo's .candor/config (spec §3.4)");
|
|
212
235
|
text = confinedPolicyRead(cfg.policyPath, p, cfg.repoRoot);
|
|
213
236
|
}
|
|
214
|
-
const v = evaluatePolicy(parsePolicy(text),
|
|
237
|
+
const v = evaluatePolicy(parsePolicy(text), loadReportLoud(p), Q.loadCallgraph(p));
|
|
215
238
|
return { ok: v.length === 0, violations: v };
|
|
216
239
|
},
|
|
217
240
|
},
|
|
@@ -232,33 +255,126 @@ const TOOLS = {
|
|
|
232
255
|
if (!cfg) throw new Error("no policy: pass `policy`, or check one into the repo's .candor/config (spec §3.4)");
|
|
233
256
|
text = confinedPolicyRead(cfg.policyPath, p, cfg.repoRoot);
|
|
234
257
|
}
|
|
235
|
-
return Q.unverified(
|
|
258
|
+
return Q.unverified(loadReportLoud(p), parsePolicy(text), scopeMatches);
|
|
236
259
|
},
|
|
237
260
|
},
|
|
238
261
|
candor_containment: {
|
|
239
262
|
description: "Per boundary effect (Db/Net/Exec/Fs/Ipc/Clipboard): how contained it is in one architectural layer — the dispersion diagnostic (spec §6.1). Not a score; per-effect facts.",
|
|
240
263
|
schema: { type: "object", properties: { ...reportArg } },
|
|
241
|
-
run: (_a, p) => Q.containment(
|
|
264
|
+
run: (_a, p) => Q.containment(loadReportLoud(p)),
|
|
242
265
|
},
|
|
243
266
|
candor_blindspots: {
|
|
244
267
|
description: "The Unknown SOURCES — calls the engine genuinely could not resolve (reflection, wide dispatch, fn-pointers) — ranked by how many functions inherit Unknown through each. Turns a high-Unknown report into a short worklist.",
|
|
245
268
|
schema: { type: "object", properties: { ...reportArg } },
|
|
246
|
-
run: (_a, p) => capBlindspots(Q.blindspots(
|
|
269
|
+
run: (_a, p) => capBlindspots(Q.blindspots(loadReportLoud(p), Q.loadCallgraph(p))),
|
|
247
270
|
},
|
|
248
271
|
candor_diff: {
|
|
249
272
|
description: "The per-function effect delta versus a baseline report: gained (introduced vs inherited) and lost effects. 'What did this change do to the effect surface?'.",
|
|
250
273
|
schema: { type: "object", properties: { baseline: { type: "string", description: "the baseline report prefix" }, ...reportArg }, required: ["baseline"] },
|
|
251
|
-
run: (a, p) =>
|
|
252
|
-
|
|
274
|
+
run: (a, p) => {
|
|
275
|
+
// Baseline existence is loud (a typo'd baseline loaded [] with hardFail=false and diffed as an
|
|
276
|
+
// authoritative empty {changes:[]}; the CLI exits 2 on the same miss) — but NOT --root-confined:
|
|
277
|
+
// see resolveBaseline for the out-of-tree-baseline trust argument.
|
|
278
|
+
const b = resolveBaseline(a.baseline);
|
|
279
|
+
return { baseline_version: Q.reportVersion(b) ?? "", engine_version: Q.reportVersion(p) ?? "",
|
|
280
|
+
...Q.diff(loadReportLoud(p), loadReportLoud(b)) };
|
|
281
|
+
},
|
|
253
282
|
},
|
|
254
283
|
candor_gains: {
|
|
255
284
|
description: "The supply-chain alarm: effects the surface GAINED versus a baseline (package-level + per-function) — 'did this dependency bump add Net/Exec somewhere?'.",
|
|
256
285
|
schema: { type: "object", properties: { baseline: { type: "string", description: "the baseline report prefix" }, ...reportArg }, required: ["baseline"] },
|
|
257
|
-
run: (a, p) =>
|
|
258
|
-
|
|
286
|
+
run: (a, p) => {
|
|
287
|
+
// Same baseline posture as candor_diff (loud existence, no --root confinement — resolveBaseline):
|
|
288
|
+
// an empty {gained:[]} over a typo'd baseline is a silent all-clear on the supply-chain ALARM
|
|
289
|
+
// tool, while a prior-release baseline legitimately lives outside the served tree.
|
|
290
|
+
const b = resolveBaseline(a.baseline);
|
|
291
|
+
// ⟨spec 0.12 staged⟩ baseline callgraph → byFunction[].origin, same as the CLI (parity). The
|
|
292
|
+
// loader's non-enumerable `partial` tag rides along: a corrupt baseline sidecar (edges dropped,
|
|
293
|
+
// disclosed) downgrades origin to "unknown", never a fabricated "new" over a truncated graph.
|
|
294
|
+
return { baseline_version: Q.reportVersion(b) ?? "", engine_version: Q.reportVersion(p) ?? "",
|
|
295
|
+
...Q.gains(loadReportLoud(p), loadReportLoud(b), Q.loadCallgraph(b)) };
|
|
296
|
+
},
|
|
297
|
+
},
|
|
298
|
+
candor_activity: {
|
|
299
|
+
description: "What the edit-time gate caught: MEASURED activity from .candor/activity.jsonl (the Stop-hook / standalone review log) — edits checked, verdicts, violations by AS-EFF code, effects introduced, largest blast radius, deepest propagation (hops), plus the most recent records. Counted from the log, no model. A missing log is an empty result (the loop isn't wired here — not an error); corrupt lines are skipped.",
|
|
300
|
+
schema: { type: "object", properties: {
|
|
301
|
+
log: { type: "string", description: "activity log path (default .candor/activity.jsonl under --root, else beside the served report prefix, else cwd)" },
|
|
302
|
+
session: { type: "string", description: "filter to one sessionId" },
|
|
303
|
+
since: { type: "string", description: "ISO timestamp lower bound (records with no ts are kept)" },
|
|
304
|
+
limit: { type: "number", description: "how many recent records to return (default 5, max 50)" },
|
|
305
|
+
} },
|
|
306
|
+
noReport: true, // reads the activity log, not a report — usable before any scan exists
|
|
307
|
+
run: (a) => readActivity(a),
|
|
259
308
|
},
|
|
260
309
|
};
|
|
261
310
|
|
|
311
|
+
// The candor_activity reader. Field SEMANTICS mirror `candor-agents stats` (the two count the same
|
|
312
|
+
// pinned record shape — lib-candor-summary.sh's writer — so they cannot tell different stories):
|
|
313
|
+
// non-object lines skipped, bool-typed numerics ignored, `since` keeps null-ts records, verdict
|
|
314
|
+
// buckets clean/blocked/setup. The `edited` paths in `recent` are the hook's local-only fields —
|
|
315
|
+
// the MCP transport is the same machine (the agent already reads those files), so serving them is
|
|
316
|
+
// not the off-box transmission FEEDBACK-SPEC's privacy note forbids.
|
|
317
|
+
// The anchor a relative/default activity-log path resolves against — a LADDER, mirroring how the LSP
|
|
318
|
+
// derives its watch dir (rootPath ?? dirname(reportPrefix)):
|
|
319
|
+
// 1. --root: the served workspace is the explicit truth when one is declared;
|
|
320
|
+
// 2. the served report prefix ($CANDOR_REPORT / CLI arg): the documented
|
|
321
|
+
// `CANDOR_REPORT=/repo/.candor/report npx candor-ts-mcp` invocation runs from ANY cwd, and the
|
|
322
|
+
// activity log lives beside the report — anchoring at cwd found nothing. A `<repo>/.candor/report`
|
|
323
|
+
// prefix anchors at `<repo>` (so the `.candor/activity.jsonl` default lands beside the report);
|
|
324
|
+
// any other prefix anchors at its own directory (its `.candor/` sits with it);
|
|
325
|
+
// 3. cwd — nothing else to go on.
|
|
326
|
+
function activityAnchor() {
|
|
327
|
+
if (WORKSPACE_ROOT) return WORKSPACE_ROOT;
|
|
328
|
+
if (DEFAULT_PREFIX) {
|
|
329
|
+
const dir = nodePath.resolve(nodePath.dirname(DEFAULT_PREFIX));
|
|
330
|
+
return nodePath.basename(dir) === ".candor" ? nodePath.dirname(dir) : dir;
|
|
331
|
+
}
|
|
332
|
+
return process.cwd();
|
|
333
|
+
}
|
|
334
|
+
function readActivity(a) {
|
|
335
|
+
const log = nodePath.resolve(activityAnchor(), a?.log || ".candor/activity.jsonl");
|
|
336
|
+
if (WORKSPACE_ROOT && !within(log, WORKSPACE_ROOT))
|
|
337
|
+
throw new Error(`activity log \`${clip(a?.log)}\` is outside the served workspace (--root ${WORKSPACE_ROOT}) — refusing`);
|
|
338
|
+
let lines = [];
|
|
339
|
+
try { lines = fs.readFileSync(log, "utf8").split("\n"); }
|
|
340
|
+
catch { return { log: null, edits: 0, note: "no activity log — the edit-time loop isn't wired here (integrations/claude-code)" }; }
|
|
341
|
+
const recs = [];
|
|
342
|
+
for (const l of lines) {
|
|
343
|
+
if (!l.trim()) continue;
|
|
344
|
+
try { const r = JSON.parse(l); if (r && typeof r === "object" && !Array.isArray(r)) recs.push(r); } catch { /* corrupt line — skipped, like stats */ }
|
|
345
|
+
}
|
|
346
|
+
const since = a?.since;
|
|
347
|
+
// `since` compares TEMPORALLY when the caller's value parses: a bytewise ISO compare mis-filters the
|
|
348
|
+
// offset/millis variants an agent naturally supplies ("…T11:30:00+01:00" sorts after "…T11:00:00Z"
|
|
349
|
+
// lexicographically yet is the earlier instant). The log's own ts format is pinned, but records are
|
|
350
|
+
// read tolerantly: a record whose ts doesn't parse is KEPT, matching the null-ts posture (a filter
|
|
351
|
+
// must never silently hide records it can't place). Only when the caller's `since` itself doesn't
|
|
352
|
+
// parse do we fall back to the old lexicographic compare (best effort over refusing).
|
|
353
|
+
const sinceMs = since ? Date.parse(since) : NaN;
|
|
354
|
+
const afterSince = (r) => {
|
|
355
|
+
if (!since || typeof r.ts !== "string") return true;
|
|
356
|
+
if (Number.isNaN(sinceMs)) return r.ts >= since; // unparseable bound — lexicographic fallback
|
|
357
|
+
const tsMs = Date.parse(r.ts);
|
|
358
|
+
return Number.isNaN(tsMs) ? true : tsMs >= sinceMs; // unparseable record ts — kept, like null ts
|
|
359
|
+
};
|
|
360
|
+
const kept = recs.filter((r) => (!a?.session || r.sessionId === a.session) && afterSince(r));
|
|
361
|
+
const summary = { log, edits: kept.length, clean: 0, blocked: 0, setup: 0,
|
|
362
|
+
violations: {}, effectsIntroduced: new Set(),
|
|
363
|
+
largestBlastRadius: 0, deepestPropagation: 0, from: null, to: null };
|
|
364
|
+
for (const r of kept) {
|
|
365
|
+
const v = r.verdict === "clean" || r.verdict === "blocked" ? r.verdict : "setup";
|
|
366
|
+
summary[v]++;
|
|
367
|
+
for (const code of Array.isArray(r.violations) ? r.violations : []) summary.violations[code] = (summary.violations[code] ?? 0) + 1;
|
|
368
|
+
for (const e of Array.isArray(r.gained) ? r.gained : []) summary.effectsIntroduced.add(e);
|
|
369
|
+
if (typeof r.blastRadius === "number" && Number.isInteger(r.blastRadius)) summary.largestBlastRadius = Math.max(summary.largestBlastRadius, r.blastRadius);
|
|
370
|
+
if (typeof r.maxHops === "number" && Number.isInteger(r.maxHops)) summary.deepestPropagation = Math.max(summary.deepestPropagation, r.maxHops);
|
|
371
|
+
if (typeof r.ts === "string") { if (!summary.from || r.ts < summary.from) summary.from = r.ts; if (!summary.to || r.ts > summary.to) summary.to = r.ts; }
|
|
372
|
+
}
|
|
373
|
+
summary.effectsIntroduced = [...summary.effectsIntroduced].sort();
|
|
374
|
+
const limit = Math.min(Math.max(1, Number.isInteger(a?.limit) ? a.limit : 5), 50);
|
|
375
|
+
return { ...summary, recent: kept.slice(-limit) };
|
|
376
|
+
}
|
|
377
|
+
|
|
262
378
|
// ---- MCP resources: the report + the checked-in policy, readable directly --------------------------
|
|
263
379
|
function listResources(prefix) {
|
|
264
380
|
const res = [{ uri: `candor://report?prefix=${encodeURIComponent(prefix)}`, name: "candor report",
|
|
@@ -270,7 +386,7 @@ function listResources(prefix) {
|
|
|
270
386
|
return res;
|
|
271
387
|
}
|
|
272
388
|
function readResource(uri, prefix) {
|
|
273
|
-
if (uri.startsWith("candor://report")) return { mimeType: "application/json", text: JSON.stringify(
|
|
389
|
+
if (uri.startsWith("candor://report")) return { mimeType: "application/json", text: JSON.stringify(loadReportLoud(prefix)) };
|
|
274
390
|
if (uri.startsWith("candor://policy")) {
|
|
275
391
|
const cfg = configPolicy(prefix);
|
|
276
392
|
if (!cfg) throw new Error("no checked-in policy (no .candor/config with a `policy` key)");
|
|
@@ -328,11 +444,13 @@ function handle(msg) {
|
|
|
328
444
|
const missing = (t.schema.required || []).filter((k) => args[k] === undefined || args[k] === "");
|
|
329
445
|
if (missing.length)
|
|
330
446
|
return result(id, { content: [{ type: "text", text: `candor: missing required argument(s): ${missing.join(", ")}` }], isError: true });
|
|
331
|
-
|
|
447
|
+
// A log-only tool (candor_activity) needs no report — resolving one would wrongly demand a
|
|
448
|
+
// scan before the gate's own activity can be read.
|
|
449
|
+
const prefix = t.noReport ? null : resolvePrefix(args);
|
|
332
450
|
// A tool that targets a `fn` gets a clear "not found" rather than a silently-empty result —
|
|
333
451
|
// an agent must distinguish "no such function" from "found, nothing calls it".
|
|
334
452
|
if (args.fn !== undefined) {
|
|
335
|
-
const names = [...new Set([...Object.keys(Q.loadCallgraph(prefix)), ...
|
|
453
|
+
const names = [...new Set([...Object.keys(Q.loadCallgraph(prefix)), ...loadReportLoud(prefix).map((e) => e.fn)])];
|
|
336
454
|
if (Q.matches(names, args.fn).length === 0)
|
|
337
455
|
return result(id, { content: [{ type: "text", text: `candor: no function matching \`${clip(args.fn)}\` in this report` }], isError: true });
|
|
338
456
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "candor-ts",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "candor for TypeScript — per-function side effects, transitively, with a policy gate (candor-spec 0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
|
+
"description": "candor for TypeScript — per-function side effects, transitively, with a policy gate (candor-spec 0.13)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"dependencies": {
|
|
7
7
|
"@types/node": "^25.9.2",
|
package/policy.mjs
CHANGED
|
@@ -4,8 +4,10 @@
|
|
|
4
4
|
* engines follow (candor-classify::policy), so the TS gate can never disagree with its own whatif.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
export const EFFECTS = ["Net", "Fs", "Db", "Exec", "Env", "Clock", "Ipc", "Log", "Rand", "Clipboard"];
|
|
8
|
-
|
|
7
|
+
export const EFFECTS = ["Net", "Fs", "Db", "Exec", "Env", "Clock", "Ipc", "Log", "Rand", "Clipboard", "Llm"];
|
|
8
|
+
// The literal surfaces `allow` can restrict. `Llm` ⟨0.13⟩ rides Net's host literal (SPEC §1) —
|
|
9
|
+
// `allow Llm <host…>` restricts which MODEL hosts a scope may reach, matched by hostname like Net.
|
|
10
|
+
const ALLOW_EFFECTS = new Set(["Net", "Exec", "Fs", "Db", "Llm"]);
|
|
9
11
|
|
|
10
12
|
// The §6.2 token separator: ASCII whitespace ONLY (space/tab/LF/VT/FF/CR). JS `\s`/`String.trim` strip
|
|
11
13
|
// Unicode spaces (NBSP, ideographic, …) that Java drops — a gateless-green cross-engine divergence
|
|
@@ -36,7 +38,7 @@ export function parsePolicy(text) {
|
|
|
36
38
|
deny.push({ effects: [], scope: t[1] ?? "", raw: line });
|
|
37
39
|
} else if (t[0] === "allow") {
|
|
38
40
|
if (t.length < 3) { warn("allow names no values"); continue; }
|
|
39
|
-
if (!ALLOW_EFFECTS.has(t[1])) { warn("allow supports only Net hosts / Exec commands / Fs paths / Db tables"); continue; }
|
|
41
|
+
if (!ALLOW_EFFECTS.has(t[1])) { warn("allow supports only Net hosts / Llm hosts / Exec commands / Fs paths / Db tables"); continue; }
|
|
40
42
|
let scope = "", vi = 2;
|
|
41
43
|
if (t[2] === "in") { scope = t[3] ?? ""; vi = 4; }
|
|
42
44
|
const values = t.slice(vi);
|
|
@@ -97,6 +99,8 @@ export function tableCovered(a, r) {
|
|
|
97
99
|
export function literalAllowed(effect, reached, values) {
|
|
98
100
|
switch (effect) {
|
|
99
101
|
case "Net": return values.some((a) => hostPart(a) === hostPart(reached));
|
|
102
|
+
// `Llm` ⟨0.13⟩ rides Net's host literal (SPEC §1) — matched by hostname exactly like Net.
|
|
103
|
+
case "Llm": return values.some((a) => hostPart(a) === hostPart(reached));
|
|
100
104
|
case "Exec": return values.some((a) => cmdBase(a) === cmdBase(reached));
|
|
101
105
|
case "Fs": return values.some((a) => pathCovered(a, reached));
|
|
102
106
|
case "Db": return values.some((a) => tableCovered(a, reached));
|
|
@@ -115,7 +119,8 @@ export function literalAllowed(effect, reached, values) {
|
|
|
115
119
|
// The console gate renders `[${rule}] ${detail}`; --gate-json emits the records verbatim.
|
|
116
120
|
export function evaluatePolicy(pol, functions, callgraph, incomplete = new Map()) {
|
|
117
121
|
const out = [];
|
|
118
|
-
|
|
122
|
+
// `Llm` ⟨0.13⟩ reaches the SAME hosts surface as Net (an Llm host WAS captured as a Net host literal).
|
|
123
|
+
const surfaces = { Net: "hosts", Llm: "hosts", Exec: "cmds", Fs: "paths", Db: "tables" };
|
|
119
124
|
const push = (rule, fn, effects, detail) => out.push({ rule, fn, effects, detail });
|
|
120
125
|
for (const f of functions) {
|
|
121
126
|
for (const r of pol.deny) {
|
|
@@ -136,7 +141,11 @@ export function evaluatePolicy(pol, functions, callgraph, incomplete = new Map()
|
|
|
136
141
|
// An INCOMPLETE surface (a structurally-invisible reach — a host-establishing call with a runtime/
|
|
137
142
|
// invisible host) can't be certified even with visible hosts, else a benign literal masks the
|
|
138
143
|
// invisible forbidden endpoint (the masking evasion). Matches candor-java 0.5.29 / candor-rust.
|
|
139
|
-
|
|
144
|
+
// `Llm` ⟨0.13⟩ rides the Net host literal (SPEC §1), so a runtime/masked host that makes the Net
|
|
145
|
+
// surface incomplete must fail-close `allow Llm …` identically (java parity #3): a benign visible
|
|
146
|
+
// model host must not certify a scope that also reaches a hidden one.
|
|
147
|
+
const surfaceIncomplete = incomplete.get(f.fn)?.has(r.effect)
|
|
148
|
+
|| (r.effect === "Llm" && incomplete.get(f.fn)?.has("Net"));
|
|
140
149
|
if (reached.length === 0 || surfaceIncomplete) {
|
|
141
150
|
push("AS-EFF-008", f.fn, [r.effect], `\`${f.fn}\` performs ${r.effect} with no visible literal — the surface cannot be certified: \`${r.raw}\``);
|
|
142
151
|
} else {
|
package/query-core.mjs
CHANGED
|
@@ -170,6 +170,13 @@ export function loadReport(prefix) {
|
|
|
170
170
|
}
|
|
171
171
|
return tagHardFail(fns, hardFail);
|
|
172
172
|
}
|
|
173
|
+
// The returned graph carries a non-enumerable `partial` flag (the loadReport `hardFail` precedent):
|
|
174
|
+
// true iff a sidecar file was MATCHED but failed to read/parse — its edges were DROPPED (disclosed on
|
|
175
|
+
// stderr above), so the graph is an UNDER-approximation. An ABSENT sidecar is NOT partial: nothing
|
|
176
|
+
// matched, and the empty graph is the whole (disclosable) truth. gains' origin ladder needs the
|
|
177
|
+
// distinction: over a partial baseline graph, absence from the surviving edges proves nothing, so
|
|
178
|
+
// labeling a dropped file's fns "new" would downgrade the attack signal — fall back to "unknown".
|
|
179
|
+
const tagPartial = (cg, partial) => { Object.defineProperty(cg, "partial", { value: partial, enumerable: false }); return cg; };
|
|
173
180
|
export function loadCallgraph(prefix) {
|
|
174
181
|
// A `null`/non-object parse (a `null` callgraph, an array, a number) must NOT reach Object.entries —
|
|
175
182
|
// it throws "Cannot convert null to object". Coerce anything but a plain object to {} (an empty
|
|
@@ -180,16 +187,18 @@ export function loadCallgraph(prefix) {
|
|
|
180
187
|
if (fs.existsSync(`${prefix}.callgraph.json`)) {
|
|
181
188
|
// The PRIMARY callgraph parse must DISCLOSE-and-tolerate like the sibling path below and like
|
|
182
189
|
// loadReport — a bare JSON.parse here threw an uncaught stack trace on the CLI for a corrupt or
|
|
183
|
-
// `null` `<prefix>.callgraph.json` (asymmetric with siblings). Tolerate (empty graph) + disclose
|
|
184
|
-
|
|
185
|
-
|
|
190
|
+
// `null` `<prefix>.callgraph.json` (asymmetric with siblings). Tolerate (empty graph) + disclose,
|
|
191
|
+
// and TAG the drop (`partial`) so a consumer never mistakes the truncated graph for the whole one.
|
|
192
|
+
try { return tagPartial(norm(JSON.parse(fs.readFileSync(`${prefix}.callgraph.json`, "utf8"))), false); }
|
|
193
|
+
catch { console.error(`candor-ts: callgraph ${prefix}.callgraph.json failed to parse — its edges are OMITTED from this query (corrupt or mid-write); re-run the scan`); return tagPartial({}, true); }
|
|
186
194
|
}
|
|
187
195
|
const cg = {};
|
|
196
|
+
let partial = false;
|
|
188
197
|
for (const f of siblings(prefix, (x) => x.endsWith(".callgraph.json"))) {
|
|
189
198
|
try { Object.assign(cg, JSON.parse(fs.readFileSync(f, "utf8"))); }
|
|
190
|
-
catch { console.error(`candor-ts: callgraph ${f} failed to parse — its edges are OMITTED from this query (corrupt or mid-write); re-run the scan`); }
|
|
199
|
+
catch { console.error(`candor-ts: callgraph ${f} failed to parse — its edges are OMITTED from this query (corrupt or mid-write); re-run the scan`); partial = true; }
|
|
191
200
|
}
|
|
192
|
-
return norm(cg);
|
|
201
|
+
return tagPartial(norm(cg), partial);
|
|
193
202
|
}
|
|
194
203
|
|
|
195
204
|
// ---- the §3.1 match ladder: exact > segment-suffix > substring ------------------------------------
|
|
@@ -334,7 +343,7 @@ export function map(fns) {
|
|
|
334
343
|
// with the AS-EFF-010 ratchet when a baseline is given. Mirrors candor-java Query.containment and
|
|
335
344
|
// candor-query cmd_containment: boundary effects are scored, ambient ones reported-not-scored; a layer is
|
|
336
345
|
// the segment AFTER the common dotted prefix ("(root)" when no package layer follows). Uses DIRECT effects.
|
|
337
|
-
export const CONTAINED = ["Db", "Net", "Exec", "Fs", "Ipc", "Clipboard"];
|
|
346
|
+
export const CONTAINED = ["Db", "Net", "Llm", "Exec", "Fs", "Ipc", "Clipboard"];
|
|
338
347
|
export const AMBIENT = ["Log", "Clock", "Rand", "Env"];
|
|
339
348
|
function commonPrefixLen(fns) {
|
|
340
349
|
let best = null;
|
|
@@ -523,10 +532,34 @@ export function diff(curFns, baseFns) {
|
|
|
523
532
|
// gains: the package-level SUPPLY-CHAIN alarm (spec §5.1) — the UNION of effects the surface gained
|
|
524
533
|
// between two reports (base → cur), with per-function detail. A dependency that grows a Net/Exec reach
|
|
525
534
|
// between releases. Same shape as candor-query's `gains --json`. Built on diff so it can't drift.
|
|
526
|
-
|
|
535
|
+
//
|
|
536
|
+
// ⟨spec 0.12 staged⟩ each byFunction entry carries `origin` — the candor-gains prototype's key finding
|
|
537
|
+
// promoted into the open query. A gain on a fn that EXISTED at the baseline (shipped pure, now does
|
|
538
|
+
// Net — the supply-chain attack signal) is a different alarm from a NEW fn that does Net (a feature).
|
|
539
|
+
// Reports OMIT pure functions (§2), so existence is keyed on the baseline CALLGRAPH (a baseline-pure
|
|
540
|
+
// fn is a graph node with no report entry):
|
|
541
|
+
// "existing" — in the baseline report, or a baseline-callgraph node (caller key or callee);
|
|
542
|
+
// "new" — a COMPLETE baseline callgraph was loaded and the fn is in neither (did not exist);
|
|
543
|
+
// "unknown" — absent from the baseline report AND the graph cannot decide: no baseline callgraph
|
|
544
|
+
// found (empty graph) OR the graph is PARTIAL (loadCallgraph's non-enumerable `partial`
|
|
545
|
+
// tag — a matched sidecar failed to load, its edges were dropped-and-disclosed, so
|
|
546
|
+
// absence from the survivors proves nothing). Undecidable is DISCLOSED, never guessed
|
|
547
|
+
// (§4) — a partial graph must not downgrade the attack signal from a dropped file's
|
|
548
|
+
// fns to a benign-looking "new".
|
|
549
|
+
// `baseCg` defaults to {} (no callgraph → "unknown") so core-only callers keep working unchanged.
|
|
550
|
+
export function gains(curFns, baseFns, baseCg = {}) {
|
|
551
|
+
const baseSet = new Set(baseFns.map((e) => e.fn));
|
|
552
|
+
const cgNodes = new Set(Object.entries(baseCg).flatMap(([k, vs]) => [k, ...vs]));
|
|
553
|
+
// The ladder: report hit → existing; graph node → existing (a surviving node is real even in a
|
|
554
|
+
// partial graph — the drop loses nodes, never invents them); else "new" only when a COMPLETE
|
|
555
|
+
// non-empty graph can vouch for non-existence; else "unknown".
|
|
556
|
+
const graphDecides = cgNodes.size > 0 && baseCg.partial !== true;
|
|
557
|
+
const originOf = (fn) => baseSet.has(fn) ? "existing"
|
|
558
|
+
: cgNodes.has(fn) ? "existing"
|
|
559
|
+
: graphDecides ? "new" : "unknown";
|
|
527
560
|
const gained = new Set(), byFunction = [];
|
|
528
561
|
for (const c of diff(curFns, baseFns).changes) {
|
|
529
|
-
for (const e of c.gained) { gained.add(e); byFunction.push({ fn: c.fn,
|
|
562
|
+
for (const e of c.gained) { gained.add(e); byFunction.push({ effect: e, fn: c.fn, origin: originOf(c.fn) }); }
|
|
530
563
|
}
|
|
531
564
|
return { gained: [...gained].sort(), byFunction };
|
|
532
565
|
}
|
package/query.mjs
CHANGED
|
@@ -49,8 +49,12 @@ const emit = (v) => console.log(JSON.stringify(v, null, 1));
|
|
|
49
49
|
// Prints to stdout and returns nothing (matches the JSON-only verbs' fire-and-forget style).
|
|
50
50
|
function renderPathHuman(fns, cg, fnQ, eff) {
|
|
51
51
|
// Resolve the start over the REPORT entries (as Rust does) — that's where `inferred` lives, and the
|
|
52
|
-
// no-effect wording quotes it.
|
|
53
|
-
//
|
|
52
|
+
// no-effect wording quotes it. The RESOLVED name (not the raw query) is then handed to corePath,
|
|
53
|
+
// which re-resolves over the CALLGRAPH keys — a DIFFERENT name set: a raw partial query could pick
|
|
54
|
+
// a different fn there (report `app.db.save`, graph `app.cache.save` for the query "save"), so the
|
|
55
|
+
// header described one function and the chain/verdict another (a misleading "not statically
|
|
56
|
+
// traceable" over a traceable fn). An exact name resolves identically in both sets (match tier 3,
|
|
57
|
+
// exact, beats every partial tier and only its own name can equal it), so they cannot disagree.
|
|
54
58
|
const start = coreMatches(fns.map((e) => e.fn), fnQ)[0];
|
|
55
59
|
if (start === undefined) {
|
|
56
60
|
// No matching function at all — parity with Rust/Java's "no function matching" (stderr, exit 2).
|
|
@@ -67,7 +71,7 @@ function renderPathHuman(fns, cg, fnQ, eff) {
|
|
|
67
71
|
console.log(`${start} does not perform ${eff} (inferred: ${dbg})`);
|
|
68
72
|
return;
|
|
69
73
|
}
|
|
70
|
-
const r = corePath(fns, cg,
|
|
74
|
+
const r = corePath(fns, cg, start, eff);
|
|
71
75
|
if (r.path.length === 0) {
|
|
72
76
|
// Inferred, but no LOCAL direct source on a `calls` path — reached cross-crate or via Unknown.
|
|
73
77
|
console.log(`${start} performs ${eff} but its source is not a local function `
|
|
@@ -90,7 +94,7 @@ function renderPathHuman(fns, cg, fnQ, eff) {
|
|
|
90
94
|
// package.json; SPEC_VERSION is the spec contract this build speaks. Reused, never re-littered.
|
|
91
95
|
const QUERY_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
92
96
|
const PKG_VERSION = JSON.parse(fs.readFileSync(path.join(QUERY_DIR, "package.json"), "utf8")).version;
|
|
93
|
-
const SPEC_VERSION = "0.
|
|
97
|
+
const SPEC_VERSION = "0.13";
|
|
94
98
|
|
|
95
99
|
// ---- the §3.3.1 canonical query grammar (⟨0.10⟩, additive over 0.9) --------------------------------
|
|
96
100
|
// One shape for every verb: `<verb> <verb-args…> [--report <locator>] [--policy <file>] [--json]
|
|
@@ -421,7 +425,13 @@ switch (cmd) {
|
|
|
421
425
|
// each resolved by the shared locator rule (dir / .json path / prefix). --json is accepted (JSON is
|
|
422
426
|
// the only output). No leading-positional-report alias here: both positionals ARE the reports.
|
|
423
427
|
const { positionals } = parseCanonical(args, {});
|
|
428
|
+
if (positionals.length < 2) { console.error("usage: candor-ts-query diff <current> <baseline> [--json]"); process.exit(2); }
|
|
424
429
|
const [curPrefix, basePrefix] = positionals.map(locatorToPrefix);
|
|
430
|
+
// BOTH locators must name real report files (the Rust engine's no-files check, named per side so
|
|
431
|
+
// the user knows which path to fix): a typo'd prefix loaded [] with hardFail=false and emitted an
|
|
432
|
+
// authoritative EMPTY {changes:[]} at exit 0 — the §4 false all-clear on the ratchet verb.
|
|
433
|
+
if (!hasReport(curPrefix)) { console.error(`candor-ts: no report files at current prefix '${curPrefix}' — check the path.`); process.exit(2); }
|
|
434
|
+
if (!hasReport(basePrefix)) { console.error(`candor-ts: no report files at baseline prefix '${basePrefix}' — check the path.`); process.exit(2); }
|
|
425
435
|
const { changes } = coreDiff(loadReportOrDie(curPrefix), loadReportOrDie(basePrefix));
|
|
426
436
|
// §2.1: a baseline is comparable only to its own producing build — disclose a mismatch (the gains
|
|
427
437
|
// may be the engine reclassifying after a coverage batch, not the code changing). Same note + JSON
|
|
@@ -547,11 +557,21 @@ switch (cmd) {
|
|
|
547
557
|
// §3.3.1: like diff, two positional locators <current> <baseline> (no discovery), each resolved by
|
|
548
558
|
// the shared locator rule; --json accepted.
|
|
549
559
|
const { positionals } = parseCanonical(args, {});
|
|
560
|
+
if (positionals.length < 2) { console.error("usage: candor-ts-query gains <current> <baseline> [--json]"); process.exit(2); }
|
|
550
561
|
const [curPrefix, basePrefix] = positionals.map(locatorToPrefix);
|
|
562
|
+
// BOTH locators must name real report files (the Rust engine's no-files check, named per side):
|
|
563
|
+
// a typo'd prefix loaded [] with hardFail=false and emitted an authoritative EMPTY
|
|
564
|
+
// {gained:[],byFunction:[]} at exit 0 — a silent all-clear on the supply-chain ALARM verb.
|
|
565
|
+
if (!hasReport(curPrefix)) { console.error(`candor-ts: no report files at current prefix '${curPrefix}' — check the path.`); process.exit(2); }
|
|
566
|
+
if (!hasReport(basePrefix)) { console.error(`candor-ts: no report files at baseline prefix '${basePrefix}' — check the path.`); process.exit(2); }
|
|
551
567
|
const gv = reportVersion(curPrefix), gbv = reportVersion(basePrefix);
|
|
552
568
|
if (gv && gbv && gv !== gbv)
|
|
553
569
|
console.error(`candor-ts: ⚠ baseline @${gbv} ≠ engine @${gv} — a "gained capability" may be the engine reclassifying, not the dependency changing. Regenerate both reports with one build to compare releases.`);
|
|
554
|
-
|
|
570
|
+
// ⟨spec 0.12 staged⟩ the BASELINE callgraph feeds byFunction[].origin (existing/new/unknown) —
|
|
571
|
+
// a MISSING sidecar loads {} and a corrupt (matched-but-unparseable) one is tagged `partial`
|
|
572
|
+
// with its edges dropped-and-disclosed: either way "new" is unavailable and origin falls back
|
|
573
|
+
// to "unknown" — the JSON itself discloses, never guessing "new" over a truncated graph.
|
|
574
|
+
emit({ baseline_version: gbv ?? "", engine_version: gv ?? "", ...coreGains(loadReportOrDie(curPrefix), loadReportOrDie(basePrefix), loadCallgraph(basePrefix)) });
|
|
555
575
|
break;
|
|
556
576
|
}
|
|
557
577
|
case "path": {
|
|
@@ -563,7 +583,13 @@ switch (cmd) {
|
|
|
563
583
|
const fns = loadReportOrDie(prefix);
|
|
564
584
|
const cg = loadCallgraph(prefix);
|
|
565
585
|
if (wantJson) emit(corePath(fns, cg, fn, eff)); // conformance PART 5 shape — UNCHANGED
|
|
566
|
-
else
|
|
586
|
+
else {
|
|
587
|
+
// The accepted 0.11 default change (the human chain replaced JSON as the no-flag output) gets a
|
|
588
|
+
// ONE-line stderr breadcrumb, so a pre-0.11 pipeline that broke on the new default is pointed at
|
|
589
|
+
// --json rather than left guessing. stderr only — stdout stays the human chain; --json untouched.
|
|
590
|
+
console.error("candor-ts-query: tip — `--json` selects the machine-readable path shape (the default before 0.11)");
|
|
591
|
+
renderPathHuman(fns, cg, fn, eff);
|
|
592
|
+
}
|
|
567
593
|
break;
|
|
568
594
|
}
|
|
569
595
|
case "whatif": {
|
package/scan-core.mjs
CHANGED
|
@@ -185,7 +185,25 @@ export const KAPPA_RULES = [
|
|
|
185
185
|
[/^drizzle-orm$/, /^(execute|transaction|findMany|findFirst|all|get|run)$/, "Db"],
|
|
186
186
|
// Nest's HttpService wraps axios — the request verbs are Net.
|
|
187
187
|
[/^@nestjs\/axios$/, /^(get|post|put|patch|delete|head|request)$/, "Net"],
|
|
188
|
+
// SPEC §1 ⟨0.13⟩ `Llm` model-SDK surface — the curated model-provider clients (Rules.MODEL_SDK_PACKAGES
|
|
189
|
+
// in the java reference). These are SINGLE-PURPOSE: any call into them dispatches a model request, which
|
|
190
|
+
// IS network I/O — so they classify Net here (the whole-module Net machinery: host literals, the masking
|
|
191
|
+
// gate) and the classify site adds `Llm` on top via isModelSdkPackage() (Net is never dropped). NO
|
|
192
|
+
// method-name gating (java parity #1: any call into a model-SDK package is Llm+Net). Sub-path imports
|
|
193
|
+
// (`openai/resources`, `@langchain/core/language_models`) are covered by the `(/|$)` tail. Curated
|
|
194
|
+
// STARTER list — the §7 coverage ledger discloses an uncovered provider package like any other.
|
|
195
|
+
[/^(openai|@anthropic-ai\/sdk|@google\/generative-ai|@aws-sdk\/client-bedrock-runtime|ai|@mistralai\/mistralai|cohere-ai|groq-sdk|ollama|langchain|@langchain\/core)(\/|$)/,
|
|
196
|
+
null, "Net"],
|
|
188
197
|
];
|
|
198
|
+
// SPEC §1 ⟨0.13⟩ `Llm` model-SDK packages — the curated model-provider clients whose calls refine Net to
|
|
199
|
+
// Llm (mirrors Literals.modelHostEffects on the SDK side; matched by the same regex the KAPPA_RULES Net
|
|
200
|
+
// entry uses, so the two can never drift). isModelSdkPackage answers "is this resolved module a model
|
|
201
|
+
// SDK?" — a call into it is Llm+Net (Net comes from the κ rule above; the classify site adds Llm).
|
|
202
|
+
export const MODEL_SDK_RE =
|
|
203
|
+
/^(openai|@anthropic-ai\/sdk|@google\/generative-ai|@aws-sdk\/client-bedrock-runtime|ai|@mistralai\/mistralai|cohere-ai|groq-sdk|ollama|langchain|@langchain\/core)(\/|$)/;
|
|
204
|
+
export function isModelSdkPackage(moduleName) {
|
|
205
|
+
return MODEL_SDK_RE.test(moduleName);
|
|
206
|
+
}
|
|
189
207
|
export function kappa(moduleName, member) {
|
|
190
208
|
for (const [mre, vre, eff] of KAPPA_RULES) {
|
|
191
209
|
if (mre.test(moduleName) && (!vre || vre.test(member))) return eff;
|
|
@@ -228,6 +246,53 @@ export function hostLiteral(s) {
|
|
|
228
246
|
if (/^[a-z0-9._-]+(:\d+)?$/i.test(s) && s.includes(".")) return s; // bare host[.tld][:port]
|
|
229
247
|
return null;
|
|
230
248
|
}
|
|
249
|
+
// SPEC §1 ⟨0.13⟩ `Llm` HOST-LITERAL refinement — the known machine-learning model-provider hosts. A
|
|
250
|
+
// statically-known Net request to one of these classifies `Llm` IN ADDITION to `Net` (Net is never
|
|
251
|
+
// dropped — a model call IS network I/O), just as a jdbc URL classifies `Db`. The four reference engines
|
|
252
|
+
// share this table VERBATIM (java Literals.MODEL_HOSTS). Matched by host, case-insensitive; a SUBDOMAIN
|
|
253
|
+
// of a listed host counts. Curated STARTER set; the §7 coverage ledger discloses an uncovered provider.
|
|
254
|
+
export const MODEL_HOSTS = new Set([
|
|
255
|
+
"api.openai.com",
|
|
256
|
+
"api.anthropic.com",
|
|
257
|
+
"generativelanguage.googleapis.com",
|
|
258
|
+
"api.mistral.ai",
|
|
259
|
+
"api.cohere.ai", "api.cohere.com",
|
|
260
|
+
"api.groq.com",
|
|
261
|
+
"api.together.xyz",
|
|
262
|
+
"api.perplexity.ai",
|
|
263
|
+
"openrouter.ai",
|
|
264
|
+
]);
|
|
265
|
+
// Whether an endpoint HOST literal is a known model provider (case-insensitive; a subdomain of a
|
|
266
|
+
// MODEL_HOSTS entry counts). Strips a `:port` suffix first. Two special forms carry their own rule (java
|
|
267
|
+
// Literals.isModelHost parity): any host whose port is 11434 is a local Ollama endpoint
|
|
268
|
+
// (`localhost:11434`, `127.0.0.1:11434`); and an AWS Bedrock runtime host `*.bedrock*.amazonaws.com`
|
|
269
|
+
// (host CONTAINS "bedrock" AND ends `.amazonaws.com` — java parity #4).
|
|
270
|
+
// Ollama is a LOCAL endpoint: :11434 → Llm ONLY on a loopback host (max-review r3 parity fix — "any host
|
|
271
|
+
// on :11434" fabricated Llm on an unrelated internal service). Bedrock matches the EXACT model-inference
|
|
272
|
+
// service label, not the substring "bedrock" (which caught `bedrock-backups.s3.amazonaws.com`, an S3 bucket).
|
|
273
|
+
const OLLAMA_LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
|
|
274
|
+
const BEDROCK_RUNTIME_LABELS = new Set(["bedrock-runtime", "bedrock-agent-runtime"]);
|
|
275
|
+
export function isModelHost(hostLiteral) {
|
|
276
|
+
if (hostLiteral == null) return false;
|
|
277
|
+
// hostPart: strip a trailing :port (keep a bracketed/unbracketed IPv6 intact, like policy.hostPart);
|
|
278
|
+
// also recover the port for the Ollama loopback check.
|
|
279
|
+
let host = hostLiteral, port = null;
|
|
280
|
+
if (host.startsWith("[")) { const e = host.indexOf("]"); if (e >= 0) { const rest = host.slice(e + 1); if (rest.startsWith(":")) port = rest.slice(1); host = host.slice(1, e); } }
|
|
281
|
+
else if ((host.match(/:/g) ?? []).length === 1) { const p = host.split(":"); host = p[0]; port = p[1]; }
|
|
282
|
+
host = host.toLowerCase();
|
|
283
|
+
if (port === "11434") return OLLAMA_LOCAL_HOSTS.has(host); // Ollama: loopback only
|
|
284
|
+
if (MODEL_HOSTS.has(host)) return true;
|
|
285
|
+
for (const m of MODEL_HOSTS) if (host.endsWith("." + m)) return true; // a subdomain counts
|
|
286
|
+
// AWS Bedrock runtime: the FIRST label is the model-inference service (bedrock-runtime.<region>.amazonaws.com).
|
|
287
|
+
if (host.endsWith(".amazonaws.com") && BEDROCK_RUNTIME_LABELS.has(host.split(".")[0])) return true;
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
// The effects a model-host literal implies: ["Llm"] for a known model host, else []. Shared with the
|
|
291
|
+
// sibling engines like commandHeadEffects; `Net` is added by the caller (the host was captured on a
|
|
292
|
+
// Net-bearing call), so this returns ONLY the refinement.
|
|
293
|
+
export function modelHostEffects(hostLiteral) {
|
|
294
|
+
return isModelHost(hostLiteral) ? ["Llm"] : [];
|
|
295
|
+
}
|
|
231
296
|
// Table-position identifiers in a SQL string literal (SPEC §2 `tables`). Mirrors the Rust
|
|
232
297
|
// tables_in_sql exactly: must open with a statement keyword; FROM/JOIN/INTO anywhere,
|
|
233
298
|
// statement-leading UPDATE/TRUNCATE, TABLE (skipping ONLY/IF NOT EXISTS); a FOR UPDATE locking
|
package/scan.mjs
CHANGED
|
@@ -29,7 +29,8 @@ import { createRequire } from "node:module";
|
|
|
29
29
|
import { parsePolicy, evaluatePolicy, scopeMatches } from "./policy.mjs";
|
|
30
30
|
import { unverifiedHoleRule, ruleUpgrade } from "./query-core.mjs";
|
|
31
31
|
import { printAgents } from "./contract.mjs";
|
|
32
|
-
import { isTestPath, kappa, kappaKnows, commandHeadEffects, hostLiteral, tablesInSql
|
|
32
|
+
import { isTestPath, kappa, kappaKnows, commandHeadEffects, hostLiteral, tablesInSql,
|
|
33
|
+
modelHostEffects, isModelHost, isModelSdkPackage } from "./scan-core.mjs";
|
|
33
34
|
import { emitSurface } from "./surface.mjs";
|
|
34
35
|
|
|
35
36
|
const ENGINE_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -40,7 +41,7 @@ const ENGINE_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
|
40
41
|
// literal stamped into the envelope's `spec` field, so the doc lines and the report can never drift.
|
|
41
42
|
// Reused, never re-littered.
|
|
42
43
|
const PKG_VERSION = JSON.parse(fs.readFileSync(path.join(ENGINE_DIR, "package.json"), "utf8")).version;
|
|
43
|
-
const SPEC_VERSION = "0.
|
|
44
|
+
const SPEC_VERSION = "0.13";
|
|
44
45
|
|
|
45
46
|
// --version: a print-and-exit MODE, handled before the main arg walk so it never depends on a target.
|
|
46
47
|
// Fully OFFLINE — candor never phones home. Staying current is the AGENT's job: read the installed
|
|
@@ -391,7 +392,7 @@ function declModule(decl) {
|
|
|
391
392
|
// silent pure/blind-spot the package would otherwise carry, exactly like a cap type (and unlike
|
|
392
393
|
// candor's own analysis, which is checked). A name outside §1 VOIDS the declaration loudly — a typo
|
|
393
394
|
// must never silently narrow a surface. Cached per package. `file` is the resolved declaration source.
|
|
394
|
-
const EFFECT_VOCAB = new Set(["Net", "Fs", "Db", "Exec", "Env", "Clock", "Ipc", "Log", "Rand", "Clipboard"]);
|
|
395
|
+
const EFFECT_VOCAB = new Set(["Net", "Fs", "Db", "Exec", "Env", "Clock", "Ipc", "Log", "Rand", "Clipboard", "Llm"]);
|
|
395
396
|
const _manifestCache = new Map();
|
|
396
397
|
// Returns the declared effect array (possibly EMPTY — `[]` is an explicit "declared pure", covered, not
|
|
397
398
|
// a blind spot), or `null` for no/invalid declaration (still a blind spot). A name outside §1 voids the
|
|
@@ -467,6 +468,66 @@ function programHeadLiteral(node) {
|
|
|
467
468
|
const a0 = (node.arguments ?? [])[0];
|
|
468
469
|
return a0 && ts.isStringLiteralLike(a0) ? a0.text : null;
|
|
469
470
|
}
|
|
471
|
+
// The URL/endpoint literal of a host-bearing Net call, read from the DOCUMENTED URL arg position — a host
|
|
472
|
+
// predicate must run against the extracted URL argument, never the first literal ANYWHERE in the args:
|
|
473
|
+
// `fetch(runtimeUrl, "some-literal")` must NOT read the trailing literal (headers/body/options) as the
|
|
474
|
+
// host (the programHeadLiteral discipline, generalized from Exec to Net — FINDING 6). Position is
|
|
475
|
+
// member-aware: the HTTP verbs (fetch/get/post/put/patch/delete/head/options/request) take the URL FIRST;
|
|
476
|
+
// net.connect/createConnection put the host at arg1 in the `(port, host)` overload (and arg0 is a path/
|
|
477
|
+
// options in the other overloads) — so those two members read arg0-or-arg1. Only STRING-LITERAL positions
|
|
478
|
+
// are considered; returns null when the URL slot is not a static string literal — the safe direction.
|
|
479
|
+
const NET_URL_ARG1_MEMBERS = new Set(["connect", "createConnection"]);
|
|
480
|
+
function urlArgLiteral(node, member) {
|
|
481
|
+
const args = node.arguments ?? [];
|
|
482
|
+
const litAt = (i) => (args[i] && ts.isStringLiteralLike(args[i]) ? args[i].text : null);
|
|
483
|
+
if (member && NET_URL_ARG1_MEMBERS.has(member)) return litAt(0) ?? litAt(1); // (port, host) or (path)
|
|
484
|
+
return litAt(0);
|
|
485
|
+
}
|
|
486
|
+
// Is arg0 a RUNTIME STRING expression whose host can't be known statically — a template, a string
|
|
487
|
+
// concat, or a `string`-typed variable/member/call? Only THIS shape masks the host and must fail the
|
|
488
|
+
// surface closed. A STRUCTURED url arg (`new URL(...)`, a `Request` object, any non-string value) carries
|
|
489
|
+
// its host in a form the literal gate never saw, but it did not mask a literal that WAS there — pre-Llm
|
|
490
|
+
// behavior added Net and moved on, so it must NOT regress to fail-closed. Absent arg0 → not a masking
|
|
491
|
+
// string either (never fabricate incompleteness). A static string literal is handled by urlArgLiteral, so
|
|
492
|
+
// it is excluded here.
|
|
493
|
+
function urlArgIsRuntimeString(node) {
|
|
494
|
+
const a0 = (node.arguments ?? [])[0];
|
|
495
|
+
if (!a0 || ts.isStringLiteralLike(a0)) return false;
|
|
496
|
+
if (ts.isTemplateExpression(a0)) return true; // `${base}/path` — host built at runtime
|
|
497
|
+
if (ts.isBinaryExpression(a0) && a0.operatorToken.kind === ts.SyntaxKind.PlusToken) return true; // concat
|
|
498
|
+
// a variable/member/call arg: a masking runtime host only when its static type is `string` (a
|
|
499
|
+
// `new URL()`/`Request`/other object is NOT a string type — leave it clean, as before the Llm port).
|
|
500
|
+
const t = checker.getTypeAtLocation(a0);
|
|
501
|
+
return t ? (t.flags & (ts.TypeFlags.String | ts.TypeFlags.StringLiteral)) !== 0 : false;
|
|
502
|
+
}
|
|
503
|
+
// The Ollama local-endpoint decision (java Literals parity #2), routed through the EXTRACTED host, never
|
|
504
|
+
// a raw literal that merely CONTAINS ":11434". `urlLit` is arg0's string text; `host` is hostLiteral(urlLit)
|
|
505
|
+
// (null when arg0 didn't parse as a structured host/URL). Returns "capture" (a dotted model/Ollama host
|
|
506
|
+
// hostLiteral kept — the caller captures it and adds modelHostEffects), "llm-no-capture" (a DOTLESS
|
|
507
|
+
// `localhost:11434`/`127.0.0.1:11434` — refine to Llm but do NOT capture the host as a Net allowlist
|
|
508
|
+
// literal, so the host gate stays intact: java parity #2), or null (no model signal). CRITICAL: the
|
|
509
|
+
// :11434 → Llm rule fires ONLY when arg0 parsed as a STRUCTURED host:port whose port is 11434 — a raw
|
|
510
|
+
// relative path like `/v1/models:11434/generate` never parses as a host, so it can never fabricate Llm.
|
|
511
|
+
function isDotlessLocalOllama(host) {
|
|
512
|
+
if (host == null) return false;
|
|
513
|
+
const colon = host.lastIndexOf(":");
|
|
514
|
+
if (colon < 0 || host.slice(colon + 1) !== "11434") return false;
|
|
515
|
+
const hostPart = host.slice(0, colon).toLowerCase();
|
|
516
|
+
return hostPart === "localhost" || hostPart === "127.0.0.1"; // dotless local endpoint only
|
|
517
|
+
}
|
|
518
|
+
function ollamaFromUrlArg(urlLit) {
|
|
519
|
+
if (urlLit == null) return null;
|
|
520
|
+
// Parse arg0 as a host[:port] the same way the capture path does. `scheme://host[:port]/…` yields the
|
|
521
|
+
// authority even when the host is dotless; a bare `foo.internal:11434` yields itself; a relative path
|
|
522
|
+
// (`/v1/x:11434/y`) parses to NOTHING → never a host, so it can never fabricate Llm (FINDING 1).
|
|
523
|
+
const parsed = hostLiteral(urlLit);
|
|
524
|
+
if (parsed == null) return null;
|
|
525
|
+
// FINDING 9: a DOTLESS local Ollama endpoint (`http://localhost:11434/…`) refines to Llm WITHOUT
|
|
526
|
+
// capturing the host as a Net allowlist literal (java parity #2 — preserve the host gate). A DOTTED
|
|
527
|
+
// model/Ollama host (`foo.internal:11434`, `api.anthropic.com`) is captured as before.
|
|
528
|
+
if (isDotlessLocalOllama(parsed)) return "llm-no-capture";
|
|
529
|
+
return isModelHost(parsed) ? "capture-model" : "capture-plain";
|
|
530
|
+
}
|
|
470
531
|
// qualifies by the file's basename (`Cases.union_a`).
|
|
471
532
|
const fns = new Map(); // qualified name -> { direct, edges, hosts, tables, cmds, paths, loc }
|
|
472
533
|
const unlistedSeen = new Map(); // the κ-coverage ledger: unlisted npm package -> call-site count
|
|
@@ -1654,18 +1715,37 @@ function visitCalls(node) {
|
|
|
1654
1715
|
}
|
|
1655
1716
|
// the literal surfaces, read only at a CLASSIFIED call (SPEC §2)
|
|
1656
1717
|
if (eff === "Net") {
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
//
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1718
|
+
// The host predicate runs against the EXTRACTED URL argument (arg0 — the URL/endpoint slot of
|
|
1719
|
+
// fetch/axios/the HTTP verbs), NEVER the first literal anywhere in the args: a trailing literal
|
|
1720
|
+
// in headers/body/options must not be read as the host (FINDING 6). Ollama's model decision runs
|
|
1721
|
+
// through the parsed host too, never a raw string that merely contains ":11434" (FINDING 1/9).
|
|
1722
|
+
const urlLit = urlArgLiteral(node, member);
|
|
1723
|
+
const ollama = ollamaFromUrlArg(urlLit);
|
|
1724
|
+
if (ollama === "capture-model" || ollama === "capture-plain") {
|
|
1725
|
+
const h = hostLiteral(urlLit);
|
|
1726
|
+
rec.hosts.add(h);
|
|
1727
|
+
// SPEC §1 ⟨0.13⟩ Llm host-literal refinement: a known model host makes this a model call
|
|
1728
|
+
// (Llm + Net — Net is never dropped), exactly as a jdbc URL classifies Db.
|
|
1729
|
+
for (const e of modelHostEffects(h)) rec.direct.add(e);
|
|
1730
|
+
} else {
|
|
1731
|
+
// No captured host literal. §1 ⟨0.13⟩ Ollama LOCAL endpoint (`localhost:11434`/`127.0.0.1:11434`):
|
|
1732
|
+
// refine to Llm but do NOT capture the host as a Net allowlist literal (java parity #2 —
|
|
1733
|
+
// preserve the host gate so `deny Llm` catches it while `allow Llm localhost` fails closed).
|
|
1734
|
+
if (ollama === "llm-no-capture") rec.direct.add("Llm");
|
|
1735
|
+
// MASKING fix: a host-ESTABLISHING Net call whose host is NOT a captured literal (runtime URL, or
|
|
1736
|
+
// built elsewhere) leaves the host invisible to the gate → mark the surface incomplete so a
|
|
1737
|
+
// benign literal can't mask it. ALLOWLIST of establishing forms only — NEVER use-calls
|
|
1738
|
+
// (write/end/non-dgram send), which would false-positive on `socket.connect("h").write(data)`
|
|
1739
|
+
// (the host is captured at connect). Under-catches an unlisted establishing verb (safe
|
|
1740
|
+
// direction); never over-flags a use-call.
|
|
1741
|
+
if (netEstablishing(member)) rec.incomplete.add("Net");
|
|
1742
|
+
}
|
|
1668
1743
|
}
|
|
1744
|
+
// SPEC §1 ⟨0.13⟩ `Llm` model-SDK surface: a call into a curated model-provider client (the
|
|
1745
|
+
// scan-core MODEL_SDK regex, also the whole-module Net κ rule above) dispatches a model request
|
|
1746
|
+
// → Llm + Net. Net came from κ (eff === "Net"); add Llm on top. NO method-name gating (java
|
|
1747
|
+
// parity #1) — any call into these single-purpose clients is a model dispatch. Additive.
|
|
1748
|
+
if (isModelSdkPackage(mod)) rec.direct.add("Llm");
|
|
1669
1749
|
if (eff === "Db") {
|
|
1670
1750
|
const lit = firstStringLiteral(node);
|
|
1671
1751
|
const before = rec.tables.size;
|
|
@@ -1818,7 +1898,33 @@ function visitCalls(node) {
|
|
|
1818
1898
|
geff = "Net";
|
|
1819
1899
|
if (geff) {
|
|
1820
1900
|
const owner = enclosing(node);
|
|
1821
|
-
if (owner)
|
|
1901
|
+
if (owner) {
|
|
1902
|
+
const rec = fns.get(owner);
|
|
1903
|
+
rec.direct.add(geff); // Net is added unconditionally for a global fetch (never gated on host capture)
|
|
1904
|
+
// The global `fetch(url)` is a host-bearing Net call — capture its URL-ARGUMENT host (arg0, like the
|
|
1905
|
+
// κ-Net path) so the allowlist/masking gate sees it, and refine to `Llm` on a known model host (SPEC
|
|
1906
|
+
// §1 ⟨0.13⟩). Without this, `fetch("https://api.anthropic.com/…")` read bare Net with no host at all.
|
|
1907
|
+
if (geff === "Net") {
|
|
1908
|
+
// Host predicate runs against arg0 (the URL slot), NEVER the first literal anywhere in the args:
|
|
1909
|
+
// `fetch(runtimeUrl, "literal")` must not read the trailing literal as the host (FINDING 6). Ollama's
|
|
1910
|
+
// model decision runs through the parsed host, never a raw ":11434" substring (FINDING 1/9).
|
|
1911
|
+
const urlLit = urlArgLiteral(node);
|
|
1912
|
+
const ollama = ollamaFromUrlArg(urlLit);
|
|
1913
|
+
if (ollama === "capture-model" || ollama === "capture-plain") {
|
|
1914
|
+
const h = hostLiteral(urlLit);
|
|
1915
|
+
rec.hosts.add(h);
|
|
1916
|
+
for (const e of modelHostEffects(h)) rec.direct.add(e);
|
|
1917
|
+
} else if (ollama === "llm-no-capture") {
|
|
1918
|
+
// §1 ⟨0.13⟩ dotless local Ollama endpoint: Llm WITHOUT capturing the host (java parity #2).
|
|
1919
|
+
rec.direct.add("Llm");
|
|
1920
|
+
} else if (urlArgIsRuntimeString(node)) {
|
|
1921
|
+
// Only a RUNTIME STRING url (template/concat/`string`-typed value) masks the host → fail closed,
|
|
1922
|
+
// like a host-establishing κ call. A structured `new URL(...)`/`Request` arg (or absent arg) did
|
|
1923
|
+
// NOT mask a literal — it passed clean pre-Llm-port, so it must NOT regress to fail-closed (FINDING 7).
|
|
1924
|
+
rec.incomplete.add("Net");
|
|
1925
|
+
}
|
|
1926
|
+
}
|
|
1927
|
+
}
|
|
1822
1928
|
}
|
|
1823
1929
|
// dynamic `require(<non-literal>)` — the CJS twin of `import(m)` (which already discloses Unknown):
|
|
1824
1930
|
// it loads an arbitrary module and runs its top-level code, so the effects are opaque → Unknown. A
|
|
@@ -2182,7 +2288,7 @@ if (!wantJson) {
|
|
|
2182
2288
|
// Effect breakdown — make the result visible at a glance, not just a count + a file path.
|
|
2183
2289
|
const counts = {};
|
|
2184
2290
|
for (const e of functions) for (const x of e.inferred) counts[x] = (counts[x] || 0) + 1;
|
|
2185
|
-
const breakdown = ["Net", "Fs", "Db", "Exec", "Ipc", "Env", "Clipboard", "Clock", "Log", "Rand"]
|
|
2291
|
+
const breakdown = ["Net", "Llm", "Fs", "Db", "Exec", "Ipc", "Env", "Clipboard", "Clock", "Log", "Rand"]
|
|
2186
2292
|
.filter((k) => counts[k]).map((k) => `${k} ${counts[k]}`).join(" · ");
|
|
2187
2293
|
const unknown = counts.Unknown || 0;
|
|
2188
2294
|
if (breakdown || unknown) {
|
package/surface.mjs
CHANGED
|
@@ -84,7 +84,7 @@ function hasToken(name, lexicon) {
|
|
|
84
84
|
// Matches the Rust reference (candor-classify/src/surface.rs) + the java/swift ports.
|
|
85
85
|
function salience(effect) {
|
|
86
86
|
switch (effect) {
|
|
87
|
-
case "Net": case "Exec": case "Db": case "Ipc": return 5;
|
|
87
|
+
case "Net": case "Llm": case "Exec": case "Db": case "Ipc": return 5;
|
|
88
88
|
case "Fs": case "Env": return 3;
|
|
89
89
|
default: return 0; // Clock/Log/Rand/Unknown/everything-else — mundane, never surfaced
|
|
90
90
|
}
|