candor-ts 0.4.6 → 0.5.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
@@ -54,20 +54,28 @@ downgraded to `Unknown` rather than silently trusted (spec §2.1). Caveat: a typ
54
54
  Q() { npx -y candor-ts-query "$@"; }; P=".candor/report" # a function — works in bash AND zsh
55
55
  Q show $P <fn-query> 1 # a function's effects (+ hosts/tables when visible)
56
56
  Q where $P <Effect> 1 # {effect, directly, inherited}
57
- Q callers $P <fn-query> 1 # the BLAST RADIUS: {of, direct, transitive} — works for pure fns
57
+ Q impact $P <fn-query> # THE BLAST RADIUS: {fn, affectedCount, affected, entryPoints}
58
+ Q callers $P <fn-query> 1 # the lower-level form: {of, direct, transitive} — works for pure fns
59
+ Q path $P <fn> <Effect> # how a fn reaches an effect: the chain to the nearest source
58
60
  Q map $P 1 # {module: {effects, functions}}
59
61
  Q whatif $P <fn> <Effect> [policy] # pre-edit gate verdict (exit 1 if it would violate)
60
62
  Q diff $P <baseline-prefix> 1 # per-function effect delta (exit 1 on a gained effect)
63
+ Q gains $P <baseline-prefix> # supply-chain alarm: {gained, byFunction} — effects a surface grew
61
64
  Q reachable $P 1 # what the app DOES at runtime: effects over the entry points
62
65
  Q parsepolicy <policy-file> # the canonical §6.2 parse (what the gate will enforce)
63
66
  ```
64
67
 
68
+ And as an MCP server, so an agent pulls these as tools instead of shelling out:
69
+ `CANDOR_REPORT=$P npx -y candor-ts-mcp` (tools `candor_impact`/`candor_reachable`/`candor_where`/…).
70
+ `npx -y candor-ts-watch <dir>` keeps the report fresh as you edit (and reports the edit-delta).
71
+
65
72
  Name queries resolve exact > segment-suffix (`db.save` matches `src.db.save`, never
66
73
  `src.db.save_all`) > substring — the same ladder as the other engines. The trailing `1` is the
67
74
  want-JSON flag.
68
75
 
69
- - **Blast radius of editing a function** → `callers <fn>` (NOT its `inferred`, which is what the
70
- function itself does). Works pre-edit for a still-pure function.
76
+ - **Blast radius of editing a function** → `impact <fn>` (the `affected` list + downstream
77
+ `entryPoints`; NOT its `inferred`, which is what the function itself does). Works pre-edit for a
78
+ still-pure function. `callers <fn>` is the lower-level raw-callers form.
71
79
  - **Decide BEFORE you edit** → `whatif <fn> <Effect> [policy]` — every transitive caller gains the
72
80
  effect, crossed with the policy.
73
81
  - **After you change code** → `diff` against a baseline report; a gained `Net`/`Db`/`Exec`/`Fs` you
@@ -119,4 +127,7 @@ curated-κ caveat cuts the other way:** a call into an npm package κ doesn't kn
119
127
  NOTHING — invisible, not `Unknown`. The scan's receipt now DISCLOSES these by name (`κ doesn't
120
128
  know N packages…`), so the blind spots are per-scan evidence, not a doc footnote: never conclude
121
129
  "no effect" through a package that line names (the documented weaker edge of the
122
- never-silently-pure promise, same as every candor engine's curated classifier).
130
+ never-silently-pure promise, same as every candor engine's curated classifier). An uncurated
131
+ dependency can opt out of that blind spot by declaring `"candorEffects": ["Net", …]` in its
132
+ `package.json` (the §5.1 effect manifest, read declared-not-verified) — its calls then classify to
133
+ the declared set instead of contributing nothing.
package/README.md CHANGED
@@ -54,10 +54,53 @@ plus a small npm tier (axios/got/node-fetch/undici/ws, pg/mysql2/mongodb/redis/k
54
54
  execa/cross-spawn, fs-extra/rimraf/glob, dotenv, winston/pino). An unlisted package contributes
55
55
  nothing — candor never guesses an effect.
56
56
 
57
+ ## MCP server — candor as agent ground truth
58
+
59
+ `candor-ts-mcp` exposes the read-only queries as an [MCP](https://modelcontextprotocol.io) server, so
60
+ a coding agent can ask **"if I change this, what's the runtime blast radius?"** or **"what reaches the
61
+ network?"** and get deterministic ground truth from a precomputed report — instead of burning tokens
62
+ tracing the call graph by hand (the measured ~700–2000× token win on blast-radius questions).
63
+
64
+ ```jsonc
65
+ // in an MCP client config — point it at a report you've already scanned
66
+ { "command": "npx", "args": ["-y", "candor-ts-mcp"],
67
+ "env": { "CANDOR_REPORT": ".candor/report.myPkg.scan" } }
68
+ ```
69
+
70
+ Tools: `candor_impact` (backward blast radius), `candor_reachable` (what runs at runtime),
71
+ `candor_where` (effect surface), `candor_path` (how an effect is reached), `candor_callers`,
72
+ `candor_show`, `candor_map`, `candor_whatif` (pre-edit gate check). Each takes an optional `report`
73
+ prefix (else `$CANDOR_REPORT`). The server is **query-only** — it never scans (the analyzer
74
+ self-boundary, spec §7.12: an agent or a hook produces the report; the server reads it, Fs only). The
75
+ query logic is the shared `query-core.mjs`, the same answers the CLI gives.
76
+
77
+ **The live loop** — `candor-ts-watch` keeps the report fresh as the agent edits, so the answers are
78
+ about the *current* code, not a stale snapshot:
79
+
80
+ ```sh
81
+ candor-ts-watch ./src --out .candor/report # re-scans only when a tracked source actually changes
82
+ ```
83
+
84
+ It tracks the project's sources by content hash and re-scans on a real change (a no-op save or an
85
+ unrelated write does nothing), writing the same prefix the MCP server reads. So: **agent edits →
86
+ watcher refreshes the report → agent asks `candor_impact` and gets the post-edit answer.** And it
87
+ reports the **edit-delta** — not just that the report is fresh but *what the edit did* to the effect
88
+ surface (`re-scanned (1 changed: app.ts) — Δ f +Net`), so the agent learns the consequence of its
89
+ own change. v1 runs a full (sound) scan per change; the deeper *perf* optimisation — re-analysing
90
+ only the changed file's subgraph instead of the whole project — is the staged next step (the
91
+ content-hash gate is its first increment).
92
+
57
93
  ## Trust contract (spec §4)
58
94
 
59
95
  Anything candor-ts can't resolve is `Unknown`, never silently pure: a function-valued parameter or
60
96
  field being called, an `any`-typed callee, resolution landing on a type rather than a body.
97
+
98
+ An **uncurated dependency** can opt out of `Unknown`/silent-pure by **declaring its effects** in its
99
+ `package.json` — `"candorEffects": ["Net"]` (spec §5.1, the effect manifest). candor-ts reads it as
100
+ the declared-not-verified tier: the package's calls classify to the declared set, and it stops being
101
+ a κ-ledger blind spot. A name outside the §1 vocabulary voids the declaration loudly (a typo must not
102
+ silently narrow a surface). And `candor-ts-query gains <cur> <base>` flags the **supply-chain**
103
+ delta — the effects a surface *gained* between two reports.
61
104
  Real-world consequence, measured on [rimraf](https://github.com/isaacs/rimraf) (50 files, 55
62
105
  functions analyzed): its DI-style fs injection means many functions honestly read `Unknown` —
63
106
  that's the contract working, not noise. The report says "can reach", never "does"; an absent
@@ -82,7 +125,7 @@ every push to the spec.
82
125
  | A call resolving to a *type* (function-typed field/param) → `Unknown`, never silent-pure | SPEC §4 |
83
126
  | Unmatched external calls contribute nothing (curated-κ caveat) | SEMANTICS §8 C1 |
84
127
  | The literal surfaces `hosts`/`cmds`/`paths`/`tables`, literal-read only | SPEC §2 |
85
- | `{ candor: { version, toolchain, spec: "0.4" }, functions }` envelope; pure fns omitted | SPEC §2/§2.1 |
128
+ | `{ candor: { version, toolchain, spec: "0.5" }, functions }` envelope; pure fns omitted | SPEC §2/§2.1 |
86
129
  | Call-graph sidecar with **every** analyzed function a key | SPEC §2.2 |
87
130
  | The gate: AS-EFF-006 / 008 / 009, loud on an unreadable policy | SPEC §6.2 |
88
131
 
@@ -107,3 +150,21 @@ conformance-held. The npm classifier tier is
107
150
  deliberately curated and will keep growing case-by-case. Entry points (Nest/Next populations),
108
151
  `unknownWhy` origins, `reachable`, cross-package inheritance (`CANDOR_DEPS` + the spec §2 `hash`,
109
152
  version-trusted per §2.1), and `--allow-js` are all in. On npm: `npx -y candor-ts <dir>`.
153
+
154
+ ## Development
155
+
156
+ No build step — the engine runs on Node directly.
157
+
158
+ ```sh
159
+ npm install
160
+ npm test # the full CI gate: lint + unit (node:test) + behavioural + MCP + watch + the
161
+ # fabrication probe + the §7.13 soundness fuzzer
162
+ npm run test:unit # just the native unit tests — the query algebra + policy DSL + the scan-core
163
+ # classifier/literal leaves (query-core / policy / scan-core)
164
+ npm run lint # eslint (the recommended ruleset; the CI lint gate)
165
+ node scan.mjs <dir | file.ts | tsconfig.json> --out .candor/report # scan a project
166
+ ```
167
+
168
+ The pure cores are factored into importable modules — `query-core.mjs` (the §3.1 queries),
169
+ `policy.mjs` (the §6.2 DSL + literal matchers), and `scan-core.mjs` (the κ classifier + the SQL/
170
+ command/host extractors) — so they're unit-tested directly; the TS-compiler-driven walk stays in `scan.mjs`.
package/mcp.mjs ADDED
@@ -0,0 +1,191 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * candor-mcp — candor's read-only query surface as an MCP server (roadmap direction #1: candor as
4
+ * agent infrastructure). An agent asks "if I change X, what's the runtime blast radius?" or "what
5
+ * reaches the network?" and gets DETERMINISTIC ground truth from a precomputed report in ~zero
6
+ * exploration tokens — the measured ~700-2000x token win over grepping to the same answer.
7
+ *
8
+ * Transport: newline-delimited JSON-RPC 2.0 over stdio (the MCP stdio framing), implemented directly
9
+ * so candor-ts's `npx` scan/query path stays dependency-free. Query logic is the shared query-core.mjs
10
+ * (one source of truth with the CLI). The server is QUERY-ONLY (it never scans — the analyzer self-
11
+ * boundary, SPEC §7.12; an agent/hook produces the report, the server reads it: Fs only).
12
+ *
13
+ * The report to query is resolved per call from the tool's `report` arg, else $CANDOR_REPORT, else
14
+ * the first CLI arg. A `<prefix>` names `<prefix>.json` + `<prefix>.callgraph.json`.
15
+ *
16
+ * CANDOR_REPORT=.candor/report.myCrate.scan npx -y candor-ts mcp
17
+ */
18
+ import fs from "node:fs";
19
+ import { createRequire } from "node:module";
20
+ import nodePath from "node:path";
21
+ import * as Q from "./query-core.mjs";
22
+ import { parsePolicy, scopeMatches } from "./policy.mjs";
23
+
24
+ const VERSION = createRequire(import.meta.url)("./package.json").version; // single-sourced, like scan.mjs
25
+
26
+ const DEFAULT_PREFIX = process.env.CANDOR_REPORT || process.argv[2] || null;
27
+
28
+ // A report exists at the prefix if there's an exact `<prefix>.json` (candor-ts) OR a sibling
29
+ // `<prefix>.<crate>.scan.json` (the candor-scan/Rust multi-report form) — the loaders read both, so
30
+ // the MCP server serves a report from ANY engine, not just candor-ts's.
31
+ function hasReport(p) {
32
+ if (fs.existsSync(`${p}.json`)) return true;
33
+ const base = nodePath.basename(p);
34
+ try {
35
+ // SAME predicate (Q.isReport) the loader uses — a prefix whose only sibling is `.encountered-*` /
36
+ // `.calibrated.json` must NOT pass here, else loadReport finds zero functions and the tool returns an
37
+ // authoritative-empty result instead of "no report" (a silent under-report — review find).
38
+ return fs.readdirSync(nodePath.dirname(p) || ".").some((f) =>
39
+ f.startsWith(base + ".") && f.endsWith(".json") && Q.isReport(f));
40
+ } catch { return false; }
41
+ }
42
+ function resolvePrefix(args) {
43
+ const p = args?.report || DEFAULT_PREFIX;
44
+ if (!p) throw new Error("no report prefix: pass `report`, set $CANDOR_REPORT, or give one as the CLI arg");
45
+ if (!hasReport(p)) throw new Error(`no report at \`${p}\` (.json or .<crate>.scan.json) — run a candor scan first`);
46
+ return p;
47
+ }
48
+
49
+ // ---- the tools: name -> {description, schema, run} ------------------------------------------------
50
+ const reportArg = { report: { type: "string", description: "report prefix (optional; defaults to $CANDOR_REPORT)" } };
51
+
52
+ // Bound a blast-radius/caller LIST for the agent transport: on a large repo a single fn can have
53
+ // hundreds-to-thousands of transitive callers, an unbounded multi-thousand-token answer. The agent's
54
+ // question ("how big is the blast radius / where does it surface") is answered by the COUNT + the entry
55
+ // points + the top names — so cap the list to MCP_LIST_CAP, keep the exact count, and flag truncation.
56
+ // The full list stays available from the CLI / `--json` (the spec-pinned §3.1 shape is UNCHANGED — this
57
+ // only shapes the MCP result for its token-sensitive transport). Small results are returned verbatim.
58
+ const MCP_LIST_CAP = 50;
59
+ function capImpact(r) {
60
+ if (!Array.isArray(r.affected) || r.affected.length <= MCP_LIST_CAP) return r; // affectedCount is the full count
61
+ return { ...r, affected: r.affected.slice(0, MCP_LIST_CAP), affectedTruncated: true };
62
+ }
63
+ function capCallers(r) {
64
+ const d = r.direct ?? [], t = r.transitive ?? [];
65
+ if (d.length <= MCP_LIST_CAP && t.length <= MCP_LIST_CAP) return r;
66
+ return {
67
+ of: r.of,
68
+ directCount: d.length, direct: d.slice(0, MCP_LIST_CAP),
69
+ transitiveCount: t.length, transitive: t.slice(0, MCP_LIST_CAP),
70
+ truncated: true,
71
+ };
72
+ }
73
+ const TOOLS = {
74
+ candor_impact: {
75
+ description: "Backward blast radius: every effectful function that transitively calls `fn`, and which runtime entry points are downstream. Answers 'if I change this, what surfaces at runtime?' — the cheapest possible alternative to tracing callers by hand.",
76
+ schema: { type: "object", properties: { fn: { type: "string", description: "the function/unit to assess" }, ...reportArg }, required: ["fn"] },
77
+ run: (a, p) => capImpact(Q.impact(Q.loadReport(p), Q.loadCallgraph(p), a.fn)),
78
+ },
79
+ candor_where: {
80
+ description: "Which functions perform a given effect (e.g. Net, Db, Exec, Fs) — `directly` vs `inherited` via a callee. The effect-surface map.",
81
+ schema: { type: "object", properties: { effect: { type: "string", description: "Net|Fs|Db|Exec|Env|Clock|Ipc|Log|Rand|Clipboard|Unknown" }, ...reportArg }, required: ["effect"] },
82
+ run: (a, p) => Q.where(Q.loadReport(p), a.effect),
83
+ },
84
+ candor_reachable: {
85
+ description: "What the program/fleet actually DOES at runtime: effects unioned over the entry points, with how many roots reach each and via which.",
86
+ schema: { type: "object", properties: { ...reportArg } },
87
+ run: (_a, p) => Q.reachable(Q.loadReport(p)),
88
+ },
89
+ candor_path: {
90
+ description: "Forward provenance: the shortest call chain from `fn` to the nearest function that performs `effect` DIRECTLY — 'this reaches Net through WHAT?'.",
91
+ schema: { type: "object", properties: { fn: { type: "string" }, effect: { type: "string" }, ...reportArg }, required: ["fn", "effect"] },
92
+ run: (a, p) => Q.path(Q.loadReport(p), Q.loadCallgraph(p), a.fn, a.effect),
93
+ },
94
+ candor_callers: {
95
+ description: "Who calls `fn` — direct (one hop) and transitive callers over the effect-relevant call graph.",
96
+ schema: { type: "object", properties: { fn: { type: "string" }, ...reportArg }, required: ["fn"] },
97
+ run: (a, p) => capCallers(Q.callers(Q.loadCallgraph(p), a.fn)),
98
+ },
99
+ candor_show: {
100
+ description: "A function's effects (inferred = transitive, direct = own body) plus its literal surfaces (hosts/cmds/paths/tables) when present.",
101
+ schema: { type: "object", properties: { fn: { type: "string" }, ...reportArg }, required: ["fn"] },
102
+ run: (a, p) => Q.show(Q.loadReport(p), a.fn),
103
+ },
104
+ candor_map: {
105
+ description: "Per-module effect overview: each module's union of effects and function count. The architecture-at-a-glance.",
106
+ schema: { type: "object", properties: { ...reportArg } },
107
+ run: (_a, p) => Q.map(Q.loadReport(p)),
108
+ },
109
+ candor_whatif: {
110
+ description: "Hypothetically add `effect` to `fn` and report the blast radius; with `policy`, also the deny-rule violations it would cause. Pre-edit gate check.",
111
+ schema: { type: "object", properties: { fn: { type: "string" }, effect: { type: "string" }, policy: { type: "string", description: "path to a CANDOR_POLICY file (optional)" }, ...reportArg }, required: ["fn", "effect"] },
112
+ run: (a, p) => {
113
+ const pol = a.policy && fs.existsSync(a.policy) ? parsePolicy(fs.readFileSync(a.policy, "utf8")) : null;
114
+ const r = Q.whatif(Q.loadCallgraph(p), a.fn, a.effect, pol, scopeMatches);
115
+ if (r === null) throw new Error(`no function matching \`${a.fn}\` in the call graph`);
116
+ return r;
117
+ },
118
+ },
119
+ };
120
+
121
+ // ---- JSON-RPC 2.0 over stdio (newline-delimited; the MCP stdio framing) ---------------------------
122
+ function send(msg) { process.stdout.write(JSON.stringify(msg) + "\n"); }
123
+ function result(id, r) { send({ jsonrpc: "2.0", id, result: r }); }
124
+ function error(id, code, message) { send({ jsonrpc: "2.0", id, error: { code, message } }); }
125
+
126
+ function handle(msg) {
127
+ const { id, method, params } = msg;
128
+ if (method === "initialize") {
129
+ return result(id, {
130
+ protocolVersion: params?.protocolVersion || "2025-06-18",
131
+ capabilities: { tools: {} },
132
+ serverInfo: { name: "candor-mcp", version: VERSION },
133
+ instructions: "candor's read-only effect queries. Prefer candor_impact/candor_reachable/candor_where over manually tracing the call graph — they return deterministic ground truth from a precomputed report. Run a candor scan first to produce the report.",
134
+ });
135
+ }
136
+ if (method === "notifications/initialized" || method === "notifications/cancelled") return; // notifications: no reply
137
+ if (method === "ping") return result(id, {});
138
+ if (method === "tools/list") {
139
+ return result(id, {
140
+ tools: Object.entries(TOOLS).map(([name, t]) => ({ name, description: t.description, inputSchema: t.schema })),
141
+ });
142
+ }
143
+ if (method === "tools/call") {
144
+ const t = TOOLS[params?.name];
145
+ if (!t) return error(id, -32602, `unknown tool: ${params?.name}`);
146
+ try {
147
+ const args = params.arguments || {};
148
+ // Enforce the tool's declared required args server-side — a missing `fn` must be a clear error,
149
+ // not a silently-empty result (a defensive server doesn't trust the client to validate).
150
+ const missing = (t.schema.required || []).filter((k) => args[k] === undefined || args[k] === "");
151
+ if (missing.length)
152
+ return result(id, { content: [{ type: "text", text: `candor: missing required argument(s): ${missing.join(", ")}` }], isError: true });
153
+ const prefix = resolvePrefix(args);
154
+ // A tool that targets a `fn` gets a clear "not found" rather than a silently-empty result —
155
+ // an agent must distinguish "no such function" from "found, nothing calls it".
156
+ if (args.fn !== undefined) {
157
+ const names = [...new Set([...Object.keys(Q.loadCallgraph(prefix)), ...Q.loadReport(prefix).map((e) => e.fn)])];
158
+ if (Q.matches(names, args.fn).length === 0)
159
+ return result(id, { content: [{ type: "text", text: `candor: no function matching \`${args.fn}\` in this report` }], isError: true });
160
+ }
161
+ const out = t.run(args, prefix);
162
+ // Minified, not pretty-printed: the consumer is an AGENT (it parses the JSON), so the indentation
163
+ // was ~25-30% of every result's tokens for no benefit. The CLI keeps its human-readable shapes.
164
+ return result(id, { content: [{ type: "text", text: JSON.stringify(out) }] });
165
+ } catch (e) {
166
+ // A tool-level failure is reported in the result (isError), not as a protocol error.
167
+ return result(id, { content: [{ type: "text", text: `candor: ${e.message}` }], isError: true });
168
+ }
169
+ }
170
+ if (id !== undefined) error(id, -32601, `method not found: ${method}`);
171
+ }
172
+
173
+ let buf = "";
174
+ process.stdin.setEncoding("utf8");
175
+ process.stdin.on("data", (chunk) => {
176
+ buf += chunk;
177
+ let nl;
178
+ while ((nl = buf.indexOf("\n")) >= 0) {
179
+ const line = buf.slice(0, nl).trim();
180
+ buf = buf.slice(nl + 1);
181
+ if (!line) continue;
182
+ let msg;
183
+ try { msg = JSON.parse(line); } catch { continue; } // ignore unparseable frames
184
+ // A JSON-RPC frame is a (non-null, non-array) object. `null`, a bare primitive, or a batch array
185
+ // would crash `handle`'s destructure — and the catch's own `msg.id` deref re-threw OUTSIDE the
186
+ // handler, killing the whole server (and the agent's session) on a single `null\n` line (review find).
187
+ if (!msg || typeof msg !== "object" || Array.isArray(msg)) continue;
188
+ try { handle(msg); } catch (e) { if (msg.id !== undefined) error(msg.id, -32603, e.message); }
189
+ }
190
+ });
191
+ process.stdin.on("end", () => process.exit(0));
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "candor-ts",
3
- "version": "0.4.6",
4
- "description": "candor for TypeScript — per-function side effects, transitively, with a policy gate (candor-spec 0.4)",
3
+ "version": "0.5.0",
4
+ "description": "candor for TypeScript — per-function side effects, transitively, with a policy gate (candor-spec 0.5)",
5
5
  "type": "module",
6
6
  "dependencies": {
7
7
  "@types/node": "^25.9.2",
@@ -9,10 +9,16 @@
9
9
  },
10
10
  "bin": {
11
11
  "candor-ts": "./scan.mjs",
12
- "candor-ts-query": "./query.mjs"
12
+ "candor-ts-query": "./query.mjs",
13
+ "candor-ts-mcp": "./mcp.mjs",
14
+ "candor-ts-watch": "./watch.mjs"
13
15
  },
14
16
  "scripts": {
15
- "test": "node test.mjs"
17
+ "lint": "eslint *.mjs",
18
+ "test": "npm run lint && node --test test-unit.mjs && node test.mjs && node test-mcp.mjs && node test-watch.mjs && npm run test:probe && npm run test:fuzz",
19
+ "test:unit": "node --test test-unit.mjs",
20
+ "test:probe": "node fabrication_probe.mjs",
21
+ "test:fuzz": "node fuzz.mjs"
16
22
  },
17
23
  "license": "(MIT OR Apache-2.0)",
18
24
  "repository": {
@@ -40,6 +46,15 @@
40
46
  "AGENTS.md",
41
47
  "PROVE-IT.md",
42
48
  "LICENSE-MIT",
43
- "LICENSE-APACHE"
44
- ]
49
+ "LICENSE-APACHE",
50
+ "query-core.mjs",
51
+ "scan-core.mjs",
52
+ "mcp.mjs",
53
+ "watch.mjs"
54
+ ],
55
+ "devDependencies": {
56
+ "@eslint/js": "^9.39.4",
57
+ "eslint": "^9.39.4",
58
+ "globals": "^17.6.0"
59
+ }
45
60
  }
package/policy.mjs CHANGED
@@ -7,12 +7,17 @@
7
7
  export const EFFECTS = ["Net", "Fs", "Db", "Exec", "Env", "Clock", "Ipc", "Log", "Rand", "Clipboard"];
8
8
  const ALLOW_EFFECTS = new Set(["Net", "Exec", "Fs", "Db"]); // the four literal surfaces
9
9
 
10
+ // The §6.2 token separator: ASCII whitespace ONLY (space/tab/LF/VT/FF/CR). JS `\s`/`String.trim` strip
11
+ // Unicode spaces (NBSP, ideographic, …) that Java drops — a gateless-green cross-engine divergence
12
+ // (adversarial DSL review). A non-ASCII space stays part of its token → the rule is malformed, dropped.
13
+ const ASCII_WS = /[ \t\n\v\f\r]+/;
14
+ const ASCII_WS_TRIM = /^[ \t\n\v\f\r]+|[ \t\n\v\f\r]+$/g;
10
15
  export function parsePolicy(text) {
11
16
  const deny = [], allow = [], forbid = [];
12
17
  for (const rawLine of text.split("\n")) {
13
- const line = rawLine.split("#")[0].trim();
18
+ const line = rawLine.split("#")[0].replace(ASCII_WS_TRIM, "");
14
19
  if (!line) continue;
15
- const t = line.split(/\s+/);
20
+ const t = line.split(ASCII_WS);
16
21
  const warn = (why) => console.error(`candor: ignoring policy rule (${why}): ${line}`);
17
22
  if (t[0] === "deny") {
18
23
  const effects = [];
@@ -22,7 +27,7 @@ export function parsePolicy(text) {
22
27
  else { scope = tok; break; }
23
28
  }
24
29
  if (effects.length === 0) { warn("deny names no known effect"); continue; }
25
- deny.push({ effects: effects.sort(), scope, raw: line });
30
+ deny.push({ effects: [...new Set(effects)].sort(), scope, raw: line }); // dedup: a set, like rust/java
26
31
  } else if (t[0] === "pure") {
27
32
  deny.push({ effects: [], scope: t[1] ?? "", raw: line });
28
33
  } else if (t[0] === "allow") {
@@ -32,7 +37,7 @@ export function parsePolicy(text) {
32
37
  if (t[2] === "in") { scope = t[3] ?? ""; vi = 4; }
33
38
  const values = t.slice(vi);
34
39
  if (values.length === 0) { warn("allow names no values"); continue; }
35
- allow.push({ effect: t[1], scope, values: values.sort(), raw: line });
40
+ allow.push({ effect: t[1], scope, values: [...new Set(values)].sort(), raw: line }); // dedup (set)
36
41
  } else if (t[0] === "forbid") {
37
42
  // Token-wise like the Rust/JVM parsers: the arrow must be its own whitespace-separated token
38
43
  // (`a->b` glued is malformed), and tokens past `b` are ignored. A regex here once accepted and