candor-ts 0.7.6 → 0.8.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.
Files changed (4) hide show
  1. package/package.json +1 -1
  2. package/policy.mjs +15 -8
  3. package/query.mjs +22 -4
  4. package/scan.mjs +33 -16
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "candor-ts",
3
- "version": "0.7.6",
3
+ "version": "0.8.0",
4
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": {
package/policy.mjs CHANGED
@@ -56,10 +56,12 @@ export function parsePolicy(text) {
56
56
  return { deny, allow, forbid };
57
57
  }
58
58
 
59
- /** §6.2 scope match: by NAME SEGMENT over ".", last segment a prefix. */
59
+ /** §6.2 scope match: by NAME SEGMENT, last segment a prefix.
60
+ * Segments split on BOTH "." and "::" — Rust/Java qualify with "::" while TS uses ".", and a shared
61
+ * policy must match across engines (a `Foo::bar` scope authored against Rust was inert in TS before). */
60
62
  export function scopeMatches(name, scope) {
61
- const segs = name.split(".");
62
- const parts = scope.split(".");
63
+ const segs = name.split(/[.:]+/).filter(Boolean);
64
+ const parts = scope.split(/[.:]+/).filter(Boolean);
63
65
  if (parts.length === 0 || parts.length > segs.length) return false;
64
66
  const last = parts[parts.length - 1], init = parts.slice(0, -1);
65
67
  outer: for (let i = 0; i + parts.length <= segs.length; i++) {
@@ -107,14 +109,19 @@ export function literalAllowed(effect, reached, values) {
107
109
  * transitive inferred; AS-EFF-008 allowlists over the transitive literal surfaces, the no-visible-
108
110
  * literal case flagged as uncertifiable; AS-EFF-009 forbid by reachability). One line per violation.
109
111
  */
112
+ // Each violation is a STRUCTURED record { rule, fn, effects, detail } (candor-spec §3.3 ⟨0.8⟩): `effects`
113
+ // is the specific denied/allowed effect set the violation concerns ([] for the 009 layer-flow, which has
114
+ // no single effect); `detail` is the message BODY (no `[AS-EFF-00x]` prefix — the rule carries the code).
115
+ // The console gate renders `[${rule}] ${detail}`; --gate-json emits the records verbatim.
110
116
  export function evaluatePolicy(pol, functions, callgraph, incomplete = new Map()) {
111
117
  const out = [];
112
118
  const surfaces = { Net: "hosts", Exec: "cmds", Fs: "paths", Db: "tables" };
119
+ const push = (rule, fn, effects, detail) => out.push({ rule, fn, effects, detail });
113
120
  for (const f of functions) {
114
121
  for (const r of pol.deny) {
115
122
  if (r.scope && !scopeMatches(f.fn, r.scope)) continue;
116
123
  const hits = r.effects.length === 0 ? f.inferred : f.inferred.filter((e) => r.effects.includes(e));
117
- if (hits.length) out.push(`[AS-EFF-006] \`${f.fn}\` performs { ${hits.join(", ")} }, forbidden by policy: \`${r.raw}\``);
124
+ if (hits.length) push("AS-EFF-006", f.fn, hits, `\`${f.fn}\` performs { ${hits.join(", ")} }, forbidden by policy: \`${r.raw}\``);
118
125
  }
119
126
  for (const r of pol.allow) {
120
127
  if (r.scope && !scopeMatches(f.fn, r.scope)) continue;
@@ -125,14 +132,14 @@ export function evaluatePolicy(pol, functions, callgraph, incomplete = new Map()
125
132
  // invisible forbidden endpoint (the masking evasion). Matches candor-java 0.5.29 / candor-rust.
126
133
  const surfaceIncomplete = incomplete.get(f.fn)?.has(r.effect);
127
134
  if (reached.length === 0 || surfaceIncomplete) {
128
- out.push(`[AS-EFF-008] \`${f.fn}\` performs ${r.effect} with no visible literal — the surface cannot be certified: \`${r.raw}\``);
135
+ push("AS-EFF-008", f.fn, [r.effect], `\`${f.fn}\` performs ${r.effect} with no visible literal — the surface cannot be certified: \`${r.raw}\``);
129
136
  } else {
130
137
  const bad = reached.filter((v) => !literalAllowed(r.effect, v, r.values));
131
- if (bad.length) out.push(`[AS-EFF-008] \`${f.fn}\` reaches { ${bad.join(", ")} } outside the allowlist: \`${r.raw}\``);
138
+ if (bad.length) push("AS-EFF-008", f.fn, [r.effect], `\`${f.fn}\` reaches { ${bad.join(", ")} } outside the allowlist: \`${r.raw}\``);
132
139
  }
133
140
  }
134
141
  }
135
- // AS-EFF-009: forbid A -> B by reachability over the callgraph.
142
+ // AS-EFF-009: forbid A -> B by reachability over the callgraph. No single effect → effects: [].
136
143
  for (const r of pol.forbid) {
137
144
  for (const fn of Object.keys(callgraph)) {
138
145
  if (!scopeMatches(fn, r.from)) continue;
@@ -146,7 +153,7 @@ export function evaluatePolicy(pol, functions, callgraph, incomplete = new Map()
146
153
  queue.push(c);
147
154
  }
148
155
  }
149
- if (hit) out.push(`[AS-EFF-009] \`${fn}\` reaches into a forbidden layer (via \`${hit}\`), violating policy: \`${r.raw}\``);
156
+ if (hit) push("AS-EFF-009", fn, [], `\`${fn}\` reaches into a forbidden layer (via \`${hit}\`), violating policy: \`${r.raw}\``);
150
157
  }
151
158
  }
152
159
  return out;
package/query.mjs CHANGED
@@ -37,7 +37,7 @@ const emit = (v) => console.log(JSON.stringify(v, null, 1));
37
37
  // package.json; SPEC_VERSION is the spec contract this build speaks. Reused, never re-littered.
38
38
  const QUERY_DIR = path.dirname(fileURLToPath(import.meta.url));
39
39
  const PKG_VERSION = JSON.parse(fs.readFileSync(path.join(QUERY_DIR, "package.json"), "utf8")).version;
40
- const SPEC_VERSION = "0.7";
40
+ const SPEC_VERSION = "0.8";
41
41
 
42
42
  // The full subcommand catalogue — name + one-line description (derived from the per-subcommand
43
43
  // comments + the module-doc header). The single source for the --help list AND the no-arg/unknown
@@ -95,7 +95,15 @@ switch (cmd) {
95
95
  printAgents(); // shared with scan.mjs — one implementation, can't diverge within an install
96
96
  break;
97
97
  case "parsepolicy": {
98
- emit(parsePolicy(fs.readFileSync(args[0], "utf8")));
98
+ // An unreadable/missing file is a clean exit-2 error, not an uncaught readFileSync stack trace.
99
+ let text;
100
+ try {
101
+ text = fs.readFileSync(args[0], "utf8");
102
+ } catch {
103
+ console.error(`candor: policy ${args[0] ?? "(no file given)"} could not be read`);
104
+ process.exit(2);
105
+ }
106
+ emit(parsePolicy(text));
99
107
  break;
100
108
  }
101
109
  case "show": {
@@ -234,8 +242,18 @@ switch (cmd) {
234
242
  for (const c of rev.get(n) ?? []) if (!affected.has(c)) { affected.add(c); queue.push(c); }
235
243
  }
236
244
  const violations = [];
237
- if (maybePolicy && maybePolicy !== "0" && maybePolicy !== "1" && fs.existsSync(maybePolicy)) {
238
- const pol = parsePolicy(fs.readFileSync(maybePolicy, "utf8"));
245
+ // A present policy arg (anything but the 0/1 verbosity sentinels) MUST exist and be readable —
246
+ // a typo'd path must be LOUD, not silently "no policy → ok:true, exit 0" (mirrors scan's --policy,
247
+ // which exits 2 on an unreadable file: a gate that can't read its policy can't certify anything).
248
+ if (maybePolicy && maybePolicy !== "0" && maybePolicy !== "1") {
249
+ let text;
250
+ try {
251
+ text = fs.readFileSync(maybePolicy, "utf8");
252
+ } catch {
253
+ console.error(`candor: policy ${maybePolicy} could not be read; whatif NOT evaluated against it`);
254
+ process.exit(2);
255
+ }
256
+ const pol = parsePolicy(text);
239
257
  for (const r of pol.deny) {
240
258
  if (r.effects.length && !r.effects.includes(eff)) continue; // pure ([]) forbids ANY effect
241
259
  for (const fn of affected)
package/scan.mjs CHANGED
@@ -36,7 +36,7 @@ const ENGINE_DIR = path.dirname(fileURLToPath(import.meta.url));
36
36
  // literal stamped into the envelope's `spec` field, so the doc lines and the report can never drift.
37
37
  // Reused, never re-littered.
38
38
  const PKG_VERSION = JSON.parse(fs.readFileSync(path.join(ENGINE_DIR, "package.json"), "utf8")).version;
39
- const SPEC_VERSION = "0.7";
39
+ const SPEC_VERSION = "0.8";
40
40
 
41
41
  // --version: a print-and-exit MODE, handled before the main arg walk so it never depends on a target.
42
42
  // Fully OFFLINE — candor never phones home. Staying current is the AGENT's job: read the installed
@@ -52,13 +52,14 @@ if (process.argv.includes("--version") || process.argv.includes("-V")) {
52
52
  if (process.argv.includes("-h") || process.argv.includes("--help")) {
53
53
  console.log(`candor-ts ${PKG_VERSION} — TypeScript/JavaScript effect scanner (candor-spec ${SPEC_VERSION})
54
54
 
55
- USAGE: candor-ts <dir | file.ts | tsconfig.json> [--out <prefix>] [--json] [--policy <file>] [--allow-js] [--agents] [--version]
55
+ USAGE: candor-ts <dir | file.ts | tsconfig.json> [--out <prefix>] [--json] [--policy <file>] [--gate-json <file>] [--allow-js] [--agents] [--version]
56
56
 
57
57
  <target> a dir, a .ts file, or a tsconfig.json to scan
58
58
  --out <prefix> write the report to <prefix>.json + <prefix>.callgraph.json
59
59
  --json print the report as JSON to stdout (instead of writing files)
60
60
  --policy <file> enforce a policy file (deny/pure/allow/forbid, candor-spec §6.2) — exit 1 on a
61
61
  violation, 2 if unreadable; honours $CANDOR_POLICY when the flag is absent
62
+ --gate-json <f> write the structured gate verdict { spec, ok, violations } as JSON (candor-spec §3.3)
62
63
  --allow-js also scan plain JS/Node (.js/.mjs/.cjs), not just TypeScript
63
64
  --agents print the agent contract for this build (AGENTS.md)
64
65
  -V, --version print the build and spec version (offline)
@@ -73,21 +74,25 @@ See https://github.com/tombaldwin/candor`);
73
74
  // missing/flag-shaped value; an unknown flag fails; flags may precede the target. `--agents` is a
74
75
  // flag (a print-and-exit MODE) — it must NOT fire when it is the VALUE of --out/--policy, which the
75
76
  // value-consuming skip handles, nor produce a "lying unknown flag" error for a real flag given first.
76
- const usage = "usage: candor-ts <dir | file.ts | tsconfig.json> [--out <prefix>] [--json] [--policy <file>] [--allow-js] [--agents] [--version] [--help]";
77
+ const usage = "usage: candor-ts <dir | file.ts | tsconfig.json> [--out <prefix>] [--json] [--policy <file>] [--gate-json <file>] [--allow-js] [--agents] [--version] [--help]";
77
78
  const argv = process.argv.slice(2);
78
- let target = null, outPrefix = null, policyPath = process.env.CANDOR_POLICY ?? null, allowJs = false, wantAgents = false, wantJson = false;
79
+ let target = null, outPrefix = null, policyPath = process.env.CANDOR_POLICY ?? null, gateJsonPath = null, allowJs = false, wantAgents = false, wantJson = false;
79
80
  for (let i = 0; i < argv.length; i++) {
80
81
  const a = argv[i];
81
82
  if (a === "--agents") wantAgents = true;
82
83
  else if (a === "--json") wantJson = true;
83
84
  else if (a === "--allow-js") allowJs = true;
84
- else if (a === "--out" || a === "--policy") {
85
+ else if (a === "--out" || a === "--policy" || a === "--gate-json") {
85
86
  const v = argv[i + 1];
86
87
  if (v === undefined || v.startsWith("--")) { console.error(`candor-ts: ${a} requires a value (${usage})`); process.exit(2); }
87
- if (a === "--out") outPrefix = v; else policyPath = v;
88
+ if (a === "--out") outPrefix = v; else if (a === "--policy") policyPath = v; else gateJsonPath = v;
88
89
  i++;
89
90
  }
90
- else if (a.startsWith("--")) { console.error(`candor-ts: unknown flag ${a} (${usage})`); process.exit(2); }
91
+ // Any leading-dash token that isn't a recognized flag is an unknown flag — NOT a positional target
92
+ // (SPEC §6.2/§7). `-h`/`-V`/`--help`/`--version` are print-and-exit modes consumed above, so by here
93
+ // a single-dash token (`-x`, the typo `-policy`) can only be a mistake; treating it as the scan
94
+ // target would silently scan the wrong thing.
95
+ else if (a.startsWith("-")) { console.error(`candor-ts: unknown flag ${a} (${usage})`); process.exit(2); }
91
96
  else if (target === null) target = a;
92
97
  else if (outPrefix === null) outPrefix = a; // legacy positional prefix
93
98
  else { console.error(`candor-ts: unexpected extra argument ${a} (${usage})`); process.exit(2); }
@@ -193,8 +198,8 @@ if (!wantJson) fs.mkdirSync(path.dirname(path.resolve(outPrefix)), { recursive:
193
198
  const deps = JSON.parse(fs.readFileSync(pkg, "utf8")).dependencies ?? {};
194
199
  if (Object.keys(deps).length > 0)
195
200
  console.error("candor-ts: WARNING — the target declares dependencies but has no node_modules; " +
196
- "imports will not resolve and most functions will read Unknown. " +
197
- "Run `npm install` in the target first.");
201
+ "imports won't resolve, so calls into those packages read pure/invisible (not Unknown) " +
202
+ "and effects through them are silently dropped. Run `npm install` in the target first.");
198
203
  } catch {}
199
204
  }
200
205
  // Prisma's client types are GENERATED — a project with the prisma dependency but no generated
@@ -2070,6 +2075,7 @@ if (unlistedSeen.size > 0) {
2070
2075
  }
2071
2076
 
2072
2077
  // ---- the standing §6.2 gate (--policy / CANDOR_POLICY) --------------------------------------------
2078
+ let gateViolations = [];
2073
2079
  if (policyPath) {
2074
2080
  let text;
2075
2081
  try {
@@ -2083,11 +2089,22 @@ if (policyPath) {
2083
2089
  // java/rust engines (not a report field) — passed to the gate so an incomplete surface fails closed.
2084
2090
  const incompleteMap = new Map();
2085
2091
  for (const [name, rec] of fns) if (rec.incomplete.size) incompleteMap.set(name, rec.incomplete);
2086
- const v = evaluatePolicy(parsePolicy(text), functions, cg, incompleteMap);
2087
- for (const line of v) console.log(line);
2088
- if (v.length) {
2089
- console.error(`candor-ts: ${v.length} policy violation(s)`);
2090
- process.exit(1);
2091
- }
2092
- console.error("candor-ts: policy ✓");
2092
+ gateViolations = evaluatePolicy(parsePolicy(text), functions, cg, incompleteMap);
2093
+ // In --json mode stdout is the §2 envelope and must stay pure JSON — route the gate's
2094
+ // [AS-EFF-…] violation lines to stderr so a `candor-ts --json --policy … | jq` pipe never breaks.
2095
+ const emitViolation = wantJson ? (l) => console.error(l) : (l) => console.log(l);
2096
+ for (const x of gateViolations) emitViolation(`[${x.rule}] ${x.detail}`);
2097
+ }
2098
+ // --gate-json ⟨0.8⟩: the structured gate verdict { spec, ok, violations:[{rule,fn,effects,detail}] }, from
2099
+ // the SAME gateViolations that set the exit code (so it can't disagree). Written whenever the flag is set —
2100
+ // ok:true,[] when no gate is configured. Must precede the exit(1) below.
2101
+ if (gateJsonPath) {
2102
+ const verdict = JSON.stringify({ spec: SPEC_VERSION, ok: gateViolations.length === 0, violations: gateViolations }, null, 1);
2103
+ if (gateJsonPath === "-") console.log(verdict);
2104
+ else writeAtomic(gateJsonPath, verdict + "\n");
2105
+ }
2106
+ if (policyPath && gateViolations.length) {
2107
+ console.error(`candor-ts: ${gateViolations.length} policy violation(s)`);
2108
+ process.exit(1);
2093
2109
  }
2110
+ if (policyPath) console.error("candor-ts: policy ✓");