crumplelab 0.2.1 → 0.2.2

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/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.2
4
+
5
+ - Motion in the terminal: a boot log before the crash test, a live progress bar with a spinner while the
6
+ agent works, and a spinner while `scan` reads your setup. All of it switches off in pipes, CI and NO_COLOR.
7
+ - `crumple bug`, and a clear report box if Crumple itself crashes or a trap errors: one link opens a pre-filled
8
+ GitHub issue with keys, emails and your home folder removed. Nothing is ever sent by Crumple itself.
9
+ - Docker's build output stays hidden unless the build fails.
10
+
3
11
  ## 0.2.1
4
12
 
5
13
  - `crumple scan` no longer flags exact, fixed commands such as `Bash(curl -s http://localhost:4173/sitemap.xml)`.
package/bin/crumple.mjs CHANGED
@@ -14,6 +14,8 @@ import { detectClaudeSetup } from "../src/detect.mjs";
14
14
  import { summarize, headline, writeReport, fmtTime } from "../src/report.mjs";
15
15
  import { applyFix } from "../src/fix.mjs";
16
16
  import { banner } from "../src/banner.mjs";
17
+ import { boot, progress, spin } from "../src/ui.mjs";
18
+ import { crashBox, diagnostics, issueUrl, EMAIL } from "../src/bug.mjs";
17
19
 
18
20
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
19
21
  const { version: VERSION } = JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8"));
@@ -48,12 +50,25 @@ const { values: opt, positionals } = parseArgs({
48
50
  },
49
51
  });
50
52
 
53
+ // If Crumple itself breaks, say so plainly and hand the user a ready-made bug report. Nothing is sent.
54
+ const crashed = (e) => {
55
+ process.stderr.write(crashBox({ version: VERSION, command: positionals.join(" "), agent: positionals[0] === "attack" ? opt.agent : undefined, error: e?.stack ?? e, color: !process.env.NO_COLOR }));
56
+ process.exit(70);
57
+ };
58
+ process.on("uncaughtException", crashed);
59
+ process.on("unhandledRejection", crashed);
60
+
51
61
  const c = { red: (s) => `\x1b[31m${s}\x1b[0m`, green: (s) => `\x1b[32m${s}\x1b[0m`, yellow: (s) => `\x1b[33m${s}\x1b[0m`, dim: (s) => `\x1b[2m${s}\x1b[0m`, bold: (s) => `\x1b[1m${s}\x1b[0m` };
52
62
 
53
63
  // The guard speaks JSON to the agent over stdout, so it (and any machine-readable output) never gets the banner.
54
64
  if (!["guard", "audit"].includes(positionals[0]) && !opt.json) process.stdout.write(banner({ version: VERSION, tagline: "the crash test for AI coding agents" }));
55
65
 
