candor-ts 0.12.0 → 0.14.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.12)."*
15
+ > plainly which version they're on** — e.g. *"This project is on candor-ts `<version>` (spec 0.14)."*
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). `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.12" }, functions }` envelope; pure fns omitted | SPEC §2/§2.1 |
187
+ | `{ candor: { version, toolchain, spec: "0.14" }, 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.12.x, speaking candor-spec 0.12: the analysis core, the gate (`--policy` / `--gate-json` /
205
+ 0.14.x, speaking candor-spec 0.14: 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,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
- // The BASELINE locator gets the SAME existence + --root confinement checks as the main report
265
- // (resolvePrefix) — a typo'd baseline loaded [] with hardFail=false and diffed as an
266
- // authoritative empty {changes:[]} (the CLI now exits 2 on the same miss).
267
- const b = resolvePrefix({ report: a.baseline });
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 + --root confinement as candor_diff — an empty {gained:[]} over a
277
- // typo'd baseline is a silent all-clear on the supply-chain ALARM tool.
278
- const b = resolvePrefix({ report: a.baseline });
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
- 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);
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.12.0",
4
- "description": "candor for TypeScript — per-function side effects, transitively, with a policy gate (candor-spec 0.12)",
3
+ "version": "0.14.0",
4
+ "description": "candor for TypeScript — per-function side effects, transitively, with a policy gate (candor-spec 0.14)",
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
@@ -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.12";
97
+ const SPEC_VERSION = "0.14";
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 } 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.12";
44
+ const SPEC_VERSION = "0.14";
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
@@ -1030,6 +1091,29 @@ function enumerateGetters(owner, type) {
1030
1091
  }
1031
1092
  }
1032
1093
 
1094
+ // The synthesized `<module>` unit for a source file's TOP-LEVEL executable statements (spec §2
1095
+ // unitKind "initializer" — java's `<clinit>` twin). Top-level `await fetch(…)`, a bare
1096
+ // `readFileSync(…)`, an IIFE, `export const r = await fetch(…)` execute at MODULE-LOAD time and
1097
+ // belong to nothing named — without this unit their effects reached the SourceFile in `enclosing`,
1098
+ // resolved to `null`, and were DROPPED → a false "pure" verdict (the cardinal sin: ESM top-level
1099
+ // await / serverless handler files / side-effecting config modules scanned as functions: []). This
1100
+ // is the field-initializer `Class.constructor` synthesis (~scan.mjs:774) one level up: the module
1101
+ // body is the file's own initializer. Minted LAZILY — only when a top-level statement actually
1102
+ // attributes an effect/edge here — so a pure top-level never gains a unit (pure units are omitted).
1103
+ // The qual mirrors sibling top-level units (`moduleOf(sf).<module>`); the bare local is `<module>`.
1104
+ function moduleUnit(sf) {
1105
+ const mod = moduleOf(sf);
1106
+ const qual = `${mod}.<module>`;
1107
+ let rec = fns.get(qual);
1108
+ if (!rec) {
1109
+ rec = { local: "<module>", direct: new Set(), edges: new Set(), hosts: new Set(), tables: new Set(),
1110
+ cmds: new Set(), paths: new Set(), blind: new Set(), incomplete: new Set(), why: new Set(),
1111
+ entry: false, unitKind: "initializer",
1112
+ loc: `${path.relative(rootDir, sf.fileName)}:1:1` };
1113
+ fns.set(qual, rec);
1114
+ }
1115
+ return qual;
1116
+ }
1033
1117
  // nearest enclosing analyzed function (closures attribute to it — SEMANTICS §2)
1034
1118
  function enclosing(node) {
1035
1119
  for (let p = node; p; p = p.parent) {
@@ -1043,6 +1127,9 @@ function enclosing(node) {
1043
1127
  if (ts.isDecorator(p)) return null;
1044
1128
  const n = nodeName.get(p);
1045
1129
  if (n) return n;
1130
+ // Reached the SourceFile with no named unit: a TOP-LEVEL executable statement. Attribute to the
1131
+ // file's synthesized `<module>` initializer unit (minted lazily here) rather than dropping it.
1132
+ if (ts.isSourceFile(p)) return moduleUnit(p);
1046
1133
  }
1047
1134
  return null;
1048
1135
  }
@@ -1654,18 +1741,37 @@ function visitCalls(node) {
1654
1741
  }
1655
1742
  // the literal surfaces, read only at a CLASSIFIED call (SPEC §2)
1656
1743
  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");
1744
+ // The host predicate runs against the EXTRACTED URL argument (arg0 — the URL/endpoint slot of
1745
+ // fetch/axios/the HTTP verbs), NEVER the first literal anywhere in the args: a trailing literal
1746
+ // in headers/body/options must not be read as the host (FINDING 6). Ollama's model decision runs
1747
+ // through the parsed host too, never a raw string that merely contains ":11434" (FINDING 1/9).
1748
+ const urlLit = urlArgLiteral(node, member);
1749
+ const ollama = ollamaFromUrlArg(urlLit);
1750
+ if (ollama === "capture-model" || ollama === "capture-plain") {
1751
+ const h = hostLiteral(urlLit);
1752
+ rec.hosts.add(h);
1753
+ // SPEC §1 ⟨0.13⟩ Llm host-literal refinement: a known model host makes this a model call
1754
+ // (Llm + Net — Net is never dropped), exactly as a jdbc URL classifies Db.
1755
+ for (const e of modelHostEffects(h)) rec.direct.add(e);
1756
+ } else {
1757
+ // No captured host literal. §1 ⟨0.13⟩ Ollama LOCAL endpoint (`localhost:11434`/`127.0.0.1:11434`):
1758
+ // refine to Llm but do NOT capture the host as a Net allowlist literal (java parity #2 —
1759
+ // preserve the host gate so `deny Llm` catches it while `allow Llm localhost` fails closed).
1760
+ if (ollama === "llm-no-capture") rec.direct.add("Llm");
1761
+ // MASKING fix: a host-ESTABLISHING Net call whose host is NOT a captured literal (runtime URL, or
1762
+ // built elsewhere) leaves the host invisible to the gate → mark the surface incomplete so a
1763
+ // benign literal can't mask it. ALLOWLIST of establishing forms only — NEVER use-calls
1764
+ // (write/end/non-dgram send), which would false-positive on `socket.connect("h").write(data)`
1765
+ // (the host is captured at connect). Under-catches an unlisted establishing verb (safe
1766
+ // direction); never over-flags a use-call.
1767
+ if (netEstablishing(member)) rec.incomplete.add("Net");
1768
+ }
1668
1769
  }
1770
+ // SPEC §1 ⟨0.13⟩ `Llm` model-SDK surface: a call into a curated model-provider client (the
1771
+ // scan-core MODEL_SDK regex, also the whole-module Net κ rule above) dispatches a model request
1772
+ // → Llm + Net. Net came from κ (eff === "Net"); add Llm on top. NO method-name gating (java
1773
+ // parity #1) — any call into these single-purpose clients is a model dispatch. Additive.
1774
+ if (isModelSdkPackage(mod)) rec.direct.add("Llm");
1669
1775
  if (eff === "Db") {
1670
1776
  const lit = firstStringLiteral(node);
1671
1777
  const before = rec.tables.size;
@@ -1818,7 +1924,33 @@ function visitCalls(node) {
1818
1924
  geff = "Net";
1819
1925
  if (geff) {
1820
1926
  const owner = enclosing(node);
1821
- if (owner) fns.get(owner).direct.add(geff);
1927
+ if (owner) {
1928
+ const rec = fns.get(owner);
1929
+ rec.direct.add(geff); // Net is added unconditionally for a global fetch (never gated on host capture)
1930
+ // The global `fetch(url)` is a host-bearing Net call — capture its URL-ARGUMENT host (arg0, like the
1931
+ // κ-Net path) so the allowlist/masking gate sees it, and refine to `Llm` on a known model host (SPEC
1932
+ // §1 ⟨0.13⟩). Without this, `fetch("https://api.anthropic.com/…")` read bare Net with no host at all.
1933
+ if (geff === "Net") {
1934
+ // Host predicate runs against arg0 (the URL slot), NEVER the first literal anywhere in the args:
1935
+ // `fetch(runtimeUrl, "literal")` must not read the trailing literal as the host (FINDING 6). Ollama's
1936
+ // model decision runs through the parsed host, never a raw ":11434" substring (FINDING 1/9).
1937
+ const urlLit = urlArgLiteral(node);
1938
+ const ollama = ollamaFromUrlArg(urlLit);
1939
+ if (ollama === "capture-model" || ollama === "capture-plain") {
1940
+ const h = hostLiteral(urlLit);
1941
+ rec.hosts.add(h);
1942
+ for (const e of modelHostEffects(h)) rec.direct.add(e);
1943
+ } else if (ollama === "llm-no-capture") {
1944
+ // §1 ⟨0.13⟩ dotless local Ollama endpoint: Llm WITHOUT capturing the host (java parity #2).
1945
+ rec.direct.add("Llm");
1946
+ } else if (urlArgIsRuntimeString(node)) {
1947
+ // Only a RUNTIME STRING url (template/concat/`string`-typed value) masks the host → fail closed,
1948
+ // like a host-establishing κ call. A structured `new URL(...)`/`Request` arg (or absent arg) did
1949
+ // NOT mask a literal — it passed clean pre-Llm-port, so it must NOT regress to fail-closed (FINDING 7).
1950
+ rec.incomplete.add("Net");
1951
+ }
1952
+ }
1953
+ }
1822
1954
  }
1823
1955
  // dynamic `require(<non-literal>)` — the CJS twin of `import(m)` (which already discloses Unknown):
1824
1956
  // it loads an arbitrary module and runs its top-level code, so the effects are opaque → Unknown. A
@@ -2130,7 +2262,10 @@ for (const [name, rec] of fns) {
2130
2262
  // them are NOT in `inferred`, so it is a LOWER BOUND when this is non-empty. Omitted when none.
2131
2263
  if (rec.blind.size) entry.invisible = [...rec.blind].sort();
2132
2264
  if (rec.entry) entry.entryPoint = true;
2133
- if (rec.isCjsExport) entry.unitKind = "export"; // spec 0.5 draft, informative — per-unit, not by name
2265
+ // unitKind (spec §2, informative — per-unit, not by name): the synthesized `<module>` initializer
2266
+ // carries its own kind (set at mint), a CJS export is tagged "export".
2267
+ if (rec.unitKind) entry.unitKind = rec.unitKind;
2268
+ else if (rec.isCjsExport) entry.unitKind = "export"; // spec 0.5 draft, informative — per-unit, not by name
2134
2269
  functions.push(entry);
2135
2270
  }
2136
2271
  // `package` names what this report COVERS — a consumer chaining it registers coverage even when
@@ -2182,7 +2317,7 @@ if (!wantJson) {
2182
2317
  // Effect breakdown — make the result visible at a glance, not just a count + a file path.
2183
2318
  const counts = {};
2184
2319
  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"]
2320
+ const breakdown = ["Net", "Llm", "Fs", "Db", "Exec", "Ipc", "Env", "Clipboard", "Clock", "Log", "Rand"]
2186
2321
  .filter((k) => counts[k]).map((k) => `${k} ${counts[k]}`).join(" · ");
2187
2322
  const unknown = counts.Unknown || 0;
2188
2323
  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
  }