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 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.11)."*
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). `npx -y candor-ts-watch <dir>` keeps the report fresh as you edit (and
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, plus two code actions (plain LSP — helix/neovim/VS Code/JetBrains-via-LSP4IJ all get them
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.11" }, functions }` envelope; pure fns omitted | SPEC §2/§2.1 |
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.11.x, speaking candor-spec 0.11: the analysis core, the gate (`--policy` / `--gate-json` /
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
- const diags = diagnosticsFor(docPath).concat(transient.get(uri) ?? []);
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/didChange drop the file's transient whatif overlay — a fresh look at the file (or an
395
- // edit) invalidates a hypothetical answered against the previous state. didChange is not negotiated
396
- // (change: 0) but is handled defensively for clients that send it anyway.
397
- if (method === "textDocument/didOpen") { transient.delete(params.textDocument.uri); return publishDiagnostics(params.textDocument.uri); }
398
- if (method === "textDocument/didSave") { transient.delete(params.textDocument.uri); return publishDiagnostics(params.textDocument.uri); }
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(Q.loadReport(p), Q.loadCallgraph(p), a.fn)),
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(Q.loadReport(p), a.effect)),
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(Q.loadReport(p)),
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(Q.loadReport(p), Q.loadCallgraph(p), a.fn, a.effect),
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(Q.loadReport(p), a.fn),
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(Q.loadReport(p)),
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, Q.loadReport(p), a.fn, a.effect, parsePolicy(text), scopeMatches);
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), Q.loadReport(p), Q.loadCallgraph(p));
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(Q.loadReport(p), parsePolicy(text), scopeMatches);
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(Q.loadReport(p)),
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(Q.loadReport(p), Q.loadCallgraph(p))),
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) => ({ baseline_version: Q.reportVersion(a.baseline) ?? "", engine_version: Q.reportVersion(p) ?? "",
252
- ...Q.diff(Q.loadReport(p), Q.loadReport(a.baseline)) }),
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) => ({ baseline_version: Q.reportVersion(a.baseline) ?? "", engine_version: Q.reportVersion(p) ?? "",
258
- ...Q.gains(Q.loadReport(p), Q.loadReport(a.baseline)) }),
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(Q.loadReport(prefix)) };
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
- const prefix = resolvePrefix(args);
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)), ...Q.loadReport(prefix).map((e) => e.fn)])];
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.11.0",
4
- "description": "candor for TypeScript — per-function side effects, transitively, with a policy gate (candor-spec 0.11)",
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
- const ALLOW_EFFECTS = new Set(["Net", "Exec", "Fs", "Db"]); // the four literal surfaces
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
- const surfaces = { Net: "hosts", Exec: "cmds", Fs: "paths", Db: "tables" };
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
- const surfaceIncomplete = incomplete.get(f.fn)?.has(r.effect);
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
- try { return norm(JSON.parse(fs.readFileSync(`${prefix}.callgraph.json`, "utf8"))); }
185
- 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 {}; }
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
- export function gains(curFns, baseFns) {
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, effect: e }); }
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. corePath resolves over the callgraph keys for the chain; the two agree
53
- // on any fn that has an entry, which every graphed fn does.
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, fnQ, eff);
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.11";
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
- emit({ baseline_version: gbv ?? "", engine_version: gv ?? "", ...coreGains(loadReportOrDie(curPrefix), loadReportOrDie(basePrefix)) });
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 renderPathHuman(fns, cg, fn, eff);
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 } from "./scan-core.mjs";
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.11";
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
- const lit = firstStringLiteral(node);
1658
- const h = lit && hostLiteral(lit);
1659
- if (h) rec.hosts.add(h);
1660
- // MASKING fix: a host-ESTABLISHING Net call whose host is NOT a captured literal (runtime URL, or
1661
- // built elsewhere) leaves the host invisible to the gate → mark the surface incomplete so a
1662
- // benign literal can't mask it. ALLOWLIST of establishing forms only — NEVER use-calls
1663
- // (write/end/non-dgram send), which would false-positive on `socket.connect("h").write(data)`
1664
- // (the host is captured at connect). Under-catches an unlisted establishing verb (safe
1665
- // direction); never over-flags a use-call.
1666
- else if (netEstablishing(member))
1667
- rec.incomplete.add("Net");
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) fns.get(owner).direct.add(geff);
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
  }