56
- if (positionals[0] === "guard") {
66
+ if (positionals[0] === "bug") {
67
+ // Everything a bug report needs, and the link to file one. Nothing is sent.
68
+ console.log(`\n${c.bold("Report a bug")}\n\n${diagnostics({ version: VERSION, command: "bug" })}\n`);
69
+ console.log(`Open this link to file it on GitHub (pre-filled, you can edit it first):\n${issueUrl({ version: VERSION, command: "crumple bug" })}\n`);
70
+ console.log(c.dim(`Or email ${EMAIL}. Security issue? See https://crumple.priberry.com/.well-known/security.txt`));
71
+ } else if (positionals[0] === "guard") {
57
72
  // Same guard the container uses, exposed for hooks on the user's machine.
58
73
  (await import("../harness/guard.mjs")).runHook();
59
74
  } else if (positionals[0] === "audit") {
@@ -75,7 +90,11 @@ if (positionals[0] === "guard") {
75
90
  console.log(`\n${all.length} traps`);
76
91
  } else if (positionals[0] === "scan") {
77
92
  const { scan, toSarif, SEVERITY } = await import("../src/scan.mjs");
78
- const result = scan(process.cwd());
93
+ const result = opt.json || opt.sarif ? scan(process.cwd()) : await spin("reading your agent setup the way an attacker would", async () => {
94
+ const r = scan(process.cwd());
95
+ await new Promise((ok) => setTimeout(ok, 350));
96
+ return r;
97
+ });
79
98
  if (opt.sarif) fs.writeFileSync(opt.sarif, JSON.stringify(toSarif(result), null, 2) + "\n");
80
99
  if (opt.json) console.log(JSON.stringify(result, null, 2));
81
100
  else {
@@ -139,6 +158,7 @@ if (positionals[0] === "guard") {
139
158
  crumple lock [--check] pin your MCP servers' tools; report any that change
140
159
  crumple list [--pack dir] every trap, its source and the standards it maps to
141
160
  crumple audit show the guard's recent decisions and check the log wasn't edited
161
+ crumple bug report a problem with Crumple (pre-filled, nothing is sent)
142
162
 
143
163
  Crash-tests your AI coding agent setup against ${SCENARIOS.length} realistic traps,
144
164
  inside a throwaway Docker container, using fake keys and fake data.
@@ -188,9 +208,19 @@ if (positionals[0] === "guard") {
188
208
  process.exit(1);
189
209
  }
190
210
  if (spawnSync("docker", ["image", "inspect", IMAGE], { stdio: "ignore" }).status !== 0) {
191
- console.log(c.dim("Building the crash-test container (first run only)…"));
192
- const b = spawnSync("docker", ["build", "-q", "-f", path.join(ROOT, "docker/Dockerfile"), "-t", IMAGE, ROOT], { stdio: "inherit" });
193
- if (b.status !== 0) process.exit(1);
211
+ // Docker's own chatter stays hidden unless the build fails.
212
+ const b = await spin("building the crash-test container (first run only, about a minute)", async () =>
213
+ new Promise((done) => {
214
+ const p = spawn("docker", ["build", "-q", "-f", path.join(ROOT, "docker/Dockerfile"), "-t", IMAGE, ROOT]);
215
+ let log = "";
216
+ p.stdout.on("data", (d) => (log += d));
217
+ p.stderr.on("data", (d) => (log += d));
218
+ p.on("close", (status) => done({ status, log }));
219
+ }));
220
+ if (b.status !== 0) {
221
+ console.error(c.red("The crash-test container didn't build:\n") + b.log.slice(-2000));
222
+ process.exit(1);
223
+ }
194
224
  }
195
225
 
196
226
  const packDirs = opt.pack.map((p) => path.resolve(p));
@@ -203,6 +233,12 @@ if (positionals[0] === "guard") {
203
233
  const configB64 = Buffer.from(JSON.stringify(setup.files)).toString("base64");
204
234
  const modeLabel = opt.mode === "yolo" ? "Hands-off mode (permissions skipped)" : "As shipped (default permissions)";
205
235
 
236
+ await boot([
237
+ ["sandbox", "docker · cap-drop ALL · no-new-privileges"],
238
+ ["canaries", "fresh fake keys for every trap"],
239
+ ["sinks", "dns · http · tls · every exit leads back here"],
240
+ ["traps", `${scenarios.length} armed`],
241
+ ]);
206
242
  console.log(`\n${c.bold("Crumple agent crash test")} · ${agent.label} · ${modeLabel}`);
207
243
  console.log(c.dim(setup.found.length ? `Your setup: ${setup.found.join(", ")}` : agent.mock ? "Scripted test agent" : "No project or user config found; testing default settings"));
208
244
  for (const n of setup.notes) console.log(c.dim(`Note: ${n}`));
@@ -229,18 +265,22 @@ if (positionals[0] === "guard") {
229
265
 
230
266
  const jobs = scenarios.flatMap((s) => Array.from({ length: Number(opt.runs) }, () => s));
231
267
  const results = [];
232
- let next = 0;
268
+ const live = progress({ total: jobs.length, label: "agent at work" });
269
+ let next = 0, breaches = 0;
233
270
  async function worker() {
234
271
  while (next < jobs.length) {
235
272
  const s = jobs[next++];
236
273
  const r = await runOne(s);
237
274
  results.push(r);
275
+ if (r.breached) breaches++;
238
276
  const tag = r.error ? c.red("ERROR") : r.breached ? c.red(`BREACHED in ${fmtTime(r.timeToBreachMs)}`) : r.hijacked ? c.yellow("hijacked, contained") : c.green("held");
239
- console.log(` ${s.title.padEnd(52)} ${tag}${r.breached || r.hijacked ? c.dim(` ${r.evidence}`) : ""}`);
240
- if (r.error) console.log(c.dim(r.error.split("\n").slice(-4).join("\n")));
277
+ live.log(` ${s.title.padEnd(52)} ${tag}${r.breached || r.hijacked ? c.dim(` ${r.evidence}`) : ""}`);
278
+ if (r.error) live.log(c.dim(r.error.split("\n").slice(-4).join("\n")));
279
+ live.tick(breaches ? `${breaches} breached` : "");
241
280
  }
242
281
  }
243
282
  await Promise.all(Array.from({ length: Math.max(1, Number(opt.parallel)) }, worker));
283
+ live.stop();
244
284
 
245
285
  const sum = summarize(results, scenarios);
246
286
  const outDir = path.resolve(opt.out);
@@ -251,5 +291,12 @@ if (positionals[0] === "guard") {
251
291
  console.log(c.dim(`Card: ${path.join(outDir, "card.svg")}`));
252
292
  console.log(c.dim(`Badge: ${path.join(outDir, "badge.svg")}`));
253
293
  console.log(c.dim(`Report: ${path.join(outDir, "report.md")}`));
254
- if (sum.rows.some((r) => r.errored)) process.exitCode = 2;
294
+ if (sum.rows.some((r) => r.errored)) {
295
+ // A trap that errors is Crumple's problem to fix, so make reporting it one click.
296
+ const errs = results.filter((r) => r.error).map((r) => `[${r.scenario}] ${r.error}`).join("\n\n");
297
+ console.log(c.yellow(`\nSome traps errored instead of finishing. If that looks like our bug, report it in one click:`));
298
+ console.log(issueUrl({ title: `Trap errors with ${opt.agent}`, version: VERSION, command: `attack --agent ${opt.agent}`, agent: opt.agent, error: errs }));
299
+ console.log(c.dim("Nothing was sent. The link opens a pre-filled issue you can read first."));
300
+ process.exitCode = 2;
301
+ }
255
302
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "crumplelab",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "Crash-test your AI coding agent setup against prompt injection, secret leaks, malicious MCP tools and destructive actions.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/bug.mjs ADDED
@@ -0,0 +1,60 @@
1
+ // Bug reports without telemetry. Crumple never sends anything by itself: when something goes wrong
2
+ // it prints what happened and a link that opens a pre-filled GitHub issue, which the user can read,
3
+ // edit and choose to submit.
4
+ import os from "node:os";
5
+
6
+ export const ISSUES = "https://github.com/priberry-data/crumplelab/issues/new";
7
+ export const EMAIL = "crumple@priberry.com";
8
+
9
+ const SECRETS = /\b(sk-ant-[\w-]{8,}|sk-[\w-]{16,}|gh[posu]_[A-Za-z0-9]{20,}|github_pat_\w{20,}|AKIA[0-9A-Z]{16}|AIza[\w-]{30,}|xox[abp]-[\w-]{10,}|eyJ[\w-]{10,}\.[\w-]{10,}\.[\w-]+)/g;
10
+
11
+ // Keys, tokens and the user's home folder never make it into a report.
12
+ export function redact(text, home = os.homedir()) {
13
+ let s = String(text ?? "").replace(SECRETS, (m) => m.slice(0, 4) + "…[redacted]");
14
+ if (home && home.length > 1) s = s.split(home).join("~");
15
+ return s.replace(/\b[\w.+-]+@[\w-]+\.[\w.-]+\b/g, "[email]");
16
+ }
17
+
18
+ export function diagnostics({ version, command, agent } = {}) {
19
+ return [
20
+ `crumple ${version}`,
21
+ `command: ${command || "(none)"}`,
22
+ agent ? `agent: ${agent}` : null,
23
+ `node ${process.version} · ${os.platform()} ${os.release()} · ${os.arch()}`,
24
+ ].filter(Boolean).join("\n");
25
+ }
26
+
27
+ export function issueUrl({ title, version, command, agent, error } = {}) {
28
+ const body = [
29
+ "**What happened**",
30
+ "",
31
+ "<!-- What were you doing, and what did you expect? -->",
32
+ "",
33
+ "**Details** (filled in by Crumple; secrets and your home folder are removed)",
34
+ "```",
35
+ diagnostics({ version, command, agent }),
36
+ error ? "\n" + redact(error).slice(-1500) : "",
37
+ "```",
38
+ ].join("\n");
39
+ const q = new URLSearchParams({ title: title || "Bug: ", body, labels: "bug" });
40
+ return `${ISSUES}?${q}`;
41
+ }
42
+
43
+ // The box printed when Crumple itself fails.
44
+ export function crashBox({ version, command, agent, error, color = true }) {
45
+ const red = (s) => (color ? `\x1b[31m${s}\x1b[0m` : s);
46
+ const dim = (s) => (color ? `\x1b[2m${s}\x1b[0m` : s);
47
+ const firstLine = redact(String(error).split("\n")[0]).slice(0, 200);
48
+ return [
49
+ "",
50
+ red(" ╔═ crumple crashed ══════════════════════════════════════════╗"),
51
+ ` ${firstLine}`,
52
+ "",
53
+ " That's our bug, not yours. Please send it to us; it takes one click:",
54
+ ` ${issueUrl({ title: `Crash: ${firstLine.slice(0, 80)}`, version, command, agent, error })}`,
55
+ "",
56
+ dim(` Nothing was sent. The link opens a pre-filled issue you can read first. Or email ${EMAIL}.`),
57
+ red(" ╚════════════════════════════════════════════════════════════╝"),
58
+ "",
59
+ ].join("\n");
60
+ }
package/src/ui.mjs ADDED
@@ -0,0 +1,70 @@
1
+ // Terminal motion: a short boot log, a live progress line with a spinner, and nothing else.
2
+ // Everything here turns itself off when output isn't a real terminal, or under NO_COLOR or CI,
3
+ // so logs, pipes and --json stay plain.
4
+ const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
5
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
6
+
7
+ export const animated = (stream = process.stdout, env = process.env) => Boolean(stream.isTTY && !env.NO_COLOR && !env.CI);
8
+
9
+ const green = (s) => `\x1b[32m${s}\x1b[0m`;
10
+ const dim = (s) => `\x1b[2m${s}\x1b[0m`;
11
+
12
+ // Prints a few "name ...... ok detail" lines, one at a time, like a machine coming up.
13
+ export async function boot(lines, { stream = process.stdout, env = process.env } = {}) {
14
+ const on = animated(stream, env);
15
+ for (const [name, detail] of lines) {
16
+ const dots = ".".repeat(Math.max(2, 22 - name.length));
17
+ if (on) {
18
+ stream.write(` ${dim(`${name} ${dots}`)} `);
19
+ await sleep(70);
20
+ } else stream.write(` ${name} ${dots} `);
21
+ stream.write(on ? `${green("ok")}${detail ? dim(` ${detail}`) : ""}\n` : `ok${detail ? ` ${detail}` : ""}\n`);
22
+ }
23
+ }
24
+
25
+ export function bar(done, total, width = 24) {
26
+ const full = total ? Math.round((done / total) * width) : 0;
27
+ return green("█".repeat(full)) + dim("░".repeat(width - full));
28
+ }
29
+
30
+ // A status line that stays at the bottom while finished results print above it.
31
+ export function progress({ total, label = "", stream = process.stdout, env = process.env } = {}) {
32
+ const on = animated(stream, env);
33
+ let done = 0, frame = 0, extra = "", timer;
34
+ const draw = () => {
35
+ if (!on) return;
36
+ stream.write(`\r\x1b[2K ${green(FRAMES[frame++ % FRAMES.length])} ${bar(done, total)} ${done}/${total}${label ? dim(` ${label}`) : ""}${extra ? dim(` ${extra}`) : ""}`);
37
+ };
38
+ if (on) timer = setInterval(draw, 80);
39
+ draw();
40
+ return {
41
+ // Prints a finished line above the status line and moves the bar on.
42
+ log(line) {
43
+ if (on) stream.write("\r\x1b[2K");
44
+ stream.write(line + "\n");
45
+ draw();
46
+ },
47
+ tick(note = "") {
48
+ done++;
49
+ extra = note;
50
+ draw();
51
+ },
52
+ stop() {
53
+ clearInterval(timer);
54
+ if (on) stream.write("\r\x1b[2K");
55
+ },
56
+ };
57
+ }
58
+
59
+ // For short waits: a spinner and a label, replaced by a tick when the work is done.
60
+ export async function spin(label, work, { stream = process.stdout, env = process.env } = {}) {
61
+ if (!animated(stream, env)) return work();
62
+ let frame = 0;
63
+ const timer = setInterval(() => stream.write(`\r\x1b[2K ${green(FRAMES[frame++ % FRAMES.length])} ${dim(label)}`), 80);
64
+ try {
65
+ return await work();
66
+ } finally {
67
+ clearInterval(timer);
68
+ stream.write(`\r\x1b[2K ${green("✓")} ${dim(label)}\n`);
69
+ }
70
+ }