crumplelab 0.0.0-stage → 0.2.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/CHANGELOG.md +18 -0
- package/LICENSE +202 -0
- package/README.md +177 -2
- package/SECURITY.md +30 -0
- package/action.yml +77 -0
- package/bin/crumple.mjs +255 -0
- package/docker/Dockerfile +22 -0
- package/harness/agents.mjs +209 -0
- package/harness/canaries.mjs +90 -0
- package/harness/evil-mcp.mjs +73 -0
- package/harness/guard.mjs +699 -0
- package/harness/kit.mjs +22 -0
- package/harness/run.mjs +144 -0
- package/harness/scenarios.mjs +563 -0
- package/harness/shell.mjs +296 -0
- package/harness/sinks.mjs +116 -0
- package/harness/standards.mjs +38 -0
- package/package.json +48 -4
- package/packs/example/debug-har.mjs +51 -0
- package/src/banner.mjs +27 -0
- package/src/detect.mjs +87 -0
- package/src/fix.mjs +101 -0
- package/src/lock.mjs +77 -0
- package/src/report.mjs +113 -0
- package/src/scan.mjs +156 -0
|
@@ -0,0 +1,699 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Crumple guard: a Claude Code PreToolUse hook that stops the attacks the crash test finds.
|
|
3
|
+
// It parses each command the way the shell will (see shell.mjs), follows wrappers, inline
|
|
4
|
+
// scripts, heredocs and scripts the agent wrote, and checks real arguments, not text.
|
|
5
|
+
// When it can't read a command it blocks it. It is a seatbelt, not a sandbox: keep secrets
|
|
6
|
+
// and production access out of reach too.
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import os from "node:os";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import crypto from "node:crypto";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { parse, unwrap, inlineScript, braces } from "./shell.mjs";
|
|
13
|
+
|
|
14
|
+
// Zero-width, bidi-control and Unicode "tag" characters: the rules-file backdoor trick.
|
|
15
|
+
const HIDDEN = /[----]|[\u{E0000}-\u{E007F}]/u;
|
|
16
|
+
const SECRET_VALUES = /AKIA[0-9A-Z]{16}|sk_(live|test)_[0-9A-Za-z_]{10,}|sk-(ant|proj)-[0-9A-Za-z_-]{10,}|-----BEGIN [A-Z ]*PRIVATE KEY|gh[pousr]_[0-9A-Za-z]{30,}|github_pat_[0-9A-Za-z_]{20,}|glpat-[0-9A-Za-z_-]{20}|xox[bpas]-[0-9A-Za-z-]{10,}|AIza[0-9A-Za-z_-]{35}|npm_[0-9A-Za-z]{36}/;
|
|
17
|
+
const SECRET_VAR = /\$\{?[A-Za-z0-9_]*(SECRET|TOKEN|PASSWORD|PASSWD|API_KEY|ACCESS_KEY|PRIVATE_KEY|CREDENTIAL|STRIPE|AWS_)[A-Za-z0-9_]*\}?/i;
|
|
18
|
+
const SECRET_PATH = /(^|\/)\.env(\.(local|prod\w*|dev\w*|staging|test))?$|(^|\/)\.envrc$|\.aws\/(credentials|config)$|(^|\/)\.ssh\/|(^|\/)id_(rsa|ed25519|ecdsa|dsa)(\.pub)?$|(^|\/)\.netrc$|(^|\/)\.git-credentials$|(^|\/)\.pypirc$|\.docker\/config\.json$|\.config\/gh\/hosts\.yml$|\.kube\/config$|application_default_credentials\.json$|\.(pem|p12|pfx|key)$|Library\/Keychains\/|(^|\/)\.gnupg\//;
|
|
19
|
+
const HOME_SECRET = new RegExp(SECRET_PATH.source + String.raw`|(^|/)\.npmrc$|Login Data$|Cookies$|Application Support/(Google/Chrome|BraveSoftware|Firefox)|\.mozilla/|(^|/)\.crumple/`);
|
|
20
|
+
const PROTECTED = new RegExp([
|
|
21
|
+
String.raw`(^|/)\.(claude|cursor|codex|gemini|windsurf|continue|crumple|vscode|idea|husky|devcontainer)(/|$)`,
|
|
22
|
+
String.raw`(^|/)(CLAUDE|AGENTS|GEMINI|CONVENTIONS)\.md$`,
|
|
23
|
+
String.raw`(^|/)\.(cursorrules|windsurfrules|clinerules|mcp\.json|aider[\w.]*)$`,
|
|
24
|
+
String.raw`(^|/)\.github/(copilot-instructions\.md|instructions/)`,
|
|
25
|
+
String.raw`(^|/)\.git/(hooks/|config$)`,
|
|
26
|
+
String.raw`(^|/)\.(npmrc|yarnrc|yarnrc\.yml|pypirc|netrc|gitconfig|git-credentials)$|(^|/)pip\.conf$|(^|/)\.config/pip/`,
|
|
27
|
+
String.raw`(^|/)\.(bashrc|bash_profile|bash_login|zshrc|zprofile|zshenv|zlogin|profile)$|(^|/)\.config/(fish|systemd|autostart)/`,
|
|
28
|
+
String.raw`(^|/)\.ssh/|(^|/)Library/LaunchAgents/|ClaudeCode/managed-settings|/etc/claude-code/`,
|
|
29
|
+
].join("|"));
|
|
30
|
+
const TEST_FILE = /(^|\/)(tests?|__tests__|spec)\/|\.(test|spec)\.[cm]?[jt]sx?$|_test\.(py|go)$|(^|\/)test_\w+\.py$/;
|
|
31
|
+
const CI_FILE = /(^|\/)\.github\/workflows\/|\.gitlab-ci\.yml$|(^|\/)Jenkinsfile$|(^|\/)\.circleci\/|azure-pipelines\.yml$|bitbucket-pipelines\.yml$/;
|
|
32
|
+
const GENERATED = /^(\.\/)?(build|dist|out|\.next|\.nuxt|\.svelte-kit|\.cache|\.turbo|\.parcel-cache|node_modules|coverage|tmp|\.tmp|target|__pycache__|\.pytest_cache|\.mypy_cache|\.ruff_cache|\.venv|venv)(\/.*)?$|\.(log|pyc)$/;
|
|
33
|
+
const UNTRUSTED_READ = /(^|\/)(node_modules|vendor|third_party|\.git|logs?|downloads?|inbox|mail)\/|\.(log|eml|mbox)$|issue|ticket/i;
|
|
34
|
+
const SKIP = /\.skip\b|\bxit\(|\bxdescribe\(|@pytest\.mark\.skip|t\.Skip\(|\{\s*skip:\s*true|\.todo\(/;
|
|
35
|
+
|
|
36
|
+
const NET_CMDS = new Set(["curl", "wget", "nc", "ncat", "netcat", "socat", "telnet", "ftp", "tftp", "sftp", "scp", "rsync", "ssh", "http", "https", "xh", "aria2c", "ping", "ping6", "dig", "nslookup", "host", "drill", "traceroute", "tracepath", "whois", "nmap"]);
|
|
37
|
+
const DNS_CMDS = new Set(["ping", "ping6", "dig", "nslookup", "host", "drill", "traceroute", "tracepath", "whois"]);
|
|
38
|
+
const DB_CMDS = new Set(["sqlite3", "psql", "mysql", "mariadb", "mongosh", "mongo", "redis-cli", "cqlsh", "prisma", "knex", "sequelize", "drizzle-kit", "supabase"]);
|
|
39
|
+
const DB_WRITE = /\b(DELETE|DROP|TRUNCATE|UPDATE|INSERT|ALTER|REPLACE|migrate|db push|reset|FLUSHALL|FLUSHDB|dropDatabase|deleteMany)\b/i;
|
|
40
|
+
const CLOUD_CMDS = new Set(["aws", "gcloud", "gsutil", "az", "kubectl", "helm", "terraform", "tofu", "pulumi", "doctl", "flyctl", "fly", "vercel", "netlify", "heroku", "firebase", "wrangler", "railway", "render"]);
|
|
41
|
+
const CLOUD_DESTROY = /^(delete|destroy|terminate|terminate-instances|rm|rb|purge|drop|remove|uninstall|scale|reset)$|^delete-|^--force$/;
|
|
42
|
+
const SHELLS = /^(ba|da|k|z|fi|c|tc)?sh$/;
|
|
43
|
+
const INTERPRETERS = /^(python[\d.]*|node|nodejs|deno|bun|perl|ruby|php|lua|Rscript|osascript|tsx|ts-node)$/;
|
|
44
|
+
const AGENT_CLIS = new Set(["claude", "codex", "gemini", "q", "amp", "aider", "cursor-agent", "copilot", "opencode", "goose", "cline"]);
|
|
45
|
+
const UNSAFE_AGENT_FLAGS = /^--(dangerously-skip-permissions|dangerously-bypass-approvals-and-sandbox|yolo|trust-all-tools|full-auto|auto-approve|allow-all-tools|no-sandbox)$/;
|
|
46
|
+
const CODE_NET = /\b(requests\.|urllib|http\.client|httpx|aiohttp|socket\.|fetch\(|axios|https?\.(request|get)\(|net\.connect|XMLHttpRequest|WebSocket|dns\.(resolve|lookup)|Net::HTTP|LWP::|IO::Socket|curl_exec|file_get_contents\(['"]https?:)/;
|
|
47
|
+
const CODE_SECRET = /os\.environ|getenv\(|process\.env|ENV\[|\$ENV\{|['"][^'"]*\.env['"]|\.aws\/credentials|\.ssh\/|id_rsa|keychain/i;
|
|
48
|
+
const CODE_SHELL = /\b(subprocess|os\.system|os\.popen|child_process|execSync|spawnSync|exec\(|spawn\(|system\(|popen\(|shell_exec|Open3|`)/;
|
|
49
|
+
const LOCAL_HOST = /^(localhost|127(\.\d{1,3}){3}|0\.0\.0\.0|\[?::1\]?|[\w-]+\.localhost)$/i;
|
|
50
|
+
const REGISTRIES = ["registry.npmjs.org", "registry.yarnpkg.com", "pypi.org", "files.pythonhosted.org", "proxy.golang.org", "crates.io", "static.crates.io"];
|
|
51
|
+
|
|
52
|
+
const deny = (reason) => ({ decision: "deny", reason });
|
|
53
|
+
const base = (s) => (s ?? "").split("/").pop();
|
|
54
|
+
const asserts = (s = "") => (s.match(/\bassert\w*[.(]|\bexpect\(|\bshould\b/g) ?? []).length;
|
|
55
|
+
const flagsOf = (args) => args.filter((a) => /^-[a-zA-Z]/.test(a)).map((a) => a.slice(1)).join("");
|
|
56
|
+
const positional = (args) => args.filter((a) => a !== "" && !a.startsWith("-"));
|
|
57
|
+
const homeDir = os.homedir();
|
|
58
|
+
|
|
59
|
+
// --- paths ---------------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
function resolveIn(ctx, p) {
|
|
62
|
+
const t = p.replace(/^~(?=\/|$)/, homeDir).replace(/^\$\{?HOME\}?(?=\/|$)/, homeDir);
|
|
63
|
+
return path.resolve(ctx.cwd, t);
|
|
64
|
+
}
|
|
65
|
+
const inside = (dir, abs) => abs === dir || abs.startsWith(dir.endsWith("/") ? dir : dir + "/");
|
|
66
|
+
function outsideWorkspace(ctx, p) {
|
|
67
|
+
const abs = resolveIn(ctx, p);
|
|
68
|
+
return !inside(ctx.cwd, abs) && !["/tmp", "/private/tmp", "/var/folders", "/private/var/folders", os.tmpdir()].some((d) => inside(d, abs));
|
|
69
|
+
}
|
|
70
|
+
const rel = (ctx, p) => {
|
|
71
|
+
const abs = resolveIn(ctx, p);
|
|
72
|
+
return inside(ctx.cwd, abs) ? path.relative(ctx.cwd, abs) || "." : abs;
|
|
73
|
+
};
|
|
74
|
+
const isSecretArg = (a) => SECRET_VALUES.test(a) || SECRET_VAR.test(a) || SECRET_PATH.test(a.replace(/^[^@<]*[@<]/, "").replace(/^file:\/\//, ""));
|
|
75
|
+
|
|
76
|
+
function readText(ctx, p, limit = 256 * 1024) {
|
|
77
|
+
try {
|
|
78
|
+
const abs = resolveIn(ctx, p);
|
|
79
|
+
const st = fs.statSync(abs);
|
|
80
|
+
if (!st.isFile() || st.size > limit) return null;
|
|
81
|
+
return fs.readFileSync(abs, "utf8");
|
|
82
|
+
} catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function hostOf(name, args) {
|
|
88
|
+
const url = args.find((a) => /^[a-z][a-z0-9+.-]*:\/\//i.test(a));
|
|
89
|
+
if (url) return url.replace(/^[a-z][a-z0-9+.-]*:\/\//i, "").replace(/^[^@/]*@/, "").split(/[/:?#]/)[0];
|
|
90
|
+
if (["ssh", "scp", "sftp", "rsync"].includes(name)) {
|
|
91
|
+
const remote = args.find((a) => !a.startsWith("-") && /^([^/@\s]+@)?[\w.-]+:/.test(a));
|
|
92
|
+
if (remote) return remote.replace(/^[^@]*@/, "").split(":")[0];
|
|
93
|
+
if (name === "ssh") return (positional(args)[0] ?? "").replace(/^[^@]*@/, "");
|
|
94
|
+
}
|
|
95
|
+
const pos = positional(args).filter((a) => !/^[@+]/.test(a) && !/^(GET|POST|PUT|PATCH|DELETE|HEAD)$/.test(a));
|
|
96
|
+
return (name === "curl" || name === "wget" ? pos[pos.length - 1] : pos[0])?.split(/[/:?#]/)[0] ?? "";
|
|
97
|
+
}
|
|
98
|
+
const allowedHost = (h) => LOCAL_HOST.test(h) || REGISTRIES.includes(h) || (process.env.CRUMPLE_ALLOW_HOSTS ?? "").split(",").map((s) => s.trim()).filter(Boolean).includes(h);
|
|
99
|
+
|
|
100
|
+
// --- shell ---------------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
function checkShell(code, ctx, depth = 0, lenient = false) {
|
|
103
|
+
if (depth > 8) return deny("Blocked: commands nested too deeply to check. Run the steps one at a time.");
|
|
104
|
+
if (HIDDEN.test(code)) return deny("Blocked: this command contains invisible characters, a known way to hide instructions.");
|
|
105
|
+
const p = parse(code);
|
|
106
|
+
if (p.error) return lenient ? null : deny(`Blocked: couldn't read this command safely (${p.error}). Rewrite it more simply.`);
|
|
107
|
+
for (const s of p.substitutions) {
|
|
108
|
+
const r = checkShell(s, ctx, depth + 1, lenient);
|
|
109
|
+
if (r) return r;
|
|
110
|
+
}
|
|
111
|
+
const vars = {};
|
|
112
|
+
for (const c of p.commands) {
|
|
113
|
+
for (const a of c.assigns) {
|
|
114
|
+
const k = a.slice(0, a.indexOf("="));
|
|
115
|
+
vars[k] = a.slice(k.length + 1);
|
|
116
|
+
const r = checkAssign(k, vars[k]);
|
|
117
|
+
if (r) return r;
|
|
118
|
+
}
|
|
119
|
+
const argv = c.argv.map((a) => a.replace(/\$\{?([A-Za-z_]\w*)\}?/g, (m, k) => vars[k] ?? m));
|
|
120
|
+
const r = checkCommand(argv, c, ctx, depth);
|
|
121
|
+
if (r) return r;
|
|
122
|
+
}
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function checkAssign(k, v) {
|
|
127
|
+
if (/^(ANTHROPIC|OPENAI|CLAUDE_CODE|GEMINI|GOOGLE_GEMINI|AZURE_OPENAI|MISTRAL|GROQ)_(BASE_URL|API_BASE|API_URL|BASE)$/.test(k) && !/localhost|127\.0\.0\.1/.test(v))
|
|
128
|
+
return deny(`Blocked: setting ${k} sends your AI traffic, and your API key, to another server.`);
|
|
129
|
+
if (/^(HTTPS?_PROXY|ALL_PROXY|https?_proxy|all_proxy)$/.test(k) && !/localhost|127\.0\.0\.1/.test(v))
|
|
130
|
+
return deny(`Blocked: setting ${k} routes all web traffic through another server.`);
|
|
131
|
+
if (k === "NODE_OPTIONS" && /--(require|import|loader|experimental-loader)\b|(^|\s)-r\b/.test(v)) return deny("Blocked: NODE_OPTIONS that load code into every Node process.");
|
|
132
|
+
if (/^(LD_PRELOAD|DYLD_INSERT_LIBRARIES|BASH_ENV|ENV|PROMPT_COMMAND|PYTHONSTARTUP|PERL5OPT|RUBYOPT)$/.test(k)) return deny(`Blocked: ${k} makes other programs load extra code.`);
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function checkCommand(argvIn, cmd, ctx, depth) {
|
|
137
|
+
let argv = argvIn;
|
|
138
|
+
while (["if", "then", "else", "elif", "do", "while", "until", "!", "{", "time"].includes(argv[0])) argv = argv.slice(1);
|
|
139
|
+
if (["for", "case", "esac", "fi", "done", "function", "select", "in"].includes(argv[0])) return null;
|
|
140
|
+
const text = argv.join(" ");
|
|
141
|
+
|
|
142
|
+
// Redirects: raw sockets, secret inputs, writes, heredoc bodies.
|
|
143
|
+
for (const r of cmd.redirects) {
|
|
144
|
+
if (/^\/dev\/(tcp|udp)\//.test(r.target)) return deny("Blocked: opening a raw network socket from the shell.");
|
|
145
|
+
if (r.body === undefined && r.op.includes("<") && SECRET_PATH.test(r.target)) ctx.secret = true;
|
|
146
|
+
if (r.op.includes(">") && !/^\/dev\/(null|stdout|stderr)$|^&?\d$/.test(r.target)) {
|
|
147
|
+
const body = cmd.redirects.find((x) => x.body !== undefined)?.body;
|
|
148
|
+
const v = checkWriteTarget(r.target, body ?? text, ctx, r.op.includes(">>") ? "append" : "overwrite", depth);
|
|
149
|
+
if (v) return v;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (!argv.length) return null;
|
|
153
|
+
const name = base(argv[0]);
|
|
154
|
+
const args = argv.slice(1);
|
|
155
|
+
const heredoc = cmd.redirects.find((r) => r.body !== undefined)?.body;
|
|
156
|
+
|
|
157
|
+
if (/\$|\(…\)/.test(argv[0])) return deny("Blocked: the command name is built at runtime, so nobody can check what it runs.");
|
|
158
|
+
if (HIDDEN.test(text)) return deny("Blocked: this command contains invisible characters, a known way to hide instructions.");
|
|
159
|
+
|
|
160
|
+
// Facts that matter across the whole command line.
|
|
161
|
+
if (args.some(isSecretArg) || ["env", "printenv"].includes(name) && !positional(args).length || (name === "set" || name === "export" || name === "declare") && (!args.length || /^-[px]+$/.test(args[0]))) ctx.secret = true;
|
|
162
|
+
if (name === "security" && /find-(generic|internet)-password|dump-keychain/.test(text) || name === "gh" && /auth token/.test(text) || name === "git" && /credential (fill|get)/.test(text)) ctx.secret = true;
|
|
163
|
+
|
|
164
|
+
if (["export", "declare", "typeset", "local", "readonly", "setenv"].includes(name))
|
|
165
|
+
for (const a of args.filter((a) => a.includes("="))) {
|
|
166
|
+
const r = checkAssign(a.slice(0, a.indexOf("=")), a.slice(a.indexOf("=") + 1));
|
|
167
|
+
if (r) return r;
|
|
168
|
+
}
|
|
169
|
+
if (AGENT_CLIS.has(name)) {
|
|
170
|
+
if (args.some((a) => UNSAFE_AGENT_FLAGS.test(a))) return deny("Blocked: starting an AI agent with its safety checks switched off (the s1ngularity/Nx attack did exactly this).");
|
|
171
|
+
if (/^(config|mcp)$/.test(args[0]) && /\b(add|add-json|set|remove|add-from-claude-desktop)\b/.test(text)) return deny("Blocked: agents may not change their own configuration or tools.");
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Wrappers and inline code: check what actually runs.
|
|
175
|
+
const inner = unwrap(argv);
|
|
176
|
+
if (inner) {
|
|
177
|
+
if (name === "xargs" && inner.length) {
|
|
178
|
+
const sub = { ...ctx, viaXargs: true };
|
|
179
|
+
const r = checkCommand(inner, { ...cmd, redirects: [] }, sub, depth + 1);
|
|
180
|
+
ctx.secret ||= sub.secret;
|
|
181
|
+
ctx.sink ||= sub.sink;
|
|
182
|
+
return r;
|
|
183
|
+
}
|
|
184
|
+
if ((name === "npx" || name === "bunx" || name === "pnpx") && args.some((a) => /^(-y|--yes)$/.test(a)) && inner[0] && !fs.existsSync(path.join(ctx.cwd, "node_modules", ".bin", inner[0])))
|
|
185
|
+
return deny(`Blocked: running "${inner[0]}" straight from the package registry. Check it is real and trusted, then ask the user.`);
|
|
186
|
+
return inner.length ? checkCommand(inner, { ...cmd, redirects: [] }, ctx, depth + 1) : null;
|
|
187
|
+
}
|
|
188
|
+
const script = inlineScript(argv);
|
|
189
|
+
if (script) {
|
|
190
|
+
if (script.kind === "shell") return checkShell(script.code, ctx, depth + 1);
|
|
191
|
+
const r = checkCode(script.code, ctx, depth + 1);
|
|
192
|
+
if (r) return r;
|
|
193
|
+
if (script.inPlace) for (const f of script.files) {
|
|
194
|
+
const v = checkWriteTarget(f, script.code, ctx, "edit", depth);
|
|
195
|
+
if (v) return v;
|
|
196
|
+
}
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Shells and interpreters fed from a pipe, a heredoc, a process substitution or a file.
|
|
201
|
+
if (SHELLS.test(name) || INTERPRETERS.test(name) || name === "source" || name === ".") {
|
|
202
|
+
const isShell = SHELLS.test(name) || name === "source" || name === ".";
|
|
203
|
+
if (INTERPRETERS.test(name) && args[0] === "-m" && args[1]) return checkCommand(args.slice(1), { ...cmd, redirects: [] }, ctx, depth + 1);
|
|
204
|
+
const file = positional(args)[0];
|
|
205
|
+
if (heredoc !== undefined && !file) return isShell ? checkShell(heredoc, ctx, depth + 1) : checkCode(heredoc, ctx, depth + 1);
|
|
206
|
+
if (file?.includes("(…)")) return deny("Blocked: running code from a process substitution (like bash <(curl …)) runs code nobody reviewed.");
|
|
207
|
+
if (!file || file === "-") return cmd.pipedIn || args.includes("-s") ? deny("Blocked: piping text straight into a shell or interpreter runs code nobody reviewed.") : null;
|
|
208
|
+
return checkScriptFile(file, isShell, ctx, depth);
|
|
209
|
+
}
|
|
210
|
+
if (argv[0].includes("/") && !outsideWorkspace(ctx, argv[0])) {
|
|
211
|
+
const r = checkScriptFile(argv[0], true, ctx, depth);
|
|
212
|
+
if (r) return r;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Network.
|
|
216
|
+
if (NET_CMDS.has(name) || name === "gh" && /^(api|gist|release)$/.test(args[0]) || name === "openssl" && args[0] === "s_client") {
|
|
217
|
+
ctx.sink = true;
|
|
218
|
+
const host = hostOf(name, args);
|
|
219
|
+
if (DNS_CMDS.has(name) && args.some((a) => /\$|\(…\)/.test(a))) return deny("Blocked: data built into a DNS lookup or ping, a known way to sneak secrets out (CVE-2025-55284).");
|
|
220
|
+
if (args.some(isSecretArg)) return deny("Blocked: this command would send secrets or .env contents over the network.");
|
|
221
|
+
if (name === "ssh" && args.some((a) => /ProxyCommand|LocalCommand|PermitLocalCommand/i.test(a))) return deny("Blocked: ssh options that run local commands.");
|
|
222
|
+
if ((name === "rsync") && args.some((a) => /^(-e|--rsh)/.test(a))) return deny("Blocked: rsync with a custom remote shell command.");
|
|
223
|
+
const upload = name === "curl" && args.some((a) => /^(-d|--data.*|-F|--form.*|-T|--upload-file|--json)$/.test(a) || /^-[a-zA-Z]*[dFT]$/.test(a)) || name === "curl" && /-X\s*(POST|PUT|PATCH)/i.test(text)
|
|
224
|
+
|| name === "wget" && args.some((a) => /^--(post|body)-(data|file)|^--method=(POST|PUT)/i.test(a))
|
|
225
|
+
|| ["scp", "rsync", "sftp"].includes(name) && /^([^/@\s]+@)?[\w.-]+:/.test(positional(args).pop() ?? "")
|
|
226
|
+
|| ["nc", "ncat", "netcat", "socat", "telnet"].includes(name) && (cmd.pipedIn || cmd.redirects.some((r) => r.op.includes("<")))
|
|
227
|
+
|| /^(https?|xh)$/.test(name) && args.some((a) => /^[\w-]+(:?=|@)/.test(a));
|
|
228
|
+
if (upload && host && !LOCAL_HOST.test(host)) return deny(`Blocked: uploading data to ${host}. Ask the user first.`);
|
|
229
|
+
if (ctx.state.untrusted && ctx.state.secrets && host && !allowedHost(host))
|
|
230
|
+
return deny(`Blocked (strict mode): this session has read untrusted content and secrets, so talking to ${host} is off. Allow it with CRUMPLE_ALLOW_HOSTS if it is expected.`);
|
|
231
|
+
if (["curl", "wget", "aria2c"].includes(name)) {
|
|
232
|
+
const out = args[args.findIndex((a) => /^(-o|--output|-O|--output-document)$/.test(a)) + 1];
|
|
233
|
+
if (out && args.some((a) => /^(-o|--output|-O|--output-document)$/.test(a))) {
|
|
234
|
+
const v = checkWriteTarget(out, "", ctx, "download", depth);
|
|
235
|
+
if (v) return v;
|
|
236
|
+
}
|
|
237
|
+
if (!LOCAL_HOST.test(host)) ctx.fetchedRemote = true;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Databases and cloud.
|
|
242
|
+
if (DB_CMDS.has(name)) {
|
|
243
|
+
let sql = text + " " + (heredoc ?? "");
|
|
244
|
+
for (const r of cmd.redirects) if (r.op === "<") sql += " " + (readText(ctx, r.target) ?? "");
|
|
245
|
+
if (DB_WRITE.test(sql) && /prod/i.test(text + " " + Object.values(cmd.assigns).join(" "))) return deny("Blocked: writing to a database marked production. Use the dev database or ask the user.");
|
|
246
|
+
}
|
|
247
|
+
if (CLOUD_CMDS.has(name) && args.some((a) => CLOUD_DESTROY.test(a)) && !args.includes("--dry-run") && !args.includes("plan"))
|
|
248
|
+
return deny(`Blocked: a destructive ${name} command against real infrastructure. Ask the user to run it.`);
|
|
249
|
+
if (name === "docker" && args[0] === "run" && args.some((a, k) => a === "--privileged" || /^(-v|--volume|--mount)$/.test(args[k - 1] ?? "") && /^(\/|~)(:|$)|docker\.sock|\.ssh|\.aws/.test(a)))
|
|
250
|
+
return deny("Blocked: a container with access to the whole machine, its Docker socket or its keys.");
|
|
251
|
+
if (name === "crontab" && !args.includes("-l") || name === "launchctl" && /^(load|bootstrap|submit)$/.test(args[0]) || name === "systemctl" && /^(enable|link)$/.test(args.find((a) => !a.startsWith("-"))))
|
|
252
|
+
return deny("Blocked: installing something that runs on its own later (cron, launchd, systemd).");
|
|
253
|
+
if (name === "dd" && args.some((a) => /^of=\/dev\//.test(a)) || /^mkfs/.test(name) || name === "diskutil" && /erase/i.test(text))
|
|
254
|
+
return deny("Blocked: writing straight to a disk device.");
|
|
255
|
+
|
|
256
|
+
// git.
|
|
257
|
+
if (name === "git") {
|
|
258
|
+
const v = checkGit(args, ctx);
|
|
259
|
+
if (v) return v;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Deletes.
|
|
263
|
+
if (["rm", "unlink", "shred", "srm", "rmdir", "trash"].includes(name)) {
|
|
264
|
+
const recursive = /[rR]/.test(flagsOf(args)) || args.includes("--recursive") || name === "shred";
|
|
265
|
+
const targets = positional(args).flatMap(braces);
|
|
266
|
+
if (!targets.length && recursive && ctx.viaXargs) return deny("Blocked: recursive delete of files chosen at runtime. List what will be removed and ask the user.");
|
|
267
|
+
for (const t of targets) {
|
|
268
|
+
if (outsideWorkspace(ctx, t) || /^(\/|~|\*|\.|\.\/\*?)$/.test(t)) return deny(`Blocked: deleting ${t}, which is outside this project or the whole project. Ask the user.`);
|
|
269
|
+
const r = rel(ctx, t);
|
|
270
|
+
if (TEST_FILE.test(r)) return deny("Blocked: deleting tests. Fix the code instead, or ask the user.");
|
|
271
|
+
if (PROTECTED.test(r)) return deny("Blocked: agents may not change their own configuration, git hooks or startup files.");
|
|
272
|
+
if (recursive && (!GENERATED.test(r) || r.includes(".."))) return deny(`Blocked: recursive delete of ${t}. Only generated folders (build, dist, .cache, node_modules…) may be removed without asking.`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
if (name === "find" || name === "fd" || name === "fdfind") {
|
|
276
|
+
const v = checkFind(name, args, cmd, ctx, depth);
|
|
277
|
+
if (v) return v;
|
|
278
|
+
}
|
|
279
|
+
if (name === "go" && args.includes("-exec")) {
|
|
280
|
+
const r = checkShell(args[args.indexOf("-exec") + 1] ?? "", ctx, depth + 1);
|
|
281
|
+
if (r) return r;
|
|
282
|
+
return deny("Blocked: go -exec runs another program around your tests. Run go test without it.");
|
|
283
|
+
}
|
|
284
|
+
if (name === "rg" && args.some((a) => /^--pre(=|$)/.test(a))) return deny("Blocked: rg --pre runs a program on every file it searches.");
|
|
285
|
+
if (name === "tar" && args.some((a) => /^--(to-command|checkpoint-action|use-compress-program|info-script|new-volume-script)|^-I$|^-F$/.test(a))) return deny("Blocked: tar options that run other programs.");
|
|
286
|
+
if (name === "zip" && args.some((a) => /^(-TT|--unzip-command)/.test(a)) || name === "sort" && args.some((a) => /^--compress-program/.test(a))) return deny(`Blocked: ${name} options that run other programs.`);
|
|
287
|
+
if (/^(vim?|nvim|ex)$/.test(name) && args.some((a, k) => /^(-c|--cmd)$/.test(args[k - 1] ?? "") && a.startsWith("!") || /^\+!/.test(a))) return deny("Blocked: running shell commands through the editor.");
|
|
288
|
+
if (/^g?awk$/.test(name)) {
|
|
289
|
+
const prog = positional(args)[0] ?? "";
|
|
290
|
+
if (/system\(|\|\s*getline|print[^;]*\|\s*"/.test(prog)) {
|
|
291
|
+
for (const m of prog.matchAll(/"([^"]{2,})"/g)) {
|
|
292
|
+
const r = checkShell(m[1], ctx, depth + 1, true);
|
|
293
|
+
if (r) return r;
|
|
294
|
+
}
|
|
295
|
+
if (/system\([^"]*\$/.test(prog)) return deny("Blocked: awk running shell commands built from data.");
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Package installs and registries.
|
|
300
|
+
const v = checkInstall(name, args, ctx, depth);
|
|
301
|
+
if (v) return v;
|
|
302
|
+
|
|
303
|
+
// Writes through ordinary commands.
|
|
304
|
+
if (name === "sed" && (args.some((a) => /^-[a-zA-Z]*i/.test(a) || a.startsWith("--in-place")))) {
|
|
305
|
+
const e = args.indexOf("-e");
|
|
306
|
+
const pos = positional(args);
|
|
307
|
+
const scriptArg = e >= 0 ? args[e + 1] : pos.shift();
|
|
308
|
+
for (const f of e >= 0 ? pos.filter((a) => a !== scriptArg) : pos) {
|
|
309
|
+
const w = checkWriteTarget(f, scriptArg ?? "", ctx, "edit", depth);
|
|
310
|
+
if (w) return w;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
if (name === "sed" && args.some((a) => /(^|[;\n{}])\s*(\d+|\$|\/[^/]*\/)?(,(\d+|\$|\/[^/]*\/))?\s*e(\s|$)|^s(.).*\6.*\6[gpIiMm0-9]*e/.test(a))) return deny("Blocked: a sed script that runs shell commands.");
|
|
314
|
+
if (["cp", "mv", "install", "ln", "rsync"].includes(name)) {
|
|
315
|
+
const pos = positional(args);
|
|
316
|
+
const dest = pos.pop();
|
|
317
|
+
if (name === "mv" && pos.some((s) => TEST_FILE.test(rel(ctx, s)))) return deny("Blocked: moving tests out of the way. Fix the code instead, or ask the user.");
|
|
318
|
+
if (name === "mv" && pos.some((s) => PROTECTED.test(rel(ctx, s)))) return deny("Blocked: agents may not change their own configuration, git hooks or startup files.");
|
|
319
|
+
if (dest) {
|
|
320
|
+
if (pos.some((s) => SECRET_PATH.test(s)) && /(^|\/)(public|static|www|dist|build|out|docs|site)(\/|$)/.test(rel(ctx, dest))) return deny("Blocked: copying secrets into a folder that gets published.");
|
|
321
|
+
const w = checkWriteTarget(dest, pos.map((s) => readText(ctx, s, 64 * 1024) ?? "").join("\n"), ctx, "copy", depth);
|
|
322
|
+
if (w) return w;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
if (["tee", "truncate", "touch", "chmod", "chown", "chattr"].includes(name)) {
|
|
326
|
+
for (const f of positional(args).filter((a) => !/^[+=-]?[0-7]{3,4}$|^[ugoa]*[+=-][rwxXst]+$/.test(a))) {
|
|
327
|
+
const w = checkWriteTarget(f, text, ctx, name === "truncate" ? "delete" : name === "tee" ? (args.includes("-a") ? "append" : "overwrite") : "meta", depth);
|
|
328
|
+
if (w) return w;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
return null;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function checkGit(args, ctx) {
|
|
335
|
+
let k = 0;
|
|
336
|
+
const configs = [];
|
|
337
|
+
while (k < args.length && args[k].startsWith("-")) {
|
|
338
|
+
if (args[k] === "-c") configs.push(args[++k] ?? "");
|
|
339
|
+
else if (/^--config-env/.test(args[k])) configs.push(args[k].split("=")[1] ?? args[++k] ?? "");
|
|
340
|
+
else if (["-C", "--git-dir", "--work-tree", "--namespace"].includes(args[k])) k++;
|
|
341
|
+
k++;
|
|
342
|
+
}
|
|
343
|
+
const sub = args[k];
|
|
344
|
+
const rest = args.slice(k + 1);
|
|
345
|
+
const DANGEROUS = /^(core\.(hookspath|sshcommand|fsmonitor|pager|editor|gitproxy|askpass)|sequence\.editor|diff\.external|diff\.\w+\.textconv|filter\.\w+\.(clean|smudge|process)|credential\.helper|url\..*\.insteadof|protocol\.\w+\.allow|alias\..*|uploadpack\.packobjectshook|include\.path|includeif\..*)/i;
|
|
346
|
+
if (configs.some((c) => DANGEROUS.test(c.split("=")[0]))) return deny("Blocked: git -c options that run other programs or redirect where git talks to.");
|
|
347
|
+
if (sub === "config" && rest.some((a) => DANGEROUS.test(a)) && !rest.some((a) => /^(--get|--list|-l|--get-all)$/.test(a)))
|
|
348
|
+
return deny("Blocked: changing git settings that run programs (hooks path, ssh command, aliases, helpers).");
|
|
349
|
+
if (["clone", "fetch", "pull", "ls-remote", "submodule", "archive"].includes(sub) && rest.some((a) => /^(--upload-pack|-u|--exec|--receive-pack|--config|-c)(=|$)/.test(a) && /^(-u|--upload-pack|--exec|--receive-pack)/.test(a)))
|
|
350
|
+
return deny("Blocked: git options that run a program on the remote side.");
|
|
351
|
+
if (sub === "push") {
|
|
352
|
+
ctx.sink = true;
|
|
353
|
+
if (rest.some((a) => /^(--force|-f|--force-with-lease|--force-if-includes|--mirror|--delete|-d|--prune)(=.*)?$/.test(a) || /^-[a-zA-Z]*f[a-zA-Z]*$/.test(a) || /^\+/.test(a) || /^:\S/.test(a)))
|
|
354
|
+
return deny("Blocked: force-pushing or deleting branches rewrites shared history. Push normally or ask the user.");
|
|
355
|
+
if (rest.some((a) => /:\/\/|^[\w.-]+@[\w.-]+:/.test(a))) return deny("Blocked: pushing code to an address that isn't one of this repo's remotes.");
|
|
356
|
+
}
|
|
357
|
+
if (sub === "clean" && /f/.test(flagsOf(rest)) && /[xX]/.test(flagsOf(rest))) return deny("Blocked: git clean -x also deletes ignored files like .env and local data. Ask the user.");
|
|
358
|
+
if (sub === "remote" && /^(add|set-url)$/.test(rest[0]) && ctx.state.untrusted) return deny("Blocked (strict mode): adding a git remote after reading untrusted content.");
|
|
359
|
+
return null;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function checkFind(name, args, cmd, ctx, depth) {
|
|
363
|
+
const execAt = args.findIndex((a) => /^(-exec|-execdir|-ok|-okdir|-x|--exec|-X|--exec-batch)$/.test(a));
|
|
364
|
+
if (execAt >= 0) {
|
|
365
|
+
let end = args.findIndex((a, k) => k > execAt && (a === ";" || a === "+"));
|
|
366
|
+
if (end < 0) end = args.length;
|
|
367
|
+
const r = checkCommand(args.slice(execAt + 1, end), { argv: [], redirects: [], assigns: [], pipedIn: false }, { ...ctx, viaXargs: true }, depth + 1);
|
|
368
|
+
if (r) return r;
|
|
369
|
+
}
|
|
370
|
+
if (name === "find" && args.includes("-delete")) {
|
|
371
|
+
const paths = args.slice(0, args.findIndex((a) => /^[-(!]/.test(a)) >>> 0);
|
|
372
|
+
const pattern = args[args.findIndex((a) => /^-i?(name|path)$/.test(a)) + 1] ?? "";
|
|
373
|
+
for (const p of paths.length ? paths : ["."]) {
|
|
374
|
+
if (outsideWorkspace(ctx, p)) return deny(`Blocked: deleting files under ${p}, outside this project.`);
|
|
375
|
+
if (!GENERATED.test(rel(ctx, p)) && !/\.(log|pyc|pyo|tmp|orig|rej)$|__pycache__|\.DS_Store|^(build|dist|node_modules|\.cache)$/.test(pattern.replace(/^\*/, "x")))
|
|
376
|
+
return deny(`Blocked: find -delete under ${p}. Only generated files may be removed without asking.`);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
return null;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const INSTALL_VERBS = { npm: /^(install|i|add|in|isntall|ins|inst|insta|instal|isnt|isnta|isntal)$/, pnpm: /^(add|install|i)$/, yarn: /^(add|install)$/, bun: /^(add|install|i)$/ };
|
|
383
|
+
|
|
384
|
+
function checkInstall(name, args, ctx, depth) {
|
|
385
|
+
const pos = positional(args);
|
|
386
|
+
if (INSTALL_VERBS[name]?.test(pos[0] ?? "")) {
|
|
387
|
+
if (pos.length > 1) return deny(`Blocked: installing ${pos.slice(1).join(", ")}. Check each package is real and trusted, then ask the user to approve it.`);
|
|
388
|
+
if (args.some((a) => /^--registry/.test(a))) return deny("Blocked: installing from a different package registry.");
|
|
389
|
+
return checkInstallScripts(args, ctx) ?? checkLifecycle(ctx, depth, ["preinstall", "install", "postinstall", "prepare"]);
|
|
390
|
+
}
|
|
391
|
+
if (name === "npm" && pos[0] === "ci") return checkInstallScripts(args, ctx) ?? checkLifecycle(ctx, depth, ["preinstall", "install", "postinstall", "prepare"]);
|
|
392
|
+
if (["npm", "pnpm", "yarn", "bun"].includes(name) && pos[0] === "config" && /^set$/.test(pos[1] ?? "") || name === "pip" && pos[0] === "config")
|
|
393
|
+
return deny("Blocked: changing package manager settings (registry, tokens).");
|
|
394
|
+
if (/^(npm|pnpm|yarn|bun)$/.test(name) && (pos[0] === "run" || pos[0] === "run-script" || /^(test|start|t|tst|build|dev|lint)$/.test(pos[0] ?? "") || name !== "npm" && pos[0] && !/^(install|add|remove|why|list|ls|outdated|info|view)$/.test(pos[0]))) {
|
|
395
|
+
const script = pos[0] === "run" || pos[0] === "run-script" ? pos[1] : pos[0] === "t" || pos[0] === "tst" ? "test" : pos[0];
|
|
396
|
+
return checkLifecycle(ctx, depth, [`pre${script}`, script, `post${script}`]);
|
|
397
|
+
}
|
|
398
|
+
if (/^pip[\d.]*$/.test(name) || name === "uv" && pos[0] === "pip") {
|
|
399
|
+
const p = name === "uv" ? pos.slice(1) : pos;
|
|
400
|
+
if (args.some((a) => /^(-i|--index-url|--extra-index-url|-f|--find-links|--trusted-host)(=|$)/.test(a))) return deny("Blocked: installing from a different package index.");
|
|
401
|
+
if (p[0] === "install") {
|
|
402
|
+
const pkgs = args.filter((a, k) => !a.startsWith("-") && !/^(-r|--requirement|-c|--constraint|-e|--editable)$/.test(args[k - 1] ?? "")).filter((a) => a !== "install" && a !== "pip" && a !== "." && !a.startsWith("./"));
|
|
403
|
+
if (pkgs.length) return deny(`Blocked: installing ${pkgs.join(", ")}. Check each package is real and trusted, then ask the user to approve it.`);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
if (name === "uv" && pos[0] === "add" || name === "poetry" && pos[0] === "add" || name === "cargo" && /^(install|add)$/.test(pos[0] ?? "") || name === "go" && (pos[0] === "install" || pos[0] === "get") && pos[1]
|
|
407
|
+
|| name === "gem" && pos[0] === "install" || name === "brew" && /^(install|tap)$/.test(pos[0] ?? "") || /^apt(-get)?$/.test(name) && pos[0] === "install" || name === "composer" && pos[0] === "require" || name === "dotnet" && pos[0] === "add" || /^(pipx|uvx)$/.test(name) && (pos[0] === "install" || name === "uvx"))
|
|
408
|
+
return deny(`Blocked: installing ${pos.slice(1).join(", ") || "a package"}. Check it is real and trusted, then ask the user to approve it.`);
|
|
409
|
+
return null;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Packages known to need install scripts for native builds. Anything else must be approved.
|
|
413
|
+
const TRUSTED_INSTALL_SCRIPTS = /^(@esbuild\/.*|esbuild|sharp|@swc\/core|bcrypt|better-sqlite3|sqlite3|canvas|node-sass|puppeteer|playwright|playwright-core|fsevents|@parcel\/watcher|core-js|core-js-pure|protobufjs|husky|nx|@nx\/.*|cypress|electron|re2|argon2|utf-8-validate|bufferutil|msgpackr-extract|@prisma\/.*|prisma|lefthook|unrs-resolver|@biomejs\/biome|@tailwindcss\/oxide)$/;
|
|
414
|
+
|
|
415
|
+
// npm records which locked packages run install scripts. Dependencies' scripts (the Nx/s1ngularity
|
|
416
|
+
// and Shai-Hulud route) never show up in the command itself, so read them from the lockfile.
|
|
417
|
+
function checkInstallScripts(args, ctx) {
|
|
418
|
+
if (args.some((a) => a === "--ignore-scripts" || a === "--ignore-scripts=true")) return null;
|
|
419
|
+
if (/ignore-scripts\s*=\s*true/.test(readText(ctx, ".npmrc") ?? "")) return null;
|
|
420
|
+
let lock;
|
|
421
|
+
try {
|
|
422
|
+
lock = JSON.parse(readText(ctx, "package-lock.json", 8 * 1024 * 1024) ?? "null");
|
|
423
|
+
} catch {}
|
|
424
|
+
const allow = (process.env.CRUMPLE_ALLOW_INSTALL_SCRIPTS ?? "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
425
|
+
const risky = Object.entries(lock?.packages ?? {})
|
|
426
|
+
.filter(([k, v]) => k && v?.hasInstallScript)
|
|
427
|
+
.map(([k, v]) => v.name ?? k.split("node_modules/").pop())
|
|
428
|
+
.filter((n) => !TRUSTED_INSTALL_SCRIPTS.test(n) && !allow.includes(n));
|
|
429
|
+
if (risky.length)
|
|
430
|
+
return deny(`Blocked: these packages run install scripts with access to your keys: ${[...new Set(risky)].slice(0, 6).join(", ")}. Install with --ignore-scripts, or ask the user to approve them (CRUMPLE_ALLOW_INSTALL_SCRIPTS).`);
|
|
431
|
+
return null;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function checkLifecycle(ctx, depth, names) {
|
|
435
|
+
let pkg;
|
|
436
|
+
try {
|
|
437
|
+
pkg = JSON.parse(readText(ctx, "package.json") ?? "null");
|
|
438
|
+
} catch {}
|
|
439
|
+
for (const n of names) {
|
|
440
|
+
const s = pkg?.scripts?.[n];
|
|
441
|
+
if (typeof s !== "string") continue;
|
|
442
|
+
const r = checkShell(s, ctx, depth + 1, true);
|
|
443
|
+
if (r) return deny(`${r.reason} (from the "${n}" script in package.json)`);
|
|
444
|
+
}
|
|
445
|
+
return null;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Files run directly: checked fully when the agent wrote them this session, otherwise only shell scripts are read.
|
|
449
|
+
function checkScriptFile(file, isShell, ctx, depth) {
|
|
450
|
+
const abs = resolveIn(ctx, file);
|
|
451
|
+
const agentWritten = (ctx.state.written ?? []).includes(abs);
|
|
452
|
+
const content = readText(ctx, file);
|
|
453
|
+
if (content == null) return null;
|
|
454
|
+
if (HIDDEN.test(content)) return deny(`Blocked: ${file} contains invisible characters, a known way to hide instructions.`);
|
|
455
|
+
const shellish = isShell && /\.(sh|bash|zsh)$|^[^.]*$/.test(file) || /^#!.*\b(ba|z|da)?sh\b/.test(content);
|
|
456
|
+
if (shellish) return checkShell(content, ctx, depth + 1, true);
|
|
457
|
+
const malware = scanForMalware(abs, ctx);
|
|
458
|
+
if (malware) return malware;
|
|
459
|
+
if (agentWritten) return checkCode(content, ctx, depth + 1);
|
|
460
|
+
return null;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const HIDDEN_EVAL = /\b(eval|Function|vm\.run\w*|execScript)\s*\(\s*(Buffer\.from|atob|unescape|decodeURIComponent|String\.fromCharCode|require\(\s*['"](zlib|crypto)['"]\))|\bexec\s*\(\s*(base64\.b64decode|zlib\.decompress|codecs\.decode|bytes\.fromhex|__import__\(['"]base64)/;
|
|
464
|
+
const HOME_CREDS = /\.aws\/credentials|\.ssh\/id_|\.config\/gh\/hosts|Login Data|Keychains|\.gnupg|\.npmrc|\.docker\/config\.json|Local State|wallet|Exodus|\.kube\/config|\.git-credentials/;
|
|
465
|
+
|
|
466
|
+
// Code that is about to run (an npm script, node file.js, python x.py) and everything it imports
|
|
467
|
+
// locally is scanned for what malware lures do: run hidden code, or read credential stores and
|
|
468
|
+
// send them out. Ordinary app code that calls an API with a key from the environment passes.
|
|
469
|
+
function scanForMalware(entry, ctx, seen = new Set()) {
|
|
470
|
+
if (seen.size > 40 || seen.has(entry)) return null;
|
|
471
|
+
seen.add(entry);
|
|
472
|
+
let code;
|
|
473
|
+
try {
|
|
474
|
+
if (fs.statSync(entry).size > 512 * 1024) return null;
|
|
475
|
+
code = fs.readFileSync(entry, "utf8");
|
|
476
|
+
} catch {
|
|
477
|
+
return null;
|
|
478
|
+
}
|
|
479
|
+
const where = path.relative(ctx.cwd, entry) || entry;
|
|
480
|
+
if (HIDDEN_EVAL.test(code)) return deny(`Blocked: ${where} decodes and runs hidden code, which is how malware in fake projects hides. Read it first and ask the user.`);
|
|
481
|
+
if (HOME_CREDS.test(code) && (CODE_NET.test(code) || CODE_SHELL.test(code) || /http\.request|https\.request/.test(code)))
|
|
482
|
+
return deny(`Blocked: ${where} reads saved credentials from your home folder and talks to the network.`);
|
|
483
|
+
for (const m of code.matchAll(/(?:require\(|from\s+|import\s*\(?)\s*['"](\.{1,2}\/[^'"]+)['"]/g)) {
|
|
484
|
+
const dir = path.dirname(entry);
|
|
485
|
+
for (const ext of ["", ".js", ".cjs", ".mjs", ".ts", "/index.js"]) {
|
|
486
|
+
const next = path.resolve(dir, m[1] + ext);
|
|
487
|
+
if (fs.existsSync(next) && fs.statSync(next).isFile()) {
|
|
488
|
+
const r = scanForMalware(next, ctx, seen);
|
|
489
|
+
if (r) return r;
|
|
490
|
+
break;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
return null;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function checkCode(code, ctx, depth) {
|
|
498
|
+
if (HIDDEN.test(code)) return deny("Blocked: this code contains invisible characters, a known way to hide instructions.");
|
|
499
|
+
const net = CODE_NET.test(code);
|
|
500
|
+
const secret = CODE_SECRET.test(code) || SECRET_VALUES.test(code);
|
|
501
|
+
if (net) ctx.sink = true;
|
|
502
|
+
if (secret) ctx.secret = true;
|
|
503
|
+
if (net && secret) return deny("Blocked: this code reads secrets and talks to the network. Ask the user first.");
|
|
504
|
+
if (/sqlite3\.connect|new Database\(|psycopg|pg\.(Client|Pool)|createConnection|MongoClient/.test(code) && /prod/i.test(code) && DB_WRITE.test(code))
|
|
505
|
+
return deny("Blocked: writing to a database marked production. Use the dev database or ask the user.");
|
|
506
|
+
if (/rmtree|rmSync|fs\.rm\(|rimraf|fs\.unlink|os\.remove|unlink\(/.test(code) && /expanduser|homedir|\$HOME|os\.environ\[['"]HOME|['"]\/['"]|['"]~\/|Documents|backups?\b/.test(code))
|
|
507
|
+
return deny("Blocked: code that deletes files outside this project.");
|
|
508
|
+
if (/writeFile|appendFile|open\([^)]*['"][wa]\+?['"]|write_text|write_bytes|File\.write/.test(code)) {
|
|
509
|
+
for (const m of code.matchAll(/['"`]([^'"`\n]{2,200})['"`]/g)) if (PROTECTED.test(m[1]) || CI_FILE.test(m[1])) {
|
|
510
|
+
if (PROTECTED.test(m[1])) return deny("Blocked: code that changes the agent's own configuration, git hooks or startup files.");
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
if (CODE_SHELL.test(code)) {
|
|
514
|
+
for (const m of code.matchAll(/(['"`])((?:(?!\1)[^\\\n]|\\.){3,600})\1/g)) {
|
|
515
|
+
if (!/\s/.test(m[2])) continue;
|
|
516
|
+
const r = checkShell(m[2], ctx, depth + 1, true);
|
|
517
|
+
if (r) return r;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
return null;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// Every way of writing a file ends here: redirects, cp/mv/tee/sed -i, downloads, and the Write/Edit tools.
|
|
524
|
+
function checkWriteTarget(target, text, ctx, kind, depth = 0) {
|
|
525
|
+
const r = rel(ctx, target);
|
|
526
|
+
(ctx.writes ??= []).push(resolveIn(ctx, target));
|
|
527
|
+
if (PROTECTED.test(r) || PROTECTED.test(resolveIn(ctx, target))) return deny("Blocked: agents may not change their own configuration, permissions, git hooks or startup files.");
|
|
528
|
+
if (outsideWorkspace(ctx, target) && kind !== "meta") return deny(`Blocked: writing to ${target}, outside this project. Ask the user.`);
|
|
529
|
+
if (HIDDEN.test(text)) return deny("Blocked: writing invisible characters into a file, a known way to hide instructions.");
|
|
530
|
+
if (TEST_FILE.test(r)) {
|
|
531
|
+
const exists = readText(ctx, target) != null;
|
|
532
|
+
if (kind === "delete" || kind === "overwrite" && exists || kind === "download") return deny("Blocked: replacing or deleting tests from the shell. Fix the code instead, or ask the user.");
|
|
533
|
+
if (/skip|\bxit\b|xdescribe|\.todo\b/.test(text) || kind === "edit" && /(^|[;'"\s])\/[^/]*(assert|expect|should|test|it)\b[^/]*\/d|\bd['";\s]*$/.test(text))
|
|
534
|
+
return deny("Blocked: deleting or skipping tests. Fix the code instead, or ask the user.");
|
|
535
|
+
}
|
|
536
|
+
if (CI_FILE.test(r) && /\b(curl|wget|nc|fetch|Invoke-WebRequest|requests\.)\b/.test(text) && /secrets\.|\$\{?\w*(SECRET|TOKEN|KEY|PASSWORD)|\.env\b/i.test(text))
|
|
537
|
+
return deny("Blocked: a CI change that sends secrets to a server. Review this by hand.");
|
|
538
|
+
if (/(^|\/)\.env[\w.]*$|\.(sh|bash|zsh|ps1)$|(^|\/)package\.json$/.test(r) && /(ANTHROPIC|OPENAI|GEMINI|AZURE_OPENAI)_(BASE_URL|API_BASE)\s*[=:]/.test(text) && !/localhost|127\.0\.0\.1/.test(text))
|
|
539
|
+
return deny("Blocked: pointing your AI API base URL at another server, which hands it your key.");
|
|
540
|
+
if (SECRET_VALUES.test(text) && !/(^|\/)\.env[\w.]*$/.test(r)) return deny("Blocked: writing a secret value into a project file.");
|
|
541
|
+
if (/\.(sh|bash|zsh)$/.test(r) || /^#!.*\bsh\b/.test(text)) {
|
|
542
|
+
const v = checkShell(text, ctx, depth + 1, true);
|
|
543
|
+
if (v) return deny(`${v.reason} (inside the script being written to ${target})`);
|
|
544
|
+
}
|
|
545
|
+
if (/(^|\/)package\.json$/.test(r)) {
|
|
546
|
+
try {
|
|
547
|
+
const scripts = JSON.parse(text).scripts ?? {};
|
|
548
|
+
for (const [n, s] of Object.entries(scripts)) {
|
|
549
|
+
const v = checkShell(String(s), ctx, depth + 1, true);
|
|
550
|
+
if (v) return deny(`${v.reason} (in the "${n}" script being added to package.json)`);
|
|
551
|
+
}
|
|
552
|
+
} catch {}
|
|
553
|
+
}
|
|
554
|
+
return null;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// --- tools ---------------------------------------------------------------------------------
|
|
558
|
+
|
|
559
|
+
function checkWrite(file, before, after, ctx) {
|
|
560
|
+
const v = checkWriteTarget(file, after ?? "", ctx, "tool");
|
|
561
|
+
if (v) return v;
|
|
562
|
+
const r = rel(ctx, file);
|
|
563
|
+
if (TEST_FILE.test(r) && after !== undefined && (SKIP.test(after) && !SKIP.test(before ?? "") || asserts(after) < asserts(before)))
|
|
564
|
+
return deny("Blocked: weakening or skipping tests. Fix the code instead, or ask the user.");
|
|
565
|
+
return null;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Decide on one tool call. `state` carries what this session has seen so far
|
|
570
|
+
* ({ untrusted, secrets, written }); it is updated in place for allowed calls.
|
|
571
|
+
* Returns null to allow, or { decision: "deny", reason }.
|
|
572
|
+
*/
|
|
573
|
+
export function decide(input, state = {}) {
|
|
574
|
+
const tool = input.tool_name ?? "";
|
|
575
|
+
const ti = input.tool_input ?? {};
|
|
576
|
+
const ctx = { cwd: path.resolve(input.cwd ?? process.cwd()), state, secret: false, sink: false };
|
|
577
|
+
let verdict = null;
|
|
578
|
+
|
|
579
|
+
if (tool === "Bash") {
|
|
580
|
+
const cmd = String(ti.command ?? "");
|
|
581
|
+
verdict = checkShell(cmd, ctx);
|
|
582
|
+
if (!verdict && ctx.secret && ctx.sink) verdict = deny("Blocked: this command reads secrets and talks to the network in one go.");
|
|
583
|
+
if (!verdict) {
|
|
584
|
+
if (ctx.secret) state.secrets = true;
|
|
585
|
+
if (ctx.fetchedRemote) state.untrusted = true;
|
|
586
|
+
if (ctx.writes?.length) state.written = [...new Set([...(state.written ?? []), ...ctx.writes])].slice(-200);
|
|
587
|
+
}
|
|
588
|
+
} else if (tool === "Write" || tool === "Edit" || tool === "MultiEdit" || tool === "NotebookEdit") {
|
|
589
|
+
const file = String(ti.file_path ?? ti.notebook_path ?? "");
|
|
590
|
+
if (tool === "Write") verdict = checkWrite(file, readText(ctx, file) ?? "", String(ti.content ?? ""), ctx);
|
|
591
|
+
else if (tool === "NotebookEdit") verdict = checkWrite(file, "", String(ti.new_source ?? ""), ctx);
|
|
592
|
+
else for (const e of tool === "Edit" ? [ti] : ti.edits ?? []) {
|
|
593
|
+
verdict = checkWrite(file, String(e.old_string ?? ""), String(e.new_string ?? ""), ctx);
|
|
594
|
+
if (verdict) break;
|
|
595
|
+
}
|
|
596
|
+
if (!verdict) state.written = [...new Set([...(state.written ?? []), resolveIn(ctx, file)])].slice(-200);
|
|
597
|
+
} else if (tool === "Read" || tool === "Grep" || tool === "Glob" || tool === "LS") {
|
|
598
|
+
const p = String(ti.file_path ?? ti.path ?? "");
|
|
599
|
+
if (p) {
|
|
600
|
+
const abs = resolveIn(ctx, p);
|
|
601
|
+
if (!inside(ctx.cwd, abs) && HOME_SECRET.test(abs)) verdict = deny(`Blocked: ${p} holds credentials outside this project. Keep keys out of the agent's reach.`);
|
|
602
|
+
else {
|
|
603
|
+
if (SECRET_PATH.test(abs)) state.secrets = true;
|
|
604
|
+
if (UNTRUSTED_READ.test(rel(ctx, p))) state.untrusted = true;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
} else if (tool === "WebFetch" || tool === "WebSearch") {
|
|
608
|
+
const url = String(ti.url ?? ti.query ?? "");
|
|
609
|
+
if (SECRET_VALUES.test(url) || SECRET_VAR.test(url)) verdict = deny("Blocked: a secret inside a web address.");
|
|
610
|
+
else if (tool === "WebFetch" && state.untrusted && state.secrets && !allowedHost(hostOf("curl", [url])))
|
|
611
|
+
verdict = deny(`Blocked (strict mode): this session has read untrusted content and secrets, so fetching ${hostOf("curl", [url])} is off.`);
|
|
612
|
+
if (!verdict) state.untrusted = true;
|
|
613
|
+
} else if (tool.startsWith("mcp__")) {
|
|
614
|
+
const args = JSON.stringify(ti);
|
|
615
|
+
if (!mcpToolLocked(ctx.cwd, tool)) verdict = deny(`Blocked: ${tool} is not in this project's MCP lock (.crumple/mcp.lock.json). A server added or changed a tool; run \`crumple lock --check\` and review it.`);
|
|
616
|
+
else if (HIDDEN.test(args)) verdict = deny("Blocked: invisible characters in an MCP tool call.");
|
|
617
|
+
else if (SECRET_VALUES.test(args) || /[A-Z_]{2,}(SECRET|KEY|TOKEN|PASSWORD)[A-Z_]*=/.test(args) || /-----BEGIN/.test(args))
|
|
618
|
+
verdict = deny("Blocked: this MCP tool call would hand over secrets. Do not pass credentials to tools.");
|
|
619
|
+
else if (state.secrets && state.untrusted && /(^|\\n)[A-Z][A-Z0-9_]+=\S/.test(args)) verdict = deny("Blocked (strict mode): passing environment-style data to a tool after reading untrusted content.");
|
|
620
|
+
if (!verdict) state.untrusted = true;
|
|
621
|
+
}
|
|
622
|
+
return verdict;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// `crumple lock` pins the tools each MCP server offers. With a lock present, unknown tools are refused.
|
|
626
|
+
function mcpToolLocked(cwd, toolName) {
|
|
627
|
+
let lock;
|
|
628
|
+
try {
|
|
629
|
+
lock = JSON.parse(fs.readFileSync(path.join(cwd, ".crumple", "mcp.lock.json"), "utf8"));
|
|
630
|
+
} catch {
|
|
631
|
+
return true;
|
|
632
|
+
}
|
|
633
|
+
const m = toolName.match(/^mcp__(.+?)__(.+)$/);
|
|
634
|
+
if (!m) return true;
|
|
635
|
+
const srv = Object.entries(lock.servers ?? {}).find(([n]) => n === m[1] || n.replace(/[^\w-]/g, "_") === m[1])?.[1];
|
|
636
|
+
return Boolean(srv) && (Boolean(srv.error) || m[2] in (srv.tools ?? {}));
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// --- hook: session state and a tamper-evident audit log --------------------------------------
|
|
640
|
+
|
|
641
|
+
const crumpleHome = () => process.env.CRUMPLE_HOME ?? path.join(homeDir, ".crumple");
|
|
642
|
+
const redact = (s) => s.replace(new RegExp(SECRET_VALUES.source, "g"), "[secret]").slice(0, 300);
|
|
643
|
+
|
|
644
|
+
function audit(entry) {
|
|
645
|
+
try {
|
|
646
|
+
const file = path.join(crumpleHome(), "audit.jsonl");
|
|
647
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
648
|
+
let prev = "0".repeat(64);
|
|
649
|
+
try {
|
|
650
|
+
const tail = fs.readFileSync(file, "utf8").trimEnd().split("\n").pop();
|
|
651
|
+
prev = JSON.parse(tail).hash ?? prev;
|
|
652
|
+
} catch {}
|
|
653
|
+
const body = { ...entry, prev };
|
|
654
|
+
const hash = crypto.createHash("sha256").update(prev + JSON.stringify(body)).digest("hex");
|
|
655
|
+
fs.appendFileSync(file, JSON.stringify({ ...body, hash }) + "\n");
|
|
656
|
+
} catch {}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// Checks the audit log's hash chain. Returns { entries, brokenAt } (brokenAt is -1 when intact).
|
|
660
|
+
export function verifyAudit(file = path.join(crumpleHome(), "audit.jsonl")) {
|
|
661
|
+
const lines = fs.existsSync(file) ? fs.readFileSync(file, "utf8").trimEnd().split("\n").filter(Boolean) : [];
|
|
662
|
+
let prev = "0".repeat(64);
|
|
663
|
+
for (let i = 0; i < lines.length; i++) {
|
|
664
|
+
const { hash, ...body } = JSON.parse(lines[i]);
|
|
665
|
+
if (body.prev !== prev || crypto.createHash("sha256").update(prev + JSON.stringify(body)).digest("hex") !== hash) return { entries: lines.length, brokenAt: i };
|
|
666
|
+
prev = hash;
|
|
667
|
+
}
|
|
668
|
+
return { entries: lines.length, brokenAt: -1 };
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
export function runHook() {
|
|
672
|
+
let raw = "";
|
|
673
|
+
process.stdin.on("data", (d) => (raw += d));
|
|
674
|
+
process.stdin.on("end", () => {
|
|
675
|
+
let verdict;
|
|
676
|
+
let input = {};
|
|
677
|
+
try {
|
|
678
|
+
input = JSON.parse(raw);
|
|
679
|
+
const id = String(input.session_id ?? "default").replace(/[^\w-]/g, "").slice(0, 80) || "default";
|
|
680
|
+
const stateFile = path.join(crumpleHome(), "state", `${id}.json`);
|
|
681
|
+
let state = {};
|
|
682
|
+
try {
|
|
683
|
+
state = JSON.parse(fs.readFileSync(stateFile, "utf8"));
|
|
684
|
+
} catch {}
|
|
685
|
+
verdict = decide(input, state);
|
|
686
|
+
fs.mkdirSync(path.dirname(stateFile), { recursive: true });
|
|
687
|
+
fs.writeFileSync(stateFile, JSON.stringify(state));
|
|
688
|
+
} catch (e) {
|
|
689
|
+
verdict = deny(`Blocked: the Crumple guard could not check this call (${e.message}).`);
|
|
690
|
+
}
|
|
691
|
+
audit({ t: new Date().toISOString(), session: input.session_id, tool: input.tool_name, decision: verdict ? "deny" : "allow", reason: verdict?.reason, input: redact(JSON.stringify(input.tool_input ?? {})) });
|
|
692
|
+
if (verdict)
|
|
693
|
+
process.stdout.write(JSON.stringify({
|
|
694
|
+
hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: verdict.reason },
|
|
695
|
+
}));
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === fs.realpathSync(process.argv[1])) runHook();
|