candor-ts 0.7.7 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "candor-ts",
3
- "version": "0.7.7",
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
@@ -109,14 +109,19 @@ export function literalAllowed(effect, reached, values) {
109
109
  * transitive inferred; AS-EFF-008 allowlists over the transitive literal surfaces, the no-visible-
110
110
  * literal case flagged as uncertifiable; AS-EFF-009 forbid by reachability). One line per violation.
111
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.
112
116
  export function evaluatePolicy(pol, functions, callgraph, incomplete = new Map()) {
113
117
  const out = [];
114
118
  const surfaces = { Net: "hosts", Exec: "cmds", Fs: "paths", Db: "tables" };
119
+ const push = (rule, fn, effects, detail) => out.push({ rule, fn, effects, detail });
115
120
  for (const f of functions) {
116
121
  for (const r of pol.deny) {
117
122
  if (r.scope && !scopeMatches(f.fn, r.scope)) continue;
118
123
  const hits = r.effects.length === 0 ? f.inferred : f.inferred.filter((e) => r.effects.includes(e));
119
- 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}\``);
120
125
  }
121
126
  for (const r of pol.allow) {
122
127
  if (r.scope && !scopeMatches(f.fn, r.scope)) continue;
@@ -127,14 +132,14 @@ export function evaluatePolicy(pol, functions, callgraph, incomplete = new Map()
127
132
  // invisible forbidden endpoint (the masking evasion). Matches candor-java 0.5.29 / candor-rust.
128
133
  const surfaceIncomplete = incomplete.get(f.fn)?.has(r.effect);
129
134
  if (reached.length === 0 || surfaceIncomplete) {
130
- 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}\``);
131
136
  } else {
132
137
  const bad = reached.filter((v) => !literalAllowed(r.effect, v, r.values));
133
- 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}\``);
134
139
  }
135
140
  }
136
141
  }
137
- // 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: [].
138
143
  for (const r of pol.forbid) {
139
144
  for (const fn of Object.keys(callgraph)) {
140
145
  if (!scopeMatches(fn, r.from)) continue;
@@ -148,7 +153,7 @@ export function evaluatePolicy(pol, functions, callgraph, incomplete = new Map()
148
153
  queue.push(c);
149
154
  }
150
155
  }
151
- 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}\``);
152
157
  }
153
158
  }
154
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
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,18 +74,18 @@ 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
91
  // Any leading-dash token that isn't a recognized flag is an unknown flag — NOT a positional target
@@ -2074,6 +2075,7 @@ if (unlistedSeen.size > 0) {
2074
2075
  }
2075
2076
 
2076
2077
  // ---- the standing §6.2 gate (--policy / CANDOR_POLICY) --------------------------------------------
2078
+ let gateViolations = [];
2077
2079
  if (policyPath) {
2078
2080
  let text;
2079
2081
  try {
@@ -2087,14 +2089,22 @@ if (policyPath) {
2087
2089
  // java/rust engines (not a report field) — passed to the gate so an incomplete surface fails closed.
2088
2090
  const incompleteMap = new Map();
2089
2091
  for (const [name, rec] of fns) if (rec.incomplete.size) incompleteMap.set(name, rec.incomplete);
2090
- const v = evaluatePolicy(parsePolicy(text), functions, cg, incompleteMap);
2092
+ gateViolations = evaluatePolicy(parsePolicy(text), functions, cg, incompleteMap);
2091
2093
  // In --json mode stdout is the §2 envelope and must stay pure JSON — route the gate's
2092
2094
  // [AS-EFF-…] violation lines to stderr so a `candor-ts --json --policy … | jq` pipe never breaks.
2093
2095
  const emitViolation = wantJson ? (l) => console.error(l) : (l) => console.log(l);
2094
- for (const line of v) emitViolation(line);
2095
- if (v.length) {
2096
- console.error(`candor-ts: ${v.length} policy violation(s)`);
2097
- process.exit(1);
2098
- }
2099
- console.error("candor-ts: policy ✓");
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);
2100
2109
  }
2110
+ if (policyPath) console.error("candor-ts: policy ✓");