candor-ts 0.12.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 +8 -3
- package/README.md +2 -2
- package/lsp.mjs +145 -8
- package/mcp.mjs +100 -8
- package/package.json +2 -2
- package/policy.mjs +14 -5
- package/query-core.mjs +1 -1
- package/query.mjs +1 -1
- 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
|
>
|
|
@@ -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,6 +48,17 @@ 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; };
|
|
@@ -261,10 +272,10 @@ const TOOLS = {
|
|
|
261
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?'.",
|
|
262
273
|
schema: { type: "object", properties: { baseline: { type: "string", description: "the baseline report prefix" }, ...reportArg }, required: ["baseline"] },
|
|
263
274
|
run: (a, p) => {
|
|
264
|
-
//
|
|
265
|
-
//
|
|
266
|
-
//
|
|
267
|
-
const b =
|
|
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);
|
|
268
279
|
return { baseline_version: Q.reportVersion(b) ?? "", engine_version: Q.reportVersion(p) ?? "",
|
|
269
280
|
...Q.diff(loadReportLoud(p), loadReportLoud(b)) };
|
|
270
281
|
},
|
|
@@ -273,9 +284,10 @@ const TOOLS = {
|
|
|
273
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?'.",
|
|
274
285
|
schema: { type: "object", properties: { baseline: { type: "string", description: "the baseline report prefix" }, ...reportArg }, required: ["baseline"] },
|
|
275
286
|
run: (a, p) => {
|
|
276
|
-
// Same baseline existence
|
|
277
|
-
// typo'd baseline is a silent all-clear on the supply-chain ALARM
|
|
278
|
-
|
|
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);
|
|
279
291
|
// ⟨spec 0.12 staged⟩ baseline callgraph → byFunction[].origin, same as the CLI (parity). The
|
|
280
292
|
// loader's non-enumerable `partial` tag rides along: a corrupt baseline sidecar (edges dropped,
|
|
281
293
|
// disclosed) downgrades origin to "unknown", never a fabricated "new" over a truncated graph.
|
|
@@ -283,8 +295,86 @@ const TOOLS = {
|
|
|
283
295
|
...Q.gains(loadReportLoud(p), loadReportLoud(b), Q.loadCallgraph(b)) };
|
|
284
296
|
},
|
|
285
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),
|
|
308
|
+
},
|
|
286
309
|
};
|
|
287
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
|
+
|
|
288
378
|
// ---- MCP resources: the report + the checked-in policy, readable directly --------------------------
|
|
289
379
|
function listResources(prefix) {
|
|
290
380
|
const res = [{ uri: `candor://report?prefix=${encodeURIComponent(prefix)}`, name: "candor report",
|
|
@@ -354,7 +444,9 @@ function handle(msg) {
|
|
|
354
444
|
const missing = (t.schema.required || []).filter((k) => args[k] === undefined || args[k] === "");
|
|
355
445
|
if (missing.length)
|
|
356
446
|
return result(id, { content: [{ type: "text", text: `candor: missing required argument(s): ${missing.join(", ")}` }], isError: true });
|
|
357
|
-
|
|
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);
|
|
358
450
|
// A tool that targets a `fn` gets a clear "not found" rather than a silently-empty result —
|
|
359
451
|
// an agent must distinguish "no such function" from "found, nothing calls it".
|
|
360
452
|
if (args.fn !== undefined) {
|
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
|
@@ -343,7 +343,7 @@ export function map(fns) {
|
|
|
343
343
|
// with the AS-EFF-010 ratchet when a baseline is given. Mirrors candor-java Query.containment and
|
|
344
344
|
// candor-query cmd_containment: boundary effects are scored, ambient ones reported-not-scored; a layer is
|
|
345
345
|
// the segment AFTER the common dotted prefix ("(root)" when no package layer follows). Uses DIRECT effects.
|
|
346
|
-
export const CONTAINED = ["Db", "Net", "Exec", "Fs", "Ipc", "Clipboard"];
|
|
346
|
+
export const CONTAINED = ["Db", "Net", "Llm", "Exec", "Fs", "Ipc", "Clipboard"];
|
|
347
347
|
export const AMBIENT = ["Log", "Clock", "Rand", "Env"];
|
|
348
348
|
function commonPrefixLen(fns) {
|
|
349
349
|
let best = null;
|
package/query.mjs
CHANGED
|
@@ -94,7 +94,7 @@ function renderPathHuman(fns, cg, fnQ, eff) {
|
|
|
94
94
|
// package.json; SPEC_VERSION is the spec contract this build speaks. Reused, never re-littered.
|
|
95
95
|
const QUERY_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
96
96
|
const PKG_VERSION = JSON.parse(fs.readFileSync(path.join(QUERY_DIR, "package.json"), "utf8")).version;
|
|
97
|
-
const SPEC_VERSION = "0.
|
|
97
|
+
const SPEC_VERSION = "0.13";
|
|
98
98
|
|
|
99
99
|
// ---- the §3.3.1 canonical query grammar (⟨0.10⟩, additive over 0.9) --------------------------------
|
|
100
100
|
// One shape for every verb: `<verb> <verb-args…> [--report <locator>] [--policy <file>] [--json]
|
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
|
}
|