candor-ts 0.4.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 +117 -0
- package/LICENSE-APACHE +201 -0
- package/LICENSE-MIT +21 -0
- package/PROVE-IT.md +82 -0
- package/README.md +109 -0
- package/package.json +44 -0
- package/policy.mjs +140 -0
- package/query.mjs +171 -0
- package/scan.mjs +800 -0
package/policy.mjs
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The §6.2 policy grammar + gate semantics, shared by query.mjs (whatif/parsepolicy) and scan.mjs
|
|
3
|
+
* (the standing --policy gate). One parser, one matcher set — the same single-source rule the Rust
|
|
4
|
+
* engines follow (candor-classify::policy), so the TS gate can never disagree with its own whatif.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export const EFFECTS = ["Net", "Fs", "Db", "Exec", "Env", "Clock", "Ipc", "Log", "Rand", "Clipboard"];
|
|
8
|
+
const ALLOW_EFFECTS = new Set(["Net", "Exec", "Fs", "Db"]); // the four literal surfaces
|
|
9
|
+
|
|
10
|
+
export function parsePolicy(text) {
|
|
11
|
+
const deny = [], allow = [], forbid = [];
|
|
12
|
+
for (const rawLine of text.split("\n")) {
|
|
13
|
+
const line = rawLine.split("#")[0].trim();
|
|
14
|
+
if (!line) continue;
|
|
15
|
+
const t = line.split(/\s+/);
|
|
16
|
+
const warn = (why) => console.error(`candor: ignoring policy rule (${why}): ${line}`);
|
|
17
|
+
if (t[0] === "deny") {
|
|
18
|
+
const effects = [];
|
|
19
|
+
let scope = "";
|
|
20
|
+
for (const tok of t.slice(1)) {
|
|
21
|
+
if (EFFECTS.includes(tok) || tok === "Unknown") effects.push(tok);
|
|
22
|
+
else { scope = tok; break; }
|
|
23
|
+
}
|
|
24
|
+
if (effects.length === 0) { warn("deny names no known effect"); continue; }
|
|
25
|
+
deny.push({ effects: effects.sort(), scope, raw: line });
|
|
26
|
+
} else if (t[0] === "pure") {
|
|
27
|
+
deny.push({ effects: [], scope: t[1] ?? "", raw: line });
|
|
28
|
+
} else if (t[0] === "allow") {
|
|
29
|
+
if (t.length < 3) { warn("allow names no values"); continue; }
|
|
30
|
+
if (!ALLOW_EFFECTS.has(t[1])) { warn("allow supports only Net hosts / Exec commands / Fs paths / Db tables"); continue; }
|
|
31
|
+
let scope = "", vi = 2;
|
|
32
|
+
if (t[2] === "in") { scope = t[3] ?? ""; vi = 4; }
|
|
33
|
+
const values = t.slice(vi);
|
|
34
|
+
if (values.length === 0) { warn("allow names no values"); continue; }
|
|
35
|
+
allow.push({ effect: t[1], scope, values: values.sort(), raw: line });
|
|
36
|
+
} else if (t[0] === "forbid") {
|
|
37
|
+
// Token-wise like the Rust/JVM parsers: the arrow must be its own whitespace-separated token
|
|
38
|
+
// (`a->b` glued is malformed), and tokens past `b` are ignored. A regex here once accepted and
|
|
39
|
+
// rejected DIFFERENT lines than the other engines — the one thing a shared gate must not do.
|
|
40
|
+
const [a, arrow, b] = [t[1] ?? "", t[2] ?? "", t[3] ?? ""];
|
|
41
|
+
if (!a || arrow !== "->" || !b) { warn("malformed forbid (want `forbid <scope> -> <scope>`)"); continue; }
|
|
42
|
+
forbid.push({ from: a, to: b, raw: line });
|
|
43
|
+
} else {
|
|
44
|
+
warn("unknown rule kind");
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return { deny, allow, forbid };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** §6.2 scope match: by NAME SEGMENT over ".", last segment a prefix. */
|
|
51
|
+
export function scopeMatches(name, scope) {
|
|
52
|
+
const segs = name.split(".");
|
|
53
|
+
const parts = scope.split(".");
|
|
54
|
+
if (parts.length === 0 || parts.length > segs.length) return false;
|
|
55
|
+
const last = parts[parts.length - 1], init = parts.slice(0, -1);
|
|
56
|
+
outer: for (let i = 0; i + parts.length <= segs.length; i++) {
|
|
57
|
+
for (let k = 0; k < init.length; k++) if (segs[i + k] !== init[k]) continue outer;
|
|
58
|
+
if (segs[i + parts.length - 1].startsWith(last)) return true;
|
|
59
|
+
}
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ---- the effect-specific literal matchers (§6.2), mirroring the Rust/JVM semantics ---------------
|
|
64
|
+
export function hostPart(h) {
|
|
65
|
+
if (h.startsWith("[")) return h.slice(1).split("]")[0]; // [ipv6][:port]
|
|
66
|
+
if ((h.match(/:/g) ?? []).length > 1) return h; // bare ipv6 — no port to strip
|
|
67
|
+
return h.split(":")[0];
|
|
68
|
+
}
|
|
69
|
+
export function cmdBase(c) {
|
|
70
|
+
const first = c.trim().split(/\s+/)[0];
|
|
71
|
+
return first.split(/[/\\]/).pop();
|
|
72
|
+
}
|
|
73
|
+
export function pathCovered(a, r) {
|
|
74
|
+
const norm = (s) => s.split(/[/\\]/).filter((c) => c && c !== ".");
|
|
75
|
+
if (norm(r).includes("..")) return false;
|
|
76
|
+
const abs = (s) => s.startsWith("/") || s.startsWith("\\");
|
|
77
|
+
if (abs(a) !== abs(r)) return false;
|
|
78
|
+
const ac = norm(a), rc = norm(r);
|
|
79
|
+
return ac.length <= rc.length && ac.every((x, i) => x === rc[i]);
|
|
80
|
+
}
|
|
81
|
+
export function tableCovered(a, r) {
|
|
82
|
+
a = a.toLowerCase(); r = r.toLowerCase();
|
|
83
|
+
if (a.endsWith(".*")) return r.startsWith(a.slice(0, -1)); // "schema." prefix
|
|
84
|
+
return a === r;
|
|
85
|
+
}
|
|
86
|
+
export function literalAllowed(effect, reached, values) {
|
|
87
|
+
switch (effect) {
|
|
88
|
+
case "Net": return values.some((a) => hostPart(a) === hostPart(reached));
|
|
89
|
+
case "Exec": return values.some((a) => cmdBase(a) === cmdBase(reached));
|
|
90
|
+
case "Fs": return values.some((a) => pathCovered(a, reached));
|
|
91
|
+
case "Db": return values.some((a) => tableCovered(a, reached));
|
|
92
|
+
default: return values.includes(reached);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The standing gate: evaluate a parsed policy over a report + callgraph (AS-EFF-006 deny/pure over
|
|
98
|
+
* transitive inferred; AS-EFF-008 allowlists over the transitive literal surfaces, the no-visible-
|
|
99
|
+
* literal case flagged as uncertifiable; AS-EFF-009 forbid by reachability). One line per violation.
|
|
100
|
+
*/
|
|
101
|
+
export function evaluatePolicy(pol, functions, callgraph) {
|
|
102
|
+
const out = [];
|
|
103
|
+
const surfaces = { Net: "hosts", Exec: "cmds", Fs: "paths", Db: "tables" };
|
|
104
|
+
for (const f of functions) {
|
|
105
|
+
for (const r of pol.deny) {
|
|
106
|
+
if (r.scope && !scopeMatches(f.fn, r.scope)) continue;
|
|
107
|
+
const hits = r.effects.length === 0 ? f.inferred : f.inferred.filter((e) => r.effects.includes(e));
|
|
108
|
+
if (hits.length) out.push(`[AS-EFF-006] \`${f.fn}\` performs { ${hits.join(", ")} }, forbidden by policy: \`${r.raw}\``);
|
|
109
|
+
}
|
|
110
|
+
for (const r of pol.allow) {
|
|
111
|
+
if (r.scope && !scopeMatches(f.fn, r.scope)) continue;
|
|
112
|
+
if (!f.inferred.includes(r.effect)) continue;
|
|
113
|
+
const reached = f[surfaces[r.effect]] ?? [];
|
|
114
|
+
if (reached.length === 0) {
|
|
115
|
+
out.push(`[AS-EFF-008] \`${f.fn}\` performs ${r.effect} with no visible literal — the surface cannot be certified: \`${r.raw}\``);
|
|
116
|
+
} else {
|
|
117
|
+
const bad = reached.filter((v) => !literalAllowed(r.effect, v, r.values));
|
|
118
|
+
if (bad.length) out.push(`[AS-EFF-008] \`${f.fn}\` reaches { ${bad.join(", ")} } outside the allowlist: \`${r.raw}\``);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
// AS-EFF-009: forbid A -> B by reachability over the callgraph.
|
|
123
|
+
for (const r of pol.forbid) {
|
|
124
|
+
for (const fn of Object.keys(callgraph)) {
|
|
125
|
+
if (!scopeMatches(fn, r.from)) continue;
|
|
126
|
+
const seen = new Set([fn]), queue = [fn];
|
|
127
|
+
let hit = null;
|
|
128
|
+
while (queue.length && !hit) {
|
|
129
|
+
for (const c of callgraph[queue.pop()] ?? []) {
|
|
130
|
+
if (seen.has(c)) continue;
|
|
131
|
+
seen.add(c);
|
|
132
|
+
if (scopeMatches(c, r.to)) { hit = c; break; }
|
|
133
|
+
queue.push(c);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (hit) out.push(`[AS-EFF-009] \`${fn}\` reaches into a forbidden layer (via \`${hit}\`), violating policy: \`${r.raw}\``);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return out;
|
|
140
|
+
}
|
package/query.mjs
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* candor-ts queries — the SPEC §3.1 read-only query surface + the §6.2 policy grammar, over the
|
|
4
|
+
* report + callgraph sidecar that scan.mjs writes. Same command names, same JSON shapes, same match
|
|
5
|
+
* ladder as the Rust and JVM engines (the cross-impl conformance suite diffs all three).
|
|
6
|
+
*
|
|
7
|
+
* Provenance note (honesty): the ORIGINAL scan.mjs was written from the spec documents alone — the
|
|
8
|
+
* clean-room derivability proof. This file was added later, implemented from the same spec text,
|
|
9
|
+
* but its author had by then read the reference engines; the ongoing guarantee for it is the
|
|
10
|
+
* conformance differential, not clean-room provenance.
|
|
11
|
+
*
|
|
12
|
+
* node query.mjs parsepolicy <file>
|
|
13
|
+
* node query.mjs show <prefix> <query> <0|1>
|
|
14
|
+
* node query.mjs where <prefix> <Effect> <0|1>
|
|
15
|
+
* node query.mjs callers <prefix> <query> <0|1>
|
|
16
|
+
* node query.mjs map <prefix> <0|1>
|
|
17
|
+
* node query.mjs whatif <prefix> <fn> <Effect> [policy-file] [0|1]
|
|
18
|
+
*/
|
|
19
|
+
import fs from "node:fs";
|
|
20
|
+
|
|
21
|
+
import { parsePolicy, scopeMatches } from "./policy.mjs";
|
|
22
|
+
|
|
23
|
+
// ---- the §3.1 match ladder: exact > segment-suffix > substring ------------------------------------
|
|
24
|
+
function matchTier(name, q) {
|
|
25
|
+
if (name === q) return 3;
|
|
26
|
+
if (name.endsWith(q) && /[.$]$/.test(name.slice(0, name.length - q.length))) return 2;
|
|
27
|
+
if (name.includes(q)) return 1;
|
|
28
|
+
return 0;
|
|
29
|
+
}
|
|
30
|
+
function matches(names, q) {
|
|
31
|
+
const best = Math.max(0, ...names.map((n) => matchTier(n, q)));
|
|
32
|
+
return best === 0 ? [] : names.filter((n) => matchTier(n, q) >= best);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function loadReport(prefix) {
|
|
36
|
+
const d = JSON.parse(fs.readFileSync(`${prefix}.json`, "utf8"));
|
|
37
|
+
return d.functions ?? d;
|
|
38
|
+
}
|
|
39
|
+
function loadCallgraph(prefix) {
|
|
40
|
+
return JSON.parse(fs.readFileSync(`${prefix}.callgraph.json`, "utf8"));
|
|
41
|
+
}
|
|
42
|
+
const emit = (v) => console.log(JSON.stringify(v, null, 1));
|
|
43
|
+
|
|
44
|
+
const [, , cmd, ...args] = process.argv;
|
|
45
|
+
switch (cmd) {
|
|
46
|
+
case "parsepolicy": {
|
|
47
|
+
emit(parsePolicy(fs.readFileSync(args[0], "utf8")));
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
case "show": {
|
|
51
|
+
const [prefix, q] = args;
|
|
52
|
+
const fns = loadReport(prefix);
|
|
53
|
+
const hit = new Set(matches(fns.map((e) => e.fn), q));
|
|
54
|
+
const out = fns.filter((e) => hit.has(e.fn)).map((e) => {
|
|
55
|
+
const o = { fn: e.fn, inferred: e.inferred, direct: e.direct };
|
|
56
|
+
if (e.fs?.length) o.fs = e.fs;
|
|
57
|
+
if (e.hosts?.length) o.hosts = e.hosts;
|
|
58
|
+
if (e.tables?.length) o.tables = e.tables;
|
|
59
|
+
o.unresolved = e.unresolved;
|
|
60
|
+
return o;
|
|
61
|
+
});
|
|
62
|
+
emit(out);
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
case "where": {
|
|
66
|
+
const [prefix, eff] = args;
|
|
67
|
+
const fns = loadReport(prefix);
|
|
68
|
+
emit({
|
|
69
|
+
effect: eff,
|
|
70
|
+
directly: fns.filter((e) => e.direct.includes(eff)).map((e) => e.fn).sort(),
|
|
71
|
+
inherited: fns.filter((e) => e.inferred.includes(eff) && !e.direct.includes(eff)).map((e) => e.fn).sort(),
|
|
72
|
+
});
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
case "callers": {
|
|
76
|
+
const [prefix, q] = args;
|
|
77
|
+
const cg = loadCallgraph(prefix);
|
|
78
|
+
const names = Object.keys(cg);
|
|
79
|
+
const targets = matches(names, q);
|
|
80
|
+
const rev = new Map();
|
|
81
|
+
for (const [caller, callees] of Object.entries(cg))
|
|
82
|
+
for (const c of callees) (rev.get(c) ?? rev.set(c, []).get(c)).push(caller);
|
|
83
|
+
const direct = new Set(), transitive = new Set();
|
|
84
|
+
const queue = [...targets];
|
|
85
|
+
for (const t of targets) for (const c of rev.get(t) ?? []) direct.add(c);
|
|
86
|
+
while (queue.length) {
|
|
87
|
+
const n = queue.pop();
|
|
88
|
+
for (const c of rev.get(n) ?? []) if (!transitive.has(c) && !targets.includes(c)) { transitive.add(c); queue.push(c); }
|
|
89
|
+
}
|
|
90
|
+
emit({ of: targets, direct: [...direct].sort(), transitive: [...transitive].sort() });
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
case "map": {
|
|
94
|
+
const [prefix] = args;
|
|
95
|
+
const fns = loadReport(prefix);
|
|
96
|
+
const mods = {};
|
|
97
|
+
for (const e of fns) {
|
|
98
|
+
const mod = e.fn.includes(".") ? e.fn.split(".").slice(0, -1).join(".") : "(root)";
|
|
99
|
+
const m = (mods[mod] ??= { effects: new Set(), functions: 0 });
|
|
100
|
+
for (const x of e.inferred) m.effects.add(x);
|
|
101
|
+
m.functions += 1;
|
|
102
|
+
}
|
|
103
|
+
emit(Object.fromEntries(Object.entries(mods).sort()
|
|
104
|
+
.map(([k, v]) => [k, { effects: [...v.effects].sort(), functions: v.functions }])));
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
case "diff": {
|
|
108
|
+
// per-function effect delta vs a baseline: {changes: [{fn, gained, lost}]} — the envelope shape
|
|
109
|
+
// the conformance suite pins (diff-vs-self must be {changes: []}).
|
|
110
|
+
const [curPrefix, basePrefix] = args;
|
|
111
|
+
const cur = new Map(loadReport(curPrefix).map((e) => [e.fn, new Set(e.inferred)]));
|
|
112
|
+
const base = new Map(loadReport(basePrefix).map((e) => [e.fn, new Set(e.inferred)]));
|
|
113
|
+
const changes = [];
|
|
114
|
+
for (const fn of new Set([...cur.keys(), ...base.keys()])) {
|
|
115
|
+
const c = cur.get(fn) ?? new Set(), b = base.get(fn) ?? new Set();
|
|
116
|
+
const gained = [...c].filter((e) => !b.has(e)).sort();
|
|
117
|
+
const lost = [...b].filter((e) => !c.has(e)).sort();
|
|
118
|
+
if (gained.length || lost.length) changes.push({ fn, gained, lost });
|
|
119
|
+
}
|
|
120
|
+
changes.sort((a, b) => a.fn.localeCompare(b.fn));
|
|
121
|
+
emit({ changes });
|
|
122
|
+
process.exit(changes.some((c) => c.gained.length) ? 1 : 0);
|
|
123
|
+
}
|
|
124
|
+
case "reachable": {
|
|
125
|
+
// what the app DOES at runtime: effects unioned over the entry points (SPEC §3.1; same JSON
|
|
126
|
+
// shape as the Rust engine: {entryPoints, effects: {Eff: {count, via}}}).
|
|
127
|
+
const [prefix] = args;
|
|
128
|
+
const fns = loadReport(prefix);
|
|
129
|
+
const roots = fns.filter((e) => e.entryPoint);
|
|
130
|
+
const byEff = {};
|
|
131
|
+
for (const e of roots) for (const x of e.inferred) (byEff[x] ??= []).push(e.fn);
|
|
132
|
+
emit({ entryPoints: roots.length,
|
|
133
|
+
effects: Object.fromEntries(Object.entries(byEff).sort()
|
|
134
|
+
.map(([k, v]) => [k, { count: v.length, via: v.sort() }])) });
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
case "whatif": {
|
|
138
|
+
const [prefix, target, eff, maybePolicy] = args;
|
|
139
|
+
const cg = loadCallgraph(prefix);
|
|
140
|
+
const names = Object.keys(cg);
|
|
141
|
+
const targets = matches(names, target);
|
|
142
|
+
if (targets.length === 0) {
|
|
143
|
+
console.error(`candor: no function matching \`${target}\` in the call graph`);
|
|
144
|
+
process.exit(2);
|
|
145
|
+
}
|
|
146
|
+
const rev = new Map();
|
|
147
|
+
for (const [caller, callees] of Object.entries(cg))
|
|
148
|
+
for (const c of callees) (rev.get(c) ?? rev.set(c, []).get(c)).push(caller);
|
|
149
|
+
const affected = new Set(targets);
|
|
150
|
+
const queue = [...targets];
|
|
151
|
+
while (queue.length) {
|
|
152
|
+
const n = queue.pop();
|
|
153
|
+
for (const c of rev.get(n) ?? []) if (!affected.has(c)) { affected.add(c); queue.push(c); }
|
|
154
|
+
}
|
|
155
|
+
const violations = [];
|
|
156
|
+
if (maybePolicy && maybePolicy !== "0" && maybePolicy !== "1" && fs.existsSync(maybePolicy)) {
|
|
157
|
+
const pol = parsePolicy(fs.readFileSync(maybePolicy, "utf8"));
|
|
158
|
+
for (const r of pol.deny) {
|
|
159
|
+
if (r.effects.length && !r.effects.includes(eff)) continue; // pure ([]) forbids ANY effect
|
|
160
|
+
for (const fn of affected)
|
|
161
|
+
if (!r.scope || scopeMatches(fn, r.scope))
|
|
162
|
+
violations.push({ fn, rule: `deny ${r.effects.join(" ") || "(pure)"} ${r.scope}`.trim() });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
emit({ of: targets, effect: eff, affected: [...affected].sort(), violations, ok: violations.length === 0 });
|
|
166
|
+
process.exit(violations.length ? 1 : 0);
|
|
167
|
+
}
|
|
168
|
+
default:
|
|
169
|
+
console.error("usage: node query.mjs <parsepolicy|show|where|callers|map|whatif> …");
|
|
170
|
+
process.exit(2);
|
|
171
|
+
}
|