@nugehs/bouncer 0.1.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.
@@ -0,0 +1,49 @@
1
+ import fs from "node:fs";
2
+ import { loadPacks } from "./packs.js";
3
+ import { buildContext } from "./engine.js";
4
+
5
+ /** Sanity-check that config resolves, the target repo exists, the adapter loads, and packs parse. */
6
+ export function getDoctorReport(cfg) {
7
+ const checks = [];
8
+
9
+ const repoOk = fs.existsSync(cfg.target.repoRoot);
10
+ checks.push({ name: "target repo", ok: repoOk, detail: cfg.target.repoRoot });
11
+
12
+ let filesScanned = 0;
13
+ let adapterOk = false;
14
+ try {
15
+ const ctx = buildContext(cfg);
16
+ adapterOk = true;
17
+ filesScanned = ctx.files.length;
18
+ } catch (error) {
19
+ checks.push({ name: "adapter", ok: false, detail: error.message });
20
+ }
21
+ if (adapterOk) {
22
+ checks.push({ name: "adapter", ok: true, detail: `${cfg.target.adapter} · ${filesScanned} files visible` });
23
+ }
24
+
25
+ let packsOk = false;
26
+ let packList = [];
27
+ try {
28
+ const packs = loadPacks(cfg);
29
+ packsOk = true;
30
+ packList = packs.map((p) => `${p.id} (${p.rules.length} rules)`);
31
+ } catch (error) {
32
+ checks.push({ name: "packs", ok: false, detail: error.message });
33
+ }
34
+ if (packsOk) {
35
+ checks.push({ name: "packs", ok: true, detail: packList.join(", ") });
36
+ }
37
+
38
+ const ok = checks.every((c) => c.ok);
39
+ return { ok, configPath: cfg._path, checks };
40
+ }
41
+
42
+ export function formatDoctorReport(report) {
43
+ const lines = ["", " bouncer doctor", ` config: ${report.configPath}`, ""];
44
+ for (const c of report.checks) {
45
+ lines.push(` ${c.ok ? "✓" : "✗"} ${c.name}: ${c.detail}`);
46
+ }
47
+ lines.push("", ` ${report.ok ? "✓ all checks passed" : "✗ some checks failed"}`, "");
48
+ return lines.join("\n");
49
+ }
@@ -0,0 +1,229 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { walk, matchesAnyGlob } from "./walk.js";
4
+ import * as nextAdapter from "./adapters/next.js";
5
+ import * as reactNativeAdapter from "./adapters/react-native.js";
6
+
7
+ const ADAPTERS = {
8
+ next: nextAdapter,
9
+ "react-native": reactNativeAdapter,
10
+ };
11
+
12
+ const MAX_HITS_PER_RULE = 8;
13
+
14
+ /**
15
+ * Build a scan context for a target repo: the list of relative source paths plus a
16
+ * lazy, cached file reader. Shared across every rule so the repo is walked once.
17
+ */
18
+ function buildContext(cfg) {
19
+ const adapter = ADAPTERS[cfg.target.adapter];
20
+ if (!adapter) throw new Error(`Unknown target adapter: ${cfg.target.adapter}`);
21
+
22
+ const root = cfg.target.repoRoot;
23
+ const exts = new Set(adapter.SOURCE_EXT);
24
+ const roots = cfg.target.roots && cfg.target.roots.length ? cfg.target.roots : ["."];
25
+
26
+ const files = [];
27
+ const seen = new Set();
28
+ for (const r of roots) {
29
+ const base = path.resolve(root, r);
30
+ for (const abs of walk(base, (name) => extOk(name, exts))) {
31
+ if (seen.has(abs)) continue;
32
+ seen.add(abs);
33
+ files.push({ abs, rel: path.relative(root, abs).split(path.sep).join("/") });
34
+ }
35
+ }
36
+
37
+ const cache = new Map();
38
+ const readFile = (abs) => {
39
+ if (cache.has(abs)) return cache.get(abs);
40
+ let text = "";
41
+ try {
42
+ text = fs.readFileSync(abs, "utf8");
43
+ } catch {
44
+ text = "";
45
+ }
46
+ cache.set(abs, text);
47
+ return text;
48
+ };
49
+
50
+ return { adapter, files, readFile, root };
51
+ }
52
+
53
+ function extOk(name, exts) {
54
+ // governance artifacts may be .md/.mdx/.pdf — allow anything; ext-filtering is a
55
+ // soft optimisation, surface globs do the real narrowing.
56
+ const dot = name.lastIndexOf(".");
57
+ if (dot === -1) return false;
58
+ const ext = name.slice(dot);
59
+ return exts.has(ext) || [".md", ".mdx", ".json", ".yml", ".yaml", ".pdf"].includes(ext);
60
+ }
61
+
62
+ /**
63
+ * Evaluate one assertion node.
64
+ * Returns { ok, hits, scanned } where `scanned` is how many files the node looked at
65
+ * (0 => the surface could not be located => the rule is "unknown", not a pass).
66
+ */
67
+ function evalNode(node, ctx) {
68
+ if (!node || typeof node !== "object") {
69
+ throw new Error(`Invalid assertion node: ${JSON.stringify(node)}`);
70
+ }
71
+
72
+ if (Array.isArray(node.allOf)) {
73
+ const parts = node.allOf.map((n) => evalNode(n, ctx));
74
+ return {
75
+ ok: parts.every((p) => p.ok),
76
+ hits: parts.flatMap((p) => p.hits),
77
+ scanned: parts.reduce((a, p) => a + p.scanned, 0),
78
+ };
79
+ }
80
+
81
+ if (Array.isArray(node.anyOf)) {
82
+ const parts = node.anyOf.map((n) => evalNode(n, ctx));
83
+ return {
84
+ ok: parts.some((p) => p.ok),
85
+ hits: parts.filter((p) => p.ok).flatMap((p) => p.hits),
86
+ scanned: parts.reduce((a, p) => a + p.scanned, 0),
87
+ };
88
+ }
89
+
90
+ if (node.not) {
91
+ const r = evalNode(node.not, ctx);
92
+ return { ok: !r.ok, hits: r.hits, scanned: r.scanned };
93
+ }
94
+
95
+ if (typeof node.find === "string") {
96
+ return evalFind(node, ctx);
97
+ }
98
+
99
+ if (Array.isArray(node.allInFile)) {
100
+ return evalAllInFile(node, ctx);
101
+ }
102
+
103
+ throw new Error(`Assertion node has no allOf/anyOf/not/find/allInFile: ${JSON.stringify(node)}`);
104
+ }
105
+
106
+ function evalFind(node, ctx) {
107
+ const globs = ctx.adapter.resolveSurface(node.in ?? "any");
108
+ const expect = node.expect === "absent" ? "absent" : "present";
109
+ let regex;
110
+ try {
111
+ regex = new RegExp(node.find, node.flags || "i");
112
+ } catch (error) {
113
+ throw new Error(`Bad regex in rule (find: ${node.find}): ${error.message}`);
114
+ }
115
+
116
+ const matchedFiles = ctx.files.filter((f) => matchesAnyGlob(f.rel, globs));
117
+ const hits = [];
118
+ for (const f of matchedFiles) {
119
+ const text = ctx.readFile(f.abs);
120
+ const lines = text.split("\n");
121
+ for (let i = 0; i < lines.length; i++) {
122
+ if (regex.test(lines[i])) {
123
+ hits.push({ file: f.rel, line: i + 1, excerpt: lines[i].trim().slice(0, 160) });
124
+ if (hits.length >= MAX_HITS_PER_RULE) break;
125
+ }
126
+ }
127
+ if (hits.length >= MAX_HITS_PER_RULE) break;
128
+ }
129
+
130
+ const present = hits.length > 0;
131
+ const ok = expect === "absent" ? !present : present;
132
+ return { ok, hits, scanned: matchedFiles.length };
133
+ }
134
+
135
+ // Stronger probe: every pattern must co-occur in a SINGLE file — and, when
136
+ // `within` is set, within a window of that many lines of each other (i.e. the same
137
+ // settings block / object literal). The window is what makes this meaningfully
138
+ // more precise than "all appear somewhere in a big file".
139
+ function evalAllInFile(node, ctx) {
140
+ const globs = ctx.adapter.resolveSurface(node.in ?? "any");
141
+ const expect = node.expect === "absent" ? "absent" : "present";
142
+ const within = Number.isFinite(node.within) ? node.within : null;
143
+ let regexes;
144
+ try {
145
+ regexes = node.allInFile.map((p) => new RegExp(p, node.flags || "i"));
146
+ } catch (error) {
147
+ throw new Error(`Bad regex in rule (allInFile): ${error.message}`);
148
+ }
149
+
150
+ const matchedFiles = ctx.files.filter((f) => matchesAnyGlob(f.rel, globs));
151
+ let satisfied = false;
152
+ const hits = [];
153
+ for (const f of matchedFiles) {
154
+ const text = ctx.readFile(f.abs);
155
+ const lines = text.split("\n");
156
+ // For each pattern, the set of line numbers (0-based) it matches.
157
+ const perPattern = regexes.map((re) => {
158
+ const ls = [];
159
+ for (let i = 0; i < lines.length; i++) if (re.test(lines[i])) ls.push(i);
160
+ return ls;
161
+ });
162
+ if (perPattern.some((ls) => ls.length === 0)) continue; // a pattern is absent → file can't satisfy
163
+
164
+ const anchor = findWindow(perPattern, within);
165
+ if (anchor) {
166
+ satisfied = true;
167
+ for (let p = 0; p < regexes.length; p++) {
168
+ const ln = anchor[p];
169
+ hits.push({ file: f.rel, line: ln + 1, excerpt: lines[ln].trim().slice(0, 160) });
170
+ }
171
+ break;
172
+ }
173
+ }
174
+
175
+ const ok = expect === "absent" ? !satisfied : satisfied;
176
+ return { ok, hits: hits.slice(0, MAX_HITS_PER_RULE), scanned: matchedFiles.length };
177
+ }
178
+
179
+ // Find one matching line per pattern such that max-min <= within. With no window,
180
+ // any combination works (first match of each). Returns the chosen line per pattern.
181
+ function findWindow(perPattern, within) {
182
+ if (within == null) return perPattern.map((ls) => ls[0]);
183
+ // Anchor on each match of the first pattern; require every other pattern to have
184
+ // a match inside [anchor - within, anchor + within].
185
+ for (const anchor of perPattern[0]) {
186
+ const chosen = [anchor];
187
+ let ok = true;
188
+ for (let p = 1; p < perPattern.length; p++) {
189
+ const near = perPattern[p].find((ln) => Math.abs(ln - anchor) <= within);
190
+ if (near === undefined) {
191
+ ok = false;
192
+ break;
193
+ }
194
+ chosen.push(near);
195
+ }
196
+ if (ok) return chosen;
197
+ }
198
+ return null;
199
+ }
200
+
201
+ /** Evaluate a single rule into a finding. */
202
+ export function evalRule(rule, ctx, packMeta) {
203
+ const r = evalNode(rule.assert, ctx);
204
+ let status;
205
+ if (r.scanned === 0) status = "unknown";
206
+ else status = r.ok ? "pass" : "fail";
207
+
208
+ return {
209
+ packId: packMeta.id,
210
+ packTitle: packMeta.title,
211
+ authority: packMeta.authority,
212
+ ruleId: rule.id,
213
+ standard: rule.standard,
214
+ severity: rule.severity || "medium",
215
+ surface: surfaceLabel(rule.surface ?? rule.assert),
216
+ intent: rule.intent,
217
+ fix: rule.fix,
218
+ status,
219
+ scanned: r.scanned,
220
+ hits: r.hits.slice(0, MAX_HITS_PER_RULE),
221
+ };
222
+ }
223
+
224
+ function surfaceLabel(spec) {
225
+ if (typeof spec === "string") return spec;
226
+ return undefined;
227
+ }
228
+
229
+ export { buildContext };
@@ -0,0 +1,20 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { CONFIG_TEMPLATE } from "./config.js";
4
+
5
+ /** Write a starter bouncer.config.json into `dir` (default cwd). Never overwrites without --force. */
6
+ export function initProject(dir = ".", { force = false } = {}) {
7
+ const target = path.resolve(dir, "bouncer.config.json");
8
+ if (fs.existsSync(target) && !force) {
9
+ return { created: false, path: target, reason: "exists (use --force to overwrite)" };
10
+ }
11
+ fs.writeFileSync(target, JSON.stringify(CONFIG_TEMPLATE, null, 2) + "\n");
12
+ return { created: true, path: target };
13
+ }
14
+
15
+ export function formatInitSummary(result) {
16
+ if (result.created) {
17
+ return `\n ✓ wrote ${result.path}\n\n Next:\n bouncer check\n bouncer report --out bouncer-report.html\n`;
18
+ }
19
+ return `\n • ${result.path} already ${result.reason}\n`;
20
+ }
package/src/lib/mcp.js ADDED
@@ -0,0 +1,195 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import readline from "node:readline";
4
+ import { fileURLToPath } from "node:url";
5
+ import { loadConfig } from "./config.js";
6
+ import { runCheck, listRules, explainRule, availablePacks } from "./packs.js";
7
+
8
+ const protocolVersion = "2025-06-18";
9
+ const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
10
+ const packageJson = JSON.parse(fs.readFileSync(path.join(packageRoot, "package.json"), "utf8"));
11
+
12
+ const tools = [
13
+ {
14
+ name: "compliance_check",
15
+ title: "Compliance Controls Check",
16
+ description:
17
+ "Run the configured regulation rule packs against the target repo and return per-control verdicts (pass/fail/unknown) with file-level evidence. Deterministic; no LLM involved.",
18
+ inputSchema: {
19
+ type: "object",
20
+ properties: {
21
+ config: { type: "string", description: "Path to bouncer.config.json. Defaults to searching up from cwd." },
22
+ packs: { type: "array", items: { type: "string" }, description: "Restrict to these pack ids (e.g. uk-osa, uk-aadc)." },
23
+ status: { type: "string", description: "Filter findings: fail, unknown, or all (default)." },
24
+ },
25
+ },
26
+ },
27
+ {
28
+ name: "list_rules",
29
+ title: "List Compliance Rules",
30
+ description: "List every rule the configured packs apply, with standard, severity, and surface. No scanning.",
31
+ inputSchema: {
32
+ type: "object",
33
+ properties: {
34
+ config: { type: "string", description: "Path to bouncer.config.json." },
35
+ },
36
+ },
37
+ },
38
+ {
39
+ name: "explain_rule",
40
+ title: "Explain Compliance Rule",
41
+ description: "Explain a single rule: the legal standard, intent, fix, and exactly how bouncer checks it.",
42
+ inputSchema: {
43
+ type: "object",
44
+ properties: {
45
+ ruleId: { type: "string", description: "Rule id, e.g. aadc.geolocation-default-off." },
46
+ config: { type: "string", description: "Path to bouncer.config.json." },
47
+ },
48
+ required: ["ruleId"],
49
+ },
50
+ },
51
+ {
52
+ name: "list_packs",
53
+ title: "List Rule Packs",
54
+ description: "List the regulation rule packs bundled with bouncer (and any local pack dirs).",
55
+ inputSchema: {
56
+ type: "object",
57
+ properties: {
58
+ config: { type: "string", description: "Optional bouncer.config.json to include its packDirs." },
59
+ },
60
+ },
61
+ },
62
+ ];
63
+
64
+ export async function startMcpServer({ input = process.stdin, output = process.stdout } = {}) {
65
+ // A client that hangs up mid-write produces EPIPE; exit quietly rather than crash-logging.
66
+ output.on("error", (err) => {
67
+ if (err && err.code === "EPIPE") process.exit(0);
68
+ throw err;
69
+ });
70
+
71
+ const rl = readline.createInterface({ input, crlfDelay: Infinity });
72
+
73
+ for await (const line of rl) {
74
+ const trimmed = line.trim();
75
+ if (!trimmed) continue;
76
+
77
+ let message;
78
+ try {
79
+ message = JSON.parse(trimmed);
80
+ } catch (error) {
81
+ writeMessage(output, errorResponse(null, -32700, `Parse error: ${error.message}`));
82
+ continue;
83
+ }
84
+
85
+ const response = await handleMessage(message);
86
+ if (response) writeMessage(output, response);
87
+ }
88
+ }
89
+
90
+ async function handleMessage(message) {
91
+ if (!message || message.jsonrpc !== "2.0" || typeof message.method !== "string") {
92
+ return errorResponse(message?.id ?? null, -32600, "Invalid JSON-RPC request");
93
+ }
94
+
95
+ try {
96
+ switch (message.method) {
97
+ case "initialize":
98
+ return successResponse(message.id, {
99
+ protocolVersion,
100
+ capabilities: { tools: { listChanged: false } },
101
+ serverInfo: { name: packageJson.name, version: packageJson.version },
102
+ });
103
+ case "notifications/initialized":
104
+ return undefined;
105
+ case "ping":
106
+ return successResponse(message.id, {});
107
+ case "tools/list":
108
+ return successResponse(message.id, { tools });
109
+ case "tools/call":
110
+ return successResponse(message.id, await callTool(message.params));
111
+ default:
112
+ return errorResponse(message.id, -32601, `Method not found: ${message.method}`);
113
+ }
114
+ } catch (error) {
115
+ const code = error instanceof McpProtocolError ? error.code : -32603;
116
+ return errorResponse(message.id, code, error instanceof Error ? error.message : String(error));
117
+ }
118
+ }
119
+
120
+ async function callTool(params = {}) {
121
+ if (!params || typeof params !== "object") throw new McpProtocolError(-32602, "Tool call params must be an object");
122
+ const name = params.name;
123
+ if (typeof name !== "string" || !name.trim()) throw new McpProtocolError(-32602, "Tool name is required");
124
+ const args = params.arguments ?? {};
125
+ if (!args || typeof args !== "object" || Array.isArray(args)) {
126
+ throw new McpProtocolError(-32602, "Tool arguments must be an object");
127
+ }
128
+
129
+ let result;
130
+ try {
131
+ result = dispatchTool(name, args);
132
+ } catch (error) {
133
+ if (error instanceof McpProtocolError) throw error;
134
+ return { content: [{ type: "text", text: error instanceof Error ? error.message : String(error) }], isError: true };
135
+ }
136
+
137
+ return {
138
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
139
+ structuredContent: result,
140
+ isError: false,
141
+ };
142
+ }
143
+
144
+ function dispatchTool(name, args) {
145
+ switch (name) {
146
+ case "compliance_check": {
147
+ const cfg = withPacks(loadConfig(args.config), args.packs);
148
+ const result = runCheck(cfg);
149
+ const filter = args.status || "all";
150
+ if (filter !== "all") {
151
+ result.findings = result.findings.filter((f) => (filter === "fail" ? f.status === "fail" : f.status !== "pass"));
152
+ }
153
+ return result;
154
+ }
155
+ case "list_rules":
156
+ return { rules: listRules(loadConfig(args.config)) };
157
+ case "explain_rule":
158
+ return explainRule(loadConfig(args.config), requiredString(args.ruleId, "ruleId"));
159
+ case "list_packs": {
160
+ const dirs = args.config ? loadConfig(args.config).packDirs : [];
161
+ return { packs: availablePacks(dirs) };
162
+ }
163
+ default:
164
+ throw new McpProtocolError(-32601, `Unknown tool: ${name}`);
165
+ }
166
+ }
167
+
168
+ function withPacks(cfg, packs) {
169
+ if (Array.isArray(packs) && packs.length) cfg.packs = packs;
170
+ return cfg;
171
+ }
172
+
173
+ function requiredString(value, label) {
174
+ if (typeof value !== "string" || !value.trim()) throw new McpProtocolError(-32602, `${label} is required`);
175
+ return value;
176
+ }
177
+
178
+ function successResponse(id, result) {
179
+ return { jsonrpc: "2.0", id, result };
180
+ }
181
+
182
+ function errorResponse(id, code, message) {
183
+ return { jsonrpc: "2.0", id, error: { code, message } };
184
+ }
185
+
186
+ function writeMessage(output, message) {
187
+ output.write(JSON.stringify(message) + "\n");
188
+ }
189
+
190
+ class McpProtocolError extends Error {
191
+ constructor(code, message) {
192
+ super(message);
193
+ this.code = code;
194
+ }
195
+ }
@@ -0,0 +1,49 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ export function printText(text) {
5
+ process.stdout.write(`${text}\n`);
6
+ }
7
+
8
+ export function printJson(value) {
9
+ process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
10
+ }
11
+
12
+ export function writeArtifact(targetPath, contents) {
13
+ const absolutePath = path.resolve(targetPath);
14
+ fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
15
+ fs.writeFileSync(absolutePath, contents);
16
+ return { path: absolutePath };
17
+ }
18
+
19
+ export function printHelp() {
20
+ printText(`bouncer — static compliance-controls checker
21
+
22
+ Verifies the controls a regulation requires actually exist in your code,
23
+ expressed as deterministic rule packs. Runs in CI. No LLM required.
24
+
25
+ Usage:
26
+ bouncer check [--config file] [--pack id...] [--status fail|unknown|all] [--json] [--no-fail]
27
+ bouncer report [--config file] [--out file] Write a self-contained HTML audit report
28
+ bouncer list [--config file] [--json] List every rule the configured packs apply
29
+ bouncer explain <ruleId> [--config file] Show what a rule requires and how it is checked
30
+ bouncer packs [--json] List the rule packs shipped with bouncer
31
+ bouncer init [path] Write a starter bouncer.config.json
32
+ bouncer doctor [--config file] [--json] Sanity-check config, adapter, and pack loading
33
+ bouncer mcp Start the MCP server (stdio)
34
+ bouncer help
35
+
36
+ Examples:
37
+ bouncer init .
38
+ bouncer check
39
+ bouncer check --pack uk-aadc --status fail
40
+ bouncer report --out bouncer-report.html
41
+ bouncer explain aadc.geolocation-default-off
42
+ bouncer packs
43
+
44
+ Verdicts:
45
+ pass the required control was found
46
+ fail the control is required for a surface that exists, but no evidence was found
47
+ unknown the surface could not be located in this repo (can't determine — not a pass)
48
+ `);
49
+ }