candor-ts 0.28.2 → 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 +1 -1
- package/README.md +2 -2
- package/contract.mjs +68 -7
- package/lsp.mjs +15 -3
- package/mcp.mjs +19 -3
- package/package.json +2 -2
- package/policy.mjs +156 -5
- package/query.mjs +47 -22
- package/scan.mjs +490 -37
- package/sensitivity.mjs +2 -2
- package/transitive-recall.mjs +2 -2
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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,49 @@ 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
|
-
|
|
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(
|
|
42
|
-
catch (e) {
|
|
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
103
|
} catch (e) {
|
|
45
104
|
if (e.code !== "EPIPE") throw e;
|
|
@@ -48,7 +107,9 @@ export function printAgents() {
|
|
|
48
107
|
// was rewritten to remove ("an agent piping candor-ts --agents into its context silently read a
|
|
49
108
|
// third of its own instructions") — so the reader-left case has to be STATED. stderr may be closed
|
|
50
109
|
// too; that write is best-effort by construction.
|
|
51
|
-
try { fs.writeSync(2, `candor-ts:
|
|
52
|
-
+ `— the reader closed the pipe. This
|
|
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;
|
|
53
113
|
}
|
|
114
|
+
return true;
|
|
54
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(
|
|
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,
|
|
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
|
-
|
|
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.
|
|
4
|
-
"description": "candor for TypeScript \u2014 per-function side effects, transitively, with a policy gate (candor-spec 0.
|
|
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",
|
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
|
-
|
|
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
|
}
|
package/query.mjs
CHANGED
|
@@ -27,9 +27,9 @@ import { parsePolicy, scopeMatches, discoverConfigPolicy, parseUnknownAliases, d
|
|
|
27
27
|
evaluatePolicy, reportNetClasses, resolveReasonClasses, discoverConfigPath,
|
|
28
28
|
policyVocabularyAnchor, policyErrorText, policyRefusalUnevaluated, policyUnreadable, policyZeroRules,
|
|
29
29
|
fatalPolicyErrors, refusalVerdict,
|
|
30
|
-
unanswerableScoped } from "./policy.mjs";
|
|
30
|
+
unanswerableScoped, wholePolicyUnanswerable } from "./policy.mjs";
|
|
31
31
|
import { hasReport } from "./query-core.mjs";
|
|
32
|
-
import { printAgents } from "./contract.mjs";
|
|
32
|
+
import { printAgents, writeStdoutSync } from "./contract.mjs";
|
|
33
33
|
import { bestFinds } from "./surface.mjs";
|
|
34
34
|
import { isTestPath } from "./scan-core.mjs";
|
|
35
35
|
// ONE source of truth for loading + name-matching — query.mjs kept DRIFTED local copies that didn't
|
|
@@ -47,7 +47,12 @@ import { impact as coreImpact, path as corePath, gains as coreGains,
|
|
|
47
47
|
reportVersion, reportPackage,
|
|
48
48
|
advisoryAnswer,
|
|
49
49
|
reportCompleteness, mustHedge, completenessFields, absorbCompleteness } from "./query-core.mjs";
|
|
50
|
-
|
|
50
|
+
// writeStdoutSync, NOT console.log — and this ONE line covers all twelve `emit` sites, which is the
|
|
51
|
+
// point. console.log is asynchronous on a pipe and every verb here ends in `process.exit(...)`, which
|
|
52
|
+
// discards whatever is still buffered. MEASURED on the sibling path (`scan.mjs --json`): 95281 bytes to a
|
|
53
|
+
// FILE and valid JSON, 65536 bytes through a PIPE and a JSONDecodeError. These are the query documents an
|
|
54
|
+
// agent pipes into `jq`; `map`/`where`/`blindspots` over a real report clear the buffer easily.
|
|
55
|
+
const emit = (v) => writeStdoutSync(JSON.stringify(v, null, 1) + "\n", "the verdict document");
|
|
51
56
|
// ⟨0.24⟩ SPEC §3.2 — THE OTHER CHANNEL. `advisoryAnswer` withdraws the claim from the JSON; this withdraws
|
|
52
57
|
// it from the one a human reads, and the spec requires both because a test that reads one channel is
|
|
53
58
|
// evidence about one channel. candor-rust built a mutant that kept the whole JSON fix and deleted only the
|
|
@@ -106,7 +111,8 @@ const UNEVAL_TAIL_STRICT = "(`ok` is OMITTED — neither value is a statement th
|
|
|
106
111
|
//
|
|
107
112
|
// A policy that is NOT CONFIGURED is untouched: that remains the honest way to say "I am not gating"
|
|
108
113
|
// (§6.2), and it is exactly why a configured zero-rule policy is never a legitimate expression of it.
|
|
109
|
-
const policyAskedNothing = (pol) => !!pol && !pol.deny.length && !pol.allow.length && !pol.forbid.length
|
|
114
|
+
const policyAskedNothing = (pol) => !!pol && !pol.deny.length && !pol.allow.length && !pol.forbid.length
|
|
115
|
+
&& !(pol.only ?? []).length; // ⟨0.29⟩ an `only`-only policy is ARMED
|
|
110
116
|
const emitZeroRuleCaveat = (verb, policyFile, comp) => {
|
|
111
117
|
const { unevaluated } = policyZeroRules(policyFile);
|
|
112
118
|
console.error(`candor-ts: ${verb}: the policy at ${policyFile} yielded NO RULES — every line was ignored `
|
|
@@ -503,7 +509,7 @@ function renderPathHuman(fns, cg, fnQ, eff) {
|
|
|
503
509
|
// package.json; SPEC_VERSION is the spec contract this build speaks. Reused, never re-littered.
|
|
504
510
|
const QUERY_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
505
511
|
const PKG_VERSION = JSON.parse(fs.readFileSync(path.join(QUERY_DIR, "package.json"), "utf8")).version;
|
|
506
|
-
const SPEC_VERSION = "0.
|
|
512
|
+
const SPEC_VERSION = "0.29";
|
|
507
513
|
|
|
508
514
|
// ---- the §3.3.1 canonical query grammar (⟨0.10⟩, additive over 0.9) --------------------------------
|
|
509
515
|
// One shape for every verb: `<verb> <verb-args…> [--report <locator>] [--policy <file>] [--json]
|
|
@@ -843,8 +849,9 @@ function resolveGateReportVerb(rawArgs) {
|
|
|
843
849
|
if (gate === "-") {
|
|
844
850
|
process.on("exit", (code) => {
|
|
845
851
|
if (code === 2 && !globalThis.__candorGateVerdictWritten) {
|
|
846
|
-
|
|
847
|
-
"the gate did not complete — this run exited before a verdict could be produced", null), null, 1)
|
|
852
|
+
writeStdoutSync(JSON.stringify(refusalVerdict(SPEC_VERSION,
|
|
853
|
+
"the gate did not complete — this run exited before a verdict could be produced", null), null, 1)
|
|
854
|
+
+ "\n", "the refusal verdict");
|
|
848
855
|
}
|
|
849
856
|
});
|
|
850
857
|
}
|
|
@@ -1543,7 +1550,7 @@ switch (cmd) {
|
|
|
1543
1550
|
// mostly-Unknown graph; a report that judged nothing, or that names a file it could not read, yields
|
|
1544
1551
|
// the IDENTICAL empty array from strictly less evidence, and an unread unit contributes no entry, so
|
|
1545
1552
|
// it moves neither `unknown` nor `total`. Spread last, `{}` on a complete report.
|
|
1546
|
-
|
|
1553
|
+
writeStdoutSync(JSON.stringify({ ...out, ...completenessFields(tourComp) }) + "\n", "tour");
|
|
1547
1554
|
break;
|
|
1548
1555
|
}
|
|
1549
1556
|
incompleteAnswerNote(tourComp,
|
|
@@ -1809,6 +1816,15 @@ switch (cmd) {
|
|
|
1809
1816
|
// ⟨0.24⟩ SPEC §3.2 — see `advisoryAnswer`. Over a report declaring `unanalyzed` this OMITS `ok`, adds
|
|
1810
1817
|
// the manifest, and `--strict` (the CI form) exits 2 — could-not-fully-evaluate, the same code the gate
|
|
1811
1818
|
// uses for the same situation — rather than the 1 that would claim a finding or the 0 that certified.
|
|
1819
|
+
// ⟨0.29⟩ …AND THE TWO WHOLE-POLICY UNANSWERABLE KINDS, which this verb never received. `fix-gate`'s
|
|
1820
|
+
// `unevaluated` machinery — the note, the OMITTED `ok`, the `--strict` exit 2 — has been here since
|
|
1821
|
+
// ⟨0.24⟩ and works; its INPUT was incomplete. `coreUnverified`/`coreFixGate` report scoped-`deny`
|
|
1822
|
+
// refusals only, so a policy whose rules were ALL `forbid` produced an empty refusal set and this verb
|
|
1823
|
+
// answered `{ok: true}` over a policy nothing had evaluated. MEASURED four-way: java disclosed and
|
|
1824
|
+
// withheld `ok`; rust printed a green ✓ at exit 0; ts and swift emitted `ok: true`. The reference
|
|
1825
|
+
// engine was the only one right, which is the signal to fix the other three rather than argue.
|
|
1826
|
+
{ const wp = wholePolicyUnanswerable(fgpol, "a report route");
|
|
1827
|
+
if (wp.unevaluated.length) fgr.unevaluated = [...(fgr.unevaluated ?? []), ...wp.unevaluated]; }
|
|
1812
1828
|
const fgComp = reportCompleteness(prefix);
|
|
1813
1829
|
const fgUnan = fgComp.unanalyzed;
|
|
1814
1830
|
if (fgUnan.length) advisoryIncompleteNote("fix-gate", fgUnan);
|
|
@@ -1869,6 +1885,15 @@ switch (cmd) {
|
|
|
1869
1885
|
// entire job is "your green gate is not provably green" was certifying a set it knows it cannot see all
|
|
1870
1886
|
// of. A function in an unparsed file is absent from `functions`, so it cannot be enumerated as an
|
|
1871
1887
|
// unverified pass — and that absence is exactly what this verb would have to report.
|
|
1888
|
+
// ⟨0.29⟩ …AND THE TWO WHOLE-POLICY UNANSWERABLE KINDS, which this verb never received. `unverified`'s
|
|
1889
|
+
// `unevaluated` machinery — the note, the OMITTED `ok`, the `--strict` exit 2 — has been here since
|
|
1890
|
+
// ⟨0.24⟩ and works; its INPUT was incomplete. `coreUnverified`/`coreFixGate` report scoped-`deny`
|
|
1891
|
+
// refusals only, so a policy whose rules were ALL `forbid` produced an empty refusal set and this verb
|
|
1892
|
+
// answered `{ok: true}` over a policy nothing had evaluated. MEASURED four-way: java disclosed and
|
|
1893
|
+
// withheld `ok`; rust printed a green ✓ at exit 0; ts and swift emitted `ok: true`. The reference
|
|
1894
|
+
// engine was the only one right, which is the signal to fix the other three rather than argue.
|
|
1895
|
+
{ const wp = wholePolicyUnanswerable(upol, "a report route");
|
|
1896
|
+
if (wp.unevaluated.length) r.unevaluated = [...(r.unevaluated ?? []), ...wp.unevaluated]; }
|
|
1872
1897
|
const uComp = reportCompleteness(prefix);
|
|
1873
1898
|
const uUnan = uComp.unanalyzed;
|
|
1874
1899
|
if (uUnan.length) advisoryIncompleteNote("unverified", uUnan);
|
|
@@ -1976,7 +2001,7 @@ switch (cmd) {
|
|
|
1976
2001
|
// §3.1's byte-equality MUST, which a one-route rung would break on the `# nothing` policy. Every rule
|
|
1977
2002
|
// vector, never a subset: `deny` (deny + pure), `allow`, `forbid` — keying on one would refuse an
|
|
1978
2003
|
// ordinary allow-only or forbid-only gate as if it had no rules.
|
|
1979
|
-
if (!gpol.deny.length && !gpol.allow.length && !gpol.forbid.length) {
|
|
2004
|
+
if (!gpol.deny.length && !gpol.allow.length && !gpol.forbid.length && !(gpol.only ?? []).length) {
|
|
1980
2005
|
const { why, unevaluated } = policyZeroRules(policyFile);
|
|
1981
2006
|
console.error(`candor-ts: gate: ${why} — refusing (exit 2, gate NOT enforced). Every line was ignored `
|
|
1982
2007
|
+ `(see the \`ignoring policy rule\` warnings above), the file is empty, or it holds only comments. A `
|
|
@@ -2000,18 +2025,18 @@ switch (cmd) {
|
|
|
2000
2025
|
// does not care which KIND of refusal stands beside the firing rule. They are now UNEVALUATED rules
|
|
2001
2026
|
// disclosed beside whatever the rest of the policy decided; a policy that is nothing BUT these still
|
|
2002
2027
|
// refuses below, because then there is no verdict to dominate them.
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
}
|
|
2028
|
+
// ⟨0.29⟩ ONE IMPLEMENTATION, THREE CALLERS. This block used to live here and only here, and the two
|
|
2029
|
+
// other report-reading routes in this package (the MCP `candor_gate` tool, the LSP diagnostics path)
|
|
2030
|
+
// passed the whole policy to the matcher — so `forbid` was answered from a report on the agent
|
|
2031
|
+
// channel while the CLI refused it. Moved into `wholePolicyUnanswerable` so the next route inherits
|
|
2032
|
+
// the rule instead of being audited into it.
|
|
2033
|
+
const gwp = wholePolicyUnanswerable(gpol, "`gate --report`");
|
|
2034
|
+
const gunevaluated = gwp.unevaluated;
|
|
2035
|
+
// One line per rule, each NAMING the rule. The previous shape printed a single kind-level sentence
|
|
2036
|
+
// for the whole policy, so an operator with three `forbid` rules learned that three rules were
|
|
2037
|
+
// unenforced and not which. The `unevaluated` array in the JSON document already carried `rule`; the
|
|
2038
|
+
// human channel simply did not show it.
|
|
2039
|
+
for (const u of gunevaluated) console.error(`candor-ts: gate: ${u.why}`);
|
|
2015
2040
|
const g = loadGateReport(prefix);
|
|
2016
2041
|
// ANY report under the locator that did not load cleanly REFUSES THE WHOLE GATE — not just the case
|
|
2017
2042
|
// where they ALL failed. The old guard was `functions.length === 0 && hardFail`, i.e. it fired only
|
|
@@ -2101,7 +2126,7 @@ switch (cmd) {
|
|
|
2101
2126
|
// kinds, now disclosed as `unevaluated` instead of short-circuiting (`1503368`), and handing them to the
|
|
2102
2127
|
// matcher would be the very evaluation-on-partial-evidence they are unanswerable FOR — `allow` would
|
|
2103
2128
|
// fire AS-EFF-008 "no visible literal" on every report entry whose surface the wire does not carry.
|
|
2104
|
-
const gviol = evaluatePolicy(
|
|
2129
|
+
const gviol = evaluatePolicy(gwp.answerable,
|
|
2105
2130
|
g.functions, {}, new Map(), new Set(), gnet, gwithhold);
|
|
2106
2131
|
// Route the human output exactly as a scan does: to stderr whenever stdout carries the verdict
|
|
2107
2132
|
// document, so `candor-ts-query gate … --json | jq` sees pure JSON.
|