candor-ts 0.28.1 → 0.29.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
@@ -21,7 +21,7 @@ the TypeScript-specific production + query surface.
21
21
  > **Already installed? Report the version and ask before upgrading — before you scan.** If this
22
22
  > project already has candor (a `.candor/` report directory, or `candor-ts` reachable via `npx`/a
23
23
  > global install), do this *first*: run `npx -y candor-ts --version` (offline) and **tell the user
24
- > plainly which version they're on** — e.g. *"This project is on candor-ts `<version>` (spec 0.28)."*
24
+ > plainly which version they're on** — e.g. *"This project is on candor-ts `<version>` (spec 0.29)."*
25
25
  > On a build too old for the flag, read `candor.version` / `candor.spec` from an existing
26
26
  > `.candor/report*.json`, or `npm ls -g candor-ts`.
27
27
  >
package/README.md CHANGED
@@ -192,7 +192,7 @@ pure-vs-Unknown ruling (PART 16) — the engines must answer identically, on eve
192
192
  | A call resolving to a *type* (function-typed field/param) → `Unknown`, never silent-pure | SPEC §4 |
193
193
  | Unmatched external calls contribute nothing (curated-classifier caveat) | SEMANTICS §8 C1 |
194
194
  | The literal surfaces `hosts`/`cmds`/`paths`/`tables`, literal-read only | SPEC §2 |
195
- | `{ candor: { version, toolchain, spec: "0.28" }, functions }` envelope; pure fns omitted | SPEC §2/§2.1 |
195
+ | `{ candor: { version, toolchain, spec: "0.29" }, functions }` envelope; pure fns omitted | SPEC §2/§2.1 |
196
196
  | Call-graph sidecar with **every** analyzed function a key | SPEC §2.2 |
197
197
  | The gate: AS-EFF-006 / 008 / 009, loud on an unreadable policy | SPEC §6.2 |
198
198
 
@@ -210,7 +210,7 @@ read the Rust source".
210
210
 
211
211
  ## Status
212
212
 
213
- 0.28.0, speaking candor-spec 0.28: the analysis core, the gate (`--policy` / `--gate-json` /
213
+ 0.28.0, speaking candor-spec 0.29: the analysis core, the gate (`--policy` / `--gate-json` /
214
214
  `.candor/config`), the full §3.1 query surface (including `containment`, `blindspots`, the
215
215
  `--include-unknown` dispatch frontier, and ⟨0.24⟩ `gate --report` — the gate applied to an EXISTING
216
216
  report, byte-equivalent to `scan --policy`'s verdict), the MCP server, the LSP server, and the watch loop are
package/contract.mjs CHANGED
@@ -5,11 +5,35 @@ import { fileURLToPath } from "node:url";
5
5
  // The agent contract for THE INSTALLED VERSION — AGENTS.md ships in the npm tarball, so the doc and
6
6
  // engine cannot drift (the spec §2.1 version-trust rule applied to documentation). ONE implementation
7
7
  // used by both scan.mjs and query.mjs, so `--agents` output can never diverge within an install.
8
- export function printAgents() {
8
+ // `fd` and `budgetMs` are parameters ONLY so the suite can drive this exact loop against a real
9
+ // non-blocking fd. A guard that has never taken its own EAGAIN branch is a guard nobody has seen work,
10
+ // and this file's whole history is failure modes that only appear on a pipe. Production passes neither.
11
+ // `--agents` was never the only print-then-exit site — it was the only one that got FIXED, which is how
12
+ // the defect survived. `printAgents` now delegates to `writeStdoutSync` below so the next bulk-output
13
+ // site inherits the fix instead of being audited into it.
14
+ export function printAgents(fd = 1, budgetMs = 5000) {
9
15
  const dir = path.dirname(fileURLToPath(import.meta.url)); // the package root (where AGENTS.md ships)
10
16
  const semver = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8")).version;
11
17
  const out = `<!-- candor-ts ${semver} · the agent contract for this installed version -->\n`
12
18
  + fs.readFileSync(path.join(dir, "AGENTS.md"), "utf8");
19
+ writeStdoutSync(out, "--agents", fd, budgetMs);
20
+ }
21
+
22
+ // ── THE SYNCHRONOUS BULK WRITER ───────────────────────────────────────────────────────────────────
23
+ // MEASURED on `scan.mjs --json --policy <p>` over a 400-file fixture with a violation: **95281 bytes to
24
+ // a FILE, valid JSON; 65536 bytes through a PIPE, a JSONDecodeError** — exactly the pipe buffer, exit 1
25
+ // either way, nothing on stderr. `console.log` is asynchronous on a pipe and `process.exit()` discards
26
+ // what is still buffered, which is the identical defect the `--agents` path was rewritten for, in the
27
+ // path a MACHINE consumer reads. The umbrella backlog asserted the opposite — that the remaining
28
+ // print-then-exit sites "fit the buffer and survive by SIZE" — which was true of the usage strings
29
+ // somebody measured and false of the report envelope nobody did.
30
+ //
31
+ // `what` names the caller, so the stderr diagnostic points at the surface that lost bytes.
32
+ // RETURNS true when every byte landed, false when it gave up (EAGAIN budget) or the reader left (EPIPE).
33
+ // The test driver needs that answer — an unfinished dump is an incomplete report and must turn the run
34
+ // red — and returning it is what let the driver delete its own private copy of this loop, which was
35
+ // unreachable from any route the suite drives and therefore untested by construction.
36
+ export function writeStdoutSync(out, what = "output", fd = 1, budgetMs = 5000) {
13
37
  // fs.writeSync, NOT console.log/process.stdout.write. On a PIPE those are asynchronous, and scan.mjs
14
38
  // calls `process.exit(0)` on the next line — which discards whatever is still buffered. The contract
15
39
  // came out TRUNCATED AT 8170 OF 23121 CHARACTERS, cut mid-sentence, with exit 0 and nothing on stderr:
@@ -32,14 +56,60 @@ export function printAgents() {
32
56
  // THROWS rather than short-writing as soon as the payload exceeds the 64 KiB pipe buffer.
33
57
  // The contract is 24 KiB today, so this is latent, not live — and it would come back as
34
58
  // exactly the truncation-plus-noise this function was written to remove. Retry with a
35
- // small backoff (Atomics.wait is the only synchronous sleep available here).
36
- let off = 0;
59
+ // small backoff (Atomics.wait is the only synchronous sleep available here) — BOUNDED, see
60
+ // below.
61
+ //
62
+ // WHAT THE BUDGET ACTUALLY BOUNDS: `budgetMs` of ZERO PROGRESS, not total wall-clock. The deadline
63
+ // resets on every successful write, including a partial one, so a reader draining a chunk at a time
64
+ // stretches the total to budget × the number of stalls. That is the RIGHT property — a slow reader must
65
+ // not be cut off for being slow, and the failure this guards is a reader that has STOPPED — but
66
+ // "bounded at 5s" was the wrong description of it, and the payload here is a fixed 24 KiB so the worst
67
+ // case is a small multiple rather than unbounded. Measured with a 500ms budget against a reader taking
68
+ // 4096 bytes every 400ms: 2409ms total, whole contract delivered, no give-up.
69
+ //
70
+ // THE RETRY IS BOUNDED, and it was not. `while (off < buf.length)` with an unconditional 1 ms sleep
71
+ // spins FOREVER against a reader that stalls without ever closing — an agent harness that stops
72
+ // reading while holding the pipe open, a log collector wedged on a full disk. EPIPE is the case where
73
+ // the reader LEFT, and it is handled; this is the case where it stayed and stopped, and the two look
74
+ // nothing alike from here. A hung `--agents` cannot be told from a slow one: it reports nothing, and
75
+ // burns whatever timeout is around it. Both endings are bad, so pick the one that is legible — say so
76
+ // on fd 2, in the same words as the EPIPE arm, and stop.
77
+ //
78
+ // WHY THE SAME BUDGET AS test.mjs's DRIVER: this is the identical hazard on the identical primitive,
79
+ // and the fix went into the driver first while this one — the one an AGENT actually reads through a
80
+ // pipe — was left spinning. The sibling route, again, and this side is the user-facing half.
81
+ const EAGAIN_BUDGET_MS = budgetMs;
82
+ let off = 0, deadline = 0;
37
83
  const buf = Buffer.from(out, "utf8");
38
84
  const idle = new Int32Array(new SharedArrayBuffer(4));
39
85
  try {
40
86
  while (off < buf.length) {
41
- try { off += fs.writeSync(1, buf, off, buf.length - off); } // a short write is legal
42
- catch (e) { if (e.code !== "EAGAIN") throw e; Atomics.wait(idle, 0, 0, 1); }
87
+ try { off += fs.writeSync(fd, buf, off, buf.length - off); deadline = 0; } // a short write is legal
88
+ catch (e) {
89
+ if (e.code !== "EAGAIN") throw e;
90
+ // A wall-clock deadline, not a retry count: Atomics.wait's 1 ms is a FLOOR, so N turns is not N
91
+ // milliseconds of anything. Reset on every byte that lands, so a slow reader is never punished
92
+ // for being slow — only a stopped one runs the budget down.
93
+ if (deadline === 0) deadline = Date.now() + EAGAIN_BUDGET_MS;
94
+ else if (Date.now() >= deadline) {
95
+ try { fs.writeSync(2, `candor-ts: ${what} output stalled at ${off} of ${buf.length} bytes `
96
+ + `— the reader has not drained for ${EAGAIN_BUDGET_MS}ms. This output is INCOMPLETE.\n`); }
97
+ catch { /* nothing left to tell */ }
98
+ return false;
99
+ }
100
+ Atomics.wait(idle, 0, 0, 1);
101
+ }
43
102
  }
44
- } catch (e) { if (e.code !== "EPIPE") throw e; }
103
+ } catch (e) {
104
+ if (e.code !== "EPIPE") throw e;
105
+ // SWALLOWED, BUT NOT SILENT. Exiting non-zero would make `--agents | head` a failure, which it is
106
+ // not. But a truncated contract with exit 0 and an empty stderr is verbatim the defect this function
107
+ // was rewritten to remove ("an agent piping candor-ts --agents into its context silently read a
108
+ // third of its own instructions") — so the reader-left case has to be STATED. stderr may be closed
109
+ // too; that write is best-effort by construction.
110
+ try { fs.writeSync(2, `candor-ts: ${what} output was cut short at ${off} of ${buf.length} bytes `
111
+ + `— the reader closed the pipe. This output is INCOMPLETE.\n`); } catch { /* nothing left to tell */ }
112
+ return false;
113
+ }
114
+ return true;
45
115
  }
package/lsp.mjs CHANGED
@@ -45,7 +45,7 @@ import { createRequire } from "node:module";
45
45
  import nodePath from "node:path";
46
46
  import { fileURLToPath, pathToFileURL } from "node:url";
47
47
  import * as Q from "./query-core.mjs";
48
- import { discoverConfigPolicy, evaluatePolicy, parsePolicy, scopeMatches, reportNetClasses, parseUnknownAliases, discoverConfigText, policyVocabularyAnchor, policyErrorText, unanswerableScoped, resolveReasonClasses, fatalPolicyErrors, policyZeroRules } from "./policy.mjs";
48
+ import { discoverConfigPolicy, evaluatePolicy, parsePolicy, scopeMatches, reportNetClasses, parseUnknownAliases, discoverConfigText, policyVocabularyAnchor, policyErrorText, unanswerableScoped, wholePolicyUnanswerable, resolveReasonClasses, fatalPolicyErrors, policyZeroRules } from "./policy.mjs";
49
49
 
50
50
  // Version: from the sibling package.json when running inside the npm package; a single-file BUNDLE of
51
51
  // this server (the IDE-plugin embedding) has no sibling package.json — fall back rather than crash.
@@ -326,7 +326,8 @@ function activePolicyParsed(text) {
326
326
  }
327
327
  // ⟨0.28⟩ SPEC §2/§6.2 — DID THIS CONFIGURED POLICY ASK ANYTHING AT ALL? Every rule vector, never a
328
328
  // subset: keying on `deny` alone would call an ordinary allow-only or forbid-only gate empty.
329
- const policyAskedNothing = (pol) => !!pol && !pol.deny.length && !pol.allow.length && !pol.forbid.length;
329
+ const policyAskedNothing = (pol) => !!pol && !pol.deny.length && !pol.allow.length && !pol.forbid.length
330
+ && !(pol.only ?? []).length; // ⟨0.29⟩ an `only`-only policy is ARMED
330
331
  // The editor has no exit code and no JSON document, so both of this rung's channels collapse onto the
331
332
  // one it does have. Same `warnOnce` shape (and same reasoning) as the judged-nothing warning below: there
332
333
  // is no line to pin it to, and a per-keystroke popup is how an advisory gets turned off.
@@ -384,13 +385,24 @@ function diagnosticsFor(docPath) {
384
385
  const dnet = reportNetClasses(fns, { authoritative: true });
385
386
  const { unevaluated: dunevaluated, withhold: dwithhold } =
386
387
  unanswerableScoped(dpol, fns, resolveReasonClasses(fns, Q.loadCallgraph(reportPrefix)), dnet);
388
+ // ⟨0.29⟩ `forbid`/`allow` are STRIPPED and DISCLOSED here too. This path handed the whole policy to
389
+ // `evaluatePolicy`, so an editor drew AS-EFF-009 squiggles from a report — evidence SPEC §3.1 rules
390
+ // cannot support them — or, with no sidecar, drew nothing and said nothing, which reads as "no layering
391
+ // problem here". `dunevaluated` was deny-only, so neither case produced even a log line. Same defect as
392
+ // the MCP tool, same shared helper, because these two are each other's siblings as much as the CLI's.
393
+ const dwp = wholePolicyUnanswerable(dpol, "the editor's report route");
394
+ dunevaluated.push(...dwp.unevaluated);
387
395
  // The surface has no exit code, so the refusal is carried the way this file already carries its other two
388
396
  // (the unhonourable policy, the judged-nothing report): ONE log line naming the rules, so the missing
389
397
  // squiggle is EXPLAINED rather than read as a clean bill of health. Per rule, not per keystroke — warnOnce
390
398
  // keys on the message, and the message is a function of the policy and the report, not of the edit.
399
+ // NAME THE RULE, not just its kind. The message used to carry `why` alone — "this policy has 1
400
+ // `forbid` rule(s), which … cannot evaluate" — which tells a developer a category and leaves them to
401
+ // guess which line of their policy went unenforced. In an editor that is the whole cost of the refusal:
402
+ // the squiggle is absent either way, and the message is the only thing standing in for it.
391
403
  for (const u of dunevaluated)
392
404
  warnOnce(`candor-lsp: ${u.why}\n NO diagnostics are drawn for that rule — their ABSENCE here is the refusal, not an all-clear.`);
393
- const violations = evaluatePolicy(dpol, fns, Q.loadCallgraph(reportPrefix),
405
+ const violations = evaluatePolicy(dwp.answerable, fns, Q.loadCallgraph(reportPrefix),
394
406
  new Map(), new Set(), dnet, dwithhold);
395
407
  const locByFn = new Map(fns.filter((e) => e.loc).map((e) => [e.fn, locParts(e.loc)]));
396
408
  const out = [];
package/mcp.mjs CHANGED
@@ -21,7 +21,8 @@ import nodePath from "node:path";
21
21
  import * as Q from "./query-core.mjs";
22
22
  import { discoverConfigPolicy, evaluatePolicy, parsePolicy, scopeMatches, reportNetClasses,
23
23
  parseUnknownAliases, discoverConfigText, policyVocabularyAnchor, policyErrorText,
24
- unanswerableScoped, resolveReasonClasses, fatalPolicyErrors, policyZeroRules } from "./policy.mjs";
24
+ unanswerableScoped, wholePolicyUnanswerable, resolveReasonClasses, fatalPolicyErrors,
25
+ policyZeroRules } from "./policy.mjs";
25
26
 
26
27
  const VERSION = createRequire(import.meta.url)("./package.json").version; // single-sourced, like scan.mjs
27
28
 
@@ -144,7 +145,8 @@ function policyOrThrow(text, policyPath) {
144
145
  // over `# no rules yet` — an all-clear produced by deleting the question, handed to a consumer that
145
146
  // cannot ask a follow-up. `fix` emits NO `crossing` key: that key is present exactly when the verb
146
147
  // answered. §6.2's gate REFUSES over the same policy (exit 2); these are advisory, so they disclose.
147
- const policyAskedNothing = (pol) => !!pol && !pol.deny.length && !pol.allow.length && !pol.forbid.length;
148
+ const policyAskedNothing = (pol) => !!pol && !pol.deny.length && !pol.allow.length && !pol.forbid.length
149
+ && !(pol.only ?? []).length; // ⟨0.29⟩ an `only`-only policy is ARMED
148
150
  // `policyZeroRules` also returns a `why` — the HUMAN sentence the gate's refusal puts in `reason`. The
149
151
  // caveat document carries only `unevaluated`, so it is not spread here rather than minted as a wire key.
150
152
  const zeroRuleCaveat = (policyPath, prefix) => {
@@ -405,7 +407,21 @@ const TOOLS = {
405
407
  const cg = Q.loadCallgraph(p);
406
408
  const gnet = reportNetClasses(gfns, { authoritative: true });
407
409
  const { unevaluated, withhold } = unanswerableScoped(pol, gfns, resolveReasonClasses(gfns, cg), gnet);
408
- const v = evaluatePolicy(pol, gfns, cg, new Map(), new Set(), gnet, withhold);
410
+ // ⟨0.29⟩ …AND THE TWO WHOLE-POLICY UNANSWERABLE KINDS. This tool passed the WHOLE policy to
411
+ // `evaluatePolicy`, so `forbid` was answered from a report — MEASURED: with no callgraph sidecar,
412
+ // `violations: 0` and nothing disclosed (a silent green over a rule that was never enforced); with a
413
+ // sidecar, an AS-EFF-009 violation from evidence SPEC §3.1 says cannot support one. Both outcomes the
414
+ // MUST forbids, on the channel an agent reads. The CLI sibling had stripped and disclosed these since
415
+ // ⟨0.24⟩; the shared helper is so the third route cannot drift from the first two again.
416
+ const wp = wholePolicyUnanswerable(pol, "`candor_gate` (a report route)");
417
+ unevaluated.push(...wp.unevaluated);
418
+ // …and a policy that is NOTHING BUT unanswerable kinds has no verdict to stand beside the refusal,
419
+ // so it refuses outright — the same split `gate --report` makes. Where other rules CAN fire, they
420
+ // decide and these ride along disclosed (§3.1 `1503368`: whole-policy granularity is not a licence
421
+ // to suppress a certain violation).
422
+ if (wp.onlyUnanswerable)
423
+ throw new Error(`this policy asks only questions a report cannot answer — ${wp.unevaluated[0].why}`);
424
+ const v = evaluatePolicy(wp.answerable, gfns, cg, new Map(), new Set(), gnet, withhold);
409
425
  // ⟨0.21⟩ COMPLETENESS MANIFEST — this tool implemented no incompleteness rule at all: it answered
410
426
  // `{ok:true, violations:[]}` over a report DECLARING `unanalyzed`, where the CLI exits 2. A gate
411
427
  // cannot be green over code candor never analyzed, and the manifest travels ON the report, so the
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "candor-ts",
3
- "version": "0.28.1",
4
- "description": "candor for TypeScript \u2014 per-function side effects, transitively, with a policy gate (candor-spec 0.28)",
3
+ "version": "0.29.0",
4
+ "description": "candor for TypeScript \u2014 per-function side effects, transitively, with a policy gate (candor-spec 0.29)",
5
5
  "type": "module",
6
6
  "dependencies": {
7
7
  "@types/node": "^25.9.2",
@@ -20,7 +20,7 @@
20
20
  },
21
21
  "scripts": {
22
22
  "lint": "eslint *.mjs",
23
- "test": "npm run lint && node --test test-unit.mjs && node test.mjs && node test-mcp.mjs && node test-lsp.mjs && node test-watch.mjs && npm run test:probe && npm run test:fuzz",
23
+ "test": "npm run lint && node --test test-unit.mjs && node test.mjs --parallel && node test-mcp.mjs && node test-lsp.mjs && node test-watch.mjs && npm run test:probe && npm run test:fuzz",
24
24
  "test:unit": "node --test test-unit.mjs",
25
25
  "test:probe": "node fabrication_probe.mjs",
26
26
  "test:fuzz": "node fuzz.mjs",
package/policy.mjs CHANGED
@@ -211,12 +211,12 @@ export const fatalPolicyErrors = (errors) => (errors ?? []).filter((e) => FATAL_
211
211
  // The accepted sets, as ARRAYS (SPEC §3.1 `901f14d`: `accepted` is an array of tokens, not prose — a prose
212
212
  // string is unparseable by the consumer the field exists for).
213
213
  const REASON_VOCAB = [...REASON_CLASSES, "dynamic", "*"];
214
- const RULE_KIND_VOCAB = ["deny", "pure", "forbid", "allow"];
214
+ const RULE_KIND_VOCAB = ["deny", "pure", "forbid", "only", "allow"];
215
215
  const DROPPED = (why) => `policy line NOT HONOURED — DROPPED (${why}); it is absent from the parse, so the `
216
216
  + `policy that ran is the one without it`;
217
217
 
218
218
  export function parsePolicy(text, aliases = null) {
219
- const deny = [], allow = [], forbid = [];
219
+ const deny = [], allow = [], forbid = [], only = [];
220
220
  // ⟨0.24⟩ every line this parser could not honour AS WRITTEN, and every alias a rule RESOLVED THROUGH.
221
221
  // `aliasesUsed` is recorded at the point of USE, never from the alias map: a config defining ten aliases
222
222
  // the policy never mentions moved nothing, and naming it would train the reader to skip the field.
@@ -359,6 +359,19 @@ export function parsePolicy(text, aliases = null) {
359
359
  continue;
360
360
  }
361
361
  forbid.push({ from: a, to: b, raw: line });
362
+ } else if (t[0] === "only") {
363
+ // ⟨0.29⟩ THE PERMISSION FORM. Token-wise like its `forbid` sibling above — the arrow must be its own
364
+ // token — but everything AFTER the arrow is a permitted scope, so this takes a LIST where `forbid`
365
+ // takes one destination and ignores the tail. An EMPTY tail is dropped rather than read as "A may
366
+ // reach nothing at all": that is a different rule, and one far likelier typed by accident than meant.
367
+ const from = t[1] ?? "", arrow = t[2] ?? "", to = t.slice(3).filter(Boolean);
368
+ if (!from || arrow !== "->" || !to.length) {
369
+ warn("malformed only (want `only <scope> -> <scope> [<scope> …]`)");
370
+ err("rule-kind", t.slice(1).join(" "), ["only <scope> -> <scope> [<scope> …]"], line,
371
+ DROPPED("want `only <scope> -> <scope> [<scope> …]`"));
372
+ continue;
373
+ }
374
+ only.push({ from, to, raw: line });
362
375
  } else {
363
376
  warn("unknown rule kind");
364
377
  err("rule-kind", t[0], RULE_KIND_VOCAB, line, DROPPED(`unknown rule kind \`${t[0]}\``));
@@ -391,7 +404,7 @@ export function parsePolicy(text, aliases = null) {
391
404
  // the correct one on `gate --report` questions. Recorded for the spec to adjudicate.
392
405
  // ⟨0.28⟩ `ignored` rides the parse beside `errors` (SPEC §6.2) — see the note on `err`. The gate routes
393
406
  // spread it onto the verdict document; `parsepolicy`'s pinned witness shape is untouched.
394
- return { deny, allow, forbid, errors, ignored,
407
+ return { deny, allow, forbid, only, errors, ignored,
395
408
  aliasesUsed: Object.fromEntries([...aliasesUsed.entries()].sort((x, y) => (x[0] < y[0] ? -1 : x[0] > y[0] ? 1 : 0))) };
396
409
  }
397
410
 
@@ -485,6 +498,29 @@ export function refusalVerdict(spec, reason, unevaluated = null) {
485
498
  /** §6.2 scope match: by NAME SEGMENT, last segment a prefix.
486
499
  * Segments split on BOTH "." and "::" — Rust/Java qualify with "::" while TS uses ".", and a shared
487
500
  * policy must match across engines (a `Foo::bar` scope authored against Rust was inert in TS before). */
501
+ // ⟨0.29⟩ SCOPE MATCHING FOR A PERMISSION, where the prefix rule below is FAIL-OPEN.
502
+ //
503
+ // `scopeMatches`'s last segment is a PREFIX of its name-segment, so `util` matches `utilities`. For
504
+ // deny/pure/forbid that widening is FAIL-CLOSED — a scope matching more forbids more — and it is why the
505
+ // rule exists. For the `to` list of an `only` rule it is the exact inverse: a permitted scope matching
506
+ // more PERMITS more, so the matcher that keeps every other rule kind safe silently widens the one form
507
+ // whose entire purpose is to fail safe. MEASURED on the shipped ⟨0.29⟩ implementation: `only model ->
508
+ // util` let `model.go` reach `utilities_untrusted.exfil` at `policy ✓`, while `forbid model -> util`
509
+ // charged AS-EFF-009 on the identical reach.
510
+ //
511
+ // The `from` side KEEPS the prefix rule: it selects which functions the rule BINDS, so matching more
512
+ // constrains more. Each side takes the matcher whose over-approximation errs toward the gate firing.
513
+ export function scopeMatchesPermitted(name, scope) {
514
+ const segs = name.split(/[.:]+/).filter(Boolean);
515
+ const parts = scope.split(/[.:]+/).filter(Boolean);
516
+ if (parts.length === 0 || parts.length > segs.length) return false;
517
+ outer: for (let i = 0; i + parts.length <= segs.length; i++) {
518
+ for (let k = 0; k < parts.length; k++) if (segs[i + k] !== parts[k]) continue outer;
519
+ return true;
520
+ }
521
+ return false;
522
+ }
523
+
488
524
  export function scopeMatches(name, scope) {
489
525
  const segs = name.split(/[.:]+/).filter(Boolean);
490
526
  const parts = scope.split(/[.:]+/).filter(Boolean);
@@ -631,6 +667,69 @@ export function reportNetClasses(functions, { authoritative = false } = {}) {
631
667
  * to keep the unevidenced pairs from FIRING (see the note on that parameter — flooring an empty class set
632
668
  * at `unresolved` is right for a matcher and wrong for a firing).
633
669
  */
670
+ // ── WHOLE-POLICY UNANSWERABLE KINDS ON A REPORT ROUTE (SPEC §3.1 ⟨0.24⟩ ANSWERABILITY) ────────────
671
+ // `forbid` and `allow` cannot be answered from a §2 report, so a route reading one must DISCLOSE them and
672
+ // evaluate what is left — never pass them to the matcher, and never drop them silently.
673
+ //
674
+ // WHY THIS IS A FUNCTION AND NOT A THIRD COPY. It lived inline in `query.mjs`'s `gate --report`, and the
675
+ // two OTHER report-reading routes in this package — the MCP `candor_gate` tool and the LSP diagnostics
676
+ // path — passed the WHOLE policy to `evaluatePolicy`. MEASURED on the real functions with
677
+ // `forbid model -> model`: with no callgraph sidecar (what `loadCallgraph` returns for a hand-copied
678
+ // `report.json`) the answer was `violations: 0, unevaluated: 0` — a SILENT GREEN, the false all-clear the
679
+ // rule exists to prevent; with a sidecar it EVALUATED the rule and returned an AS-EFF-009 violation. Both
680
+ // outcomes the MUST forbids, on the channel an agent reads, for any engine's report (`candor mcp` routes
681
+ // every engine's reports through here). The CLI had the rule and its two siblings did not — so the fix is
682
+ // one implementation with three callers, not three implementations that agree today.
683
+ //
684
+ // Returns { unevaluated, answerable, onlyUnanswerable }:
685
+ // unevaluated — [{rule, why}] to disclose on whatever channel the caller has
686
+ // answerable — the policy with these kinds REMOVED, safe to hand to evaluatePolicy
687
+ // onlyUnanswerable — true when nothing else remains, i.e. there is no verdict to stand beside the
688
+ // refusal and the route must refuse outright (⟨0.24⟩ §3.1 `1503368`: whole-policy
689
+ // granularity is not a licence to SUPPRESS a certain violation, so when other rules
690
+ // can still fire, they decide and these ride along disclosed)
691
+ export function wholePolicyUnanswerable(pol, verb = "this route") {
692
+ const unevaluated = [];
693
+ // ⟨0.29⟩ NO COUNT OF THE FILE'S RULES IN A ROW ABOUT ONE OF THEM. The `why` was phrased "this policy
694
+ // has N `forbid` rule(s)", so with two `forbid` lines BOTH rows said "2" — a fact about the file,
695
+ // attached to a row that is about one line of it, and the reader's obvious inference (that this row
696
+ // covers all N) is false. It is a kind-level PREDICATE now, and the SUBJECT is the rule the caller
697
+ // prints in front of it: `` `forbid a -> b` is a `forbid` rule, which … ``.
698
+ //
699
+ // `why` IS SELF-CONTAINED, and that is a decision about the CALLERS rather than about wording. Six
700
+ // sites print one of these, and THREE of them print `why` alone — `query.mjs`'s advisory disclosure,
701
+ // the LSP's fix path, and the MCP error, i.e. the agent channel. A predicate-style `why` ("is a
702
+ // `forbid` rule, which …") reads correctly only where the caller happens to prefix the rule, so those
703
+ // three would have lost the rule name entirely and read as fragments. Naming the rule HERE cannot be
704
+ // got wrong by a caller added later; the two sites that used to prefix it drop their prefix.
705
+ const forbidWhy = (raw) => `\`${raw.trim()}\` is a \`forbid\` rule, which ${verb} cannot evaluate — `
706
+ + "a report's `calls` graph is not the evidence a NAME-matching dependency rule needs, and a report "
707
+ + "MUST NOT be back-filled from its sidecar. Gate at scan time (candor-ts <src> --policy <file>).";
708
+ const allowWhy = (raw) => `\`${raw.trim()}\` is an \`allow\` rule, which ${verb} cannot evaluate — `
709
+ + "the AS-EFF-008 surface-completeness marker is not guaranteed to ride the wire, and an engine that "
710
+ + "answered where its siblings refuse would have SPLIT THE VERB. Gate at scan time.";
711
+ // ⟨0.29⟩ `only` IS AS UNANSWERABLE AS `forbid`, and for a STRICTER reason. Both match on NAME, which a
712
+ // report's effect-relevant wire cannot settle — but `forbid` asks whether ONE named crossing is present,
713
+ // while `only` asks whether EVERYTHING reached is on a list. A report that omits a crossing makes
714
+ // `forbid` read green; it makes `only` read green as a claim of COMPLETENESS.
715
+ const onlyWhy = (raw) => `\`${raw.trim()}\` is an \`only\` rule, which ${verb} cannot evaluate — it `
716
+ + "asks whether EVERYTHING a scope reaches is on a list, and a report carries an effect-relevant call "
717
+ + "surface rather than the complete dependency graph a NAME-matching rule needs. Answering it here "
718
+ + "would certify completeness from evidence that is not complete. Gate at scan time.";
719
+ for (const r of pol.forbid ?? []) unevaluated.push({ rule: r.raw, why: forbidWhy(r.raw) });
720
+ for (const r of pol.only ?? []) unevaluated.push({ rule: r.raw, why: onlyWhy(r.raw) });
721
+ for (const r of pol.allow ?? []) unevaluated.push({ rule: r.raw, why: allowWhy(r.raw) });
722
+ return {
723
+ unevaluated,
724
+ // REMOVED from the answerable policy, not merely disclosed beside it: a kind left in the object is a
725
+ // kind `evaluatePolicy` walks, and the disclosure would then stand next to the very evaluation it says
726
+ // did not happen. (Measured in the java arm of this same port, where the two sites were one line
727
+ // apart and only the one I was working in got updated.)
728
+ answerable: { ...pol, allow: [], forbid: [], only: [] },
729
+ onlyUnanswerable: unevaluated.length > 0 && !pol.deny?.length,
730
+ };
731
+ }
732
+
634
733
  export function unanswerableScoped(pol, functions, reasonAcc, netMap) {
635
734
  const held = new Set(), byRule = new Map();
636
735
  const key = (raw, fn, eff) => `${raw}\u0000${fn}\u0000${eff}`;
@@ -837,6 +936,36 @@ export function evaluatePolicy(pol, functions, callgraph, incomplete = new Map()
837
936
  if (hit) push("AS-EFF-009", fn, [], `\`${fn}\` reaches into a forbidden layer (via \`${hit}\`), violating policy: \`${r.raw}\``);
838
937
  }
839
938
  }
939
+ // ⟨0.29⟩ AS-EFF-011 — `only A -> B …`: a fn in A may reach A and the listed scopes, NOTHING else. The
940
+ // same walk as `forbid` above with the test INVERTED, and the inversion is the point: `forbid` fails
941
+ // OPEN, so a leaf can only be protected by enumerating what it must not reach — a list that does not
942
+ // cover a package added tomorrow. `only` fails SAFE.
943
+ //
944
+ // THE WALK STOPS AT A PERMITTED SCOPE. A permitted callee's own dependencies are governed by the rules
945
+ // about IT; descending past it would make `only` demand the transitive closure of everything you permit,
946
+ // which is the same enumeration-that-rots one level down. `from` IS descended through — a fn in A
947
+ // calling another fn in A that reaches infra is still A reaching infra.
948
+ for (const r of pol.only ?? []) {
949
+ for (const fn of Object.keys(callgraph)) {
950
+ if (!scopeMatches(fn, r.from)) continue;
951
+ const seen = new Set([fn]), queue = [fn];
952
+ let hit = null;
953
+ while (queue.length && !hit) {
954
+ for (const c of callgraph[queue.pop()] ?? []) {
955
+ if (seen.has(c)) continue;
956
+ seen.add(c);
957
+ // ⟨0.29⟩ EXACT segment match — the shared prefix matcher is fail-OPEN for a permission.
958
+ if (r.to.some((t) => scopeMatchesPermitted(c, t))) continue; // permitted; callees not ours
959
+ if (!scopeMatches(c, r.from)) { hit = c; break; }
960
+ queue.push(c);
961
+ }
962
+ }
963
+ // ⟨0.29⟩ ITS OWN CODE, not `forbid`'s — a rule code is what a CI suppression keys on, and these two
964
+ // are opposite constructs. Sharing 009 would make an existing `forbid` suppression silently mute
965
+ // `only` violations its author never accepted.
966
+ if (hit) push("AS-EFF-011", fn, [], `\`${fn}\` reaches \`${hit}\`, which this permission rule does not permit: \`${r.raw}\``);
967
+ }
968
+ }
840
969
  // ⟨0.27⟩ SPEC §4 — A RULE WHOSE SCOPE BOUND NO FUNCTION IS UNANSWERABLE, AND IS DISCLOSED RATHER THAN
841
970
  // SCORED AS SATISFIED. Measured on this engine before the fix: `deny Fs orders` exits 1 on a real
842
971
  // violation while `deny Fs ordrs` exits 0 in silence — a one-character typo in a layer name is a
@@ -852,6 +981,11 @@ export function evaluatePolicy(pol, functions, callgraph, incomplete = new Map()
852
981
  const zeroCount = new Map();
853
982
  for (const r of pol.deny) if (r.scope) zeroCount.set(r.raw, 0);
854
983
  for (const r of pol.forbid) zeroCount.set(r.raw, 0);
984
+ // ⟨0.29⟩ …and `only`, counted on `from` ALONE — deliberately not either endpoint the way a `forbid`
985
+ // counts. A forbid's subject is the pair; an `only`'s subject is the scope it makes a PROMISE about, so
986
+ // a rule whose destinations all resolve while its `from` names nothing has bound nothing, and that is
987
+ // exactly the typo that leaves an operator believing a leaf is protected.
988
+ for (const r of pol.only ?? []) zeroCount.set(r.raw, 0);
855
989
  if (zeroCount.size) {
856
990
  const names = new Set(functions.map((f) => f.fn));
857
991
  for (const k of Object.keys(callgraph ?? {})) names.add(k);
@@ -860,6 +994,9 @@ export function evaluatePolicy(pol, functions, callgraph, incomplete = new Map()
860
994
  for (const r of pol.forbid) {
861
995
  if (scopeMatches(n, r.from) || scopeMatches(n, r.to)) zeroCount.set(r.raw, zeroCount.get(r.raw) + 1);
862
996
  }
997
+ for (const r of pol.only ?? []) {
998
+ if (scopeMatches(n, r.from)) zeroCount.set(r.raw, zeroCount.get(r.raw) + 1);
999
+ }
863
1000
  }
864
1001
  }
865
1002
  // ⟨0.27⟩ CODE-POINT order, explicitly — the `zeroMatch` verdict key pins the `viaDispatchOn` collation
@@ -1046,7 +1183,7 @@ export function parseUnknownAliases(configText, errors = null) {
1046
1183
  // partner hosts — the per-project `known-partner` set for the Net destination-class classifier. Multi-value
1047
1184
  // (repeatable key); the value's `:port` is stripped + lowercased like MODEL_HOSTS. Case-insensitive key,
1048
1185
  // mirroring parseUnknownAliases + the java/rust config loaders. A partner is per-project — never universal.
1049
- export function parseNetPartners(configText) {
1186
+ export function parseNetPartners(configText, errs = null) {
1050
1187
  const out = new Set();
1051
1188
  if (!configText) return out;
1052
1189
  for (const raw of configText.split(/\r?\n/)) {
@@ -1055,7 +1192,21 @@ export function parseNetPartners(configText) {
1055
1192
  const m = line.match(/^(\S+)\s+(.*)$/);
1056
1193
  if (!m || m[1].toLowerCase() !== "net-partner") continue;
1057
1194
  const val = m[2].trim();
1058
- if (val) out.add(hostPart(val).toLowerCase());
1195
+ // ⟨0.29⟩ A MALFORMED VALUE IS DISCLOSED, NOT SILENTLY KEPT AS JUNK. The grammar is
1196
+ // `net-partner <host>`; the `=` spelling an operator reaches for by habit
1197
+ // (`net-partner = partner.example`) parsed as the HOST "= partner.example", which entered the set and
1198
+ // matched nothing for the rest of the run. The direction is safe — the gate stays armed, so nothing
1199
+ // is certified that should not be — which is exactly why it can sit unnoticed: the operator believes
1200
+ // a partner is declared, the verdict says otherwise, and no line connects the two. ⟨0.28⟩ gave POLICY
1201
+ // files an `ignored` block for this; config files had no equivalent.
1202
+ if (!val) continue;
1203
+ if (/\s/.test(val) || val.startsWith("=")) {
1204
+ errs?.push({ kind: "config-value", raw: raw.trim(),
1205
+ why: `net-partner takes a bare host — \`net-partner <host>\`, one per line; `
1206
+ + `'${val}' is not one and was IGNORED (an '=' or extra words is the usual cause)` });
1207
+ continue;
1208
+ }
1209
+ out.add(hostPart(val).toLowerCase());
1059
1210
  }
1060
1211
  return out;
1061
1212
  }