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 +8 -0
- package/bin/crumple.mjs +56 -9
- package/package.json +1 -1
- package/src/bug.mjs +60 -0
- package/src/ui.mjs +70 -0
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] === "
|
|
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
|
-
|
|
192
|
-
const b =
|
|
193
|
-
|
|
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
|
-
|
|
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
|
-
|
|
240
|
-
if (r.error)
|
|
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))
|
|
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
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
|
+
}
|