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.
- package/package.json +1 -1
- package/policy.mjs +15 -8
- package/query.mjs +22 -4
- package/scan.mjs +33 -16
package/package.json
CHANGED
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
|
|
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)
|
|
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
|
-
|
|
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)
|
|
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)
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
238
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
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 ✓");
|