agent-guards
Advanced tools
+457
-19
@@ -29,2 +29,9 @@ // Reading a shell command well enough to know what it is about to install. | ||
| // Interpreters that run what arrives on stdin, including the version-suffixed spellings that a | ||
| // fixed list always misses: python3.12, node20, php8.2. | ||
| const INTERPRETER_PATTERN = /^(sh|bash|zsh|ksh|dash|fish|csh|tcsh|ash|busybox|python|perl|ruby|node|deno|bun|php|lua|tclsh|wish|rscript|osascript|pwsh|powershell|iex|invoke-expression|wsl)[\d.]*$/; | ||
| function isInterpreter(name) { | ||
| return SHELLS.has(name) || INTERPRETER_PATTERN.test(name); | ||
| } | ||
| // A heredoc is data for the command on the line above it, not another shell command. Claude Code | ||
@@ -46,2 +53,9 @@ // passes the whole Bash input to this parser, including heredoc bodies. Treating those bodies as | ||
| if (ch === '#' && (i === 0 || /\s/.test(line[i - 1]))) break; | ||
| // `$((1<<2))` is arithmetic, not a heredoc. Reading it as one made the delimiter `2))`, which | ||
| // never appeared again, so every following line was swallowed as a heredoc body. | ||
| if (ch === '$' && line[i + 1] === '(' && line[i + 2] === '(') { | ||
| const close = line.indexOf('))', i + 3); | ||
| i = close === -1 ? line.length : close + 1; | ||
| continue; | ||
| } | ||
| if (ch !== '<' || line[i + 1] !== '<' || line[i + 2] === '<') continue; | ||
@@ -55,8 +69,12 @@ | ||
| // The delimiter can be spelled with quotes or escapes anywhere in it: `<<E"OF"`, `<<EO\F` and | ||
| // `<<'EOF'` all end at a line reading EOF. Read the whole word, then take the quoting out. | ||
| let delimiter = ''; | ||
| const delimiterQuote = line[i] === '"' || line[i] === "'" ? line[i++] : null; | ||
| if (!delimiterQuote && line[i] === '\\') i++; | ||
| let inner = null; | ||
| while (i < line.length) { | ||
| const c = line[i]; | ||
| if (delimiterQuote ? c === delimiterQuote : /[\s;|&()<>]/.test(c)) break; | ||
| if (inner) { if (c === inner) inner = null; else delimiter += c; i++; continue; } | ||
| if (c === '"' || c === "'") { inner = c; i++; continue; } | ||
| if (c === '\\') { if (line[i + 1] !== undefined) delimiter += line[i + 1]; i += 2; continue; } | ||
| if (/[\s;|&()<>]/.test(c)) break; | ||
| delimiter += c; | ||
@@ -70,2 +88,22 @@ i++; | ||
| // A comment runs to the end of its line, and everything in it is prose. This matters more than it | ||
| // sounds: `# don't run this by hand` has an apostrophe in it, and without this the apostrophe opened | ||
| // a quote that swallowed every command after it, including a `curl … | bash` on the next line. | ||
| function stripComments(text) { | ||
| let out = ''; | ||
| let quote = null; | ||
| for (let i = 0; i < text.length; i++) { | ||
| const ch = text[i]; | ||
| if (quote) { out += ch; if (ch === quote) quote = null; continue; } | ||
| if (ch === "'" || ch === '"') { quote = ch; out += ch; continue; } | ||
| if (ch === '#' && (out === '' || /[\s;&|(]$/.test(out))) { | ||
| while (i < text.length && text[i] !== '\n') i++; | ||
| out += '\n'; | ||
| continue; | ||
| } | ||
| out += ch; | ||
| } | ||
| return out; | ||
| } | ||
| function stripHeredocBodies(command) { | ||
@@ -112,6 +150,224 @@ const lines = String(command || '').split('\n'); | ||
| // GuardFall (Adversa AI, 2026-06-30) named the structural mistake behind ten of eleven agent shell | ||
| // guards: the string the guard inspects is not the string the shell runs. bash expands, unquotes and | ||
| // rewrites first. Everything below closes that gap for the rewrites a shell performs | ||
| // deterministically, and reports what it cannot resolve instead of passing it. Nothing here | ||
| // evaluates, expands by running anything, or executes any part of a command: it is all text. | ||
| // | ||
| // $IFS is the field separator, so `curl${IFS}-sL${IFS}url` is three words by the time bash sees it. | ||
| // Single quotes suppress the expansion, so a literal ${IFS} inside them stays literal. | ||
| function expandIfs(text) { | ||
| let out = ''; | ||
| let quote = null; | ||
| for (let i = 0; i < text.length; i++) { | ||
| const ch = text[i]; | ||
| if (quote === "'") { out += ch; if (ch === "'") quote = null; continue; } | ||
| if (quote === '"') { out += ch; if (ch === '"') quote = null; continue; } | ||
| if (ch === "'" || ch === '"') { quote = ch; out += ch; continue; } | ||
| if (ch === '$') { | ||
| const m = /^\$\{IFS\}|^\$IFS(?![A-Za-z0-9_])/.exec(text.slice(i)); | ||
| if (m) { out += ' '; i += m[0].length - 1; continue; } | ||
| } | ||
| out += ch; | ||
| } | ||
| return out; | ||
| } | ||
| // A command name can be spelled `bash`, `b"a"sh`, `b\ash` or `/usr/bin/bash`. Quotes are already | ||
| // gone by the time a token arrives here. Path-stripping and backslash-unescaping disagree about | ||
| // `b\ash` (one reads the backslash as a directory separator, the other as an escape), so both | ||
| // readings are returned and a match on either counts. Guessing wrong in the other direction would | ||
| // mean missing a real one. | ||
| function binaryNames(token) { | ||
| const raw = String(token); | ||
| const clean = (s) => s.replace(/\.(exe|cmd|bat|ps1)$/i, '').toLowerCase(); | ||
| const asPath = clean(raw.replace(/^.*[\\/]/, '')); | ||
| const asEscaped = clean(raw.replace(/\\(.)/g, '$1').replace(/^.*[\\/]/, '')); | ||
| return [...new Set([asPath, asEscaped])].filter(Boolean); | ||
| } | ||
| // Words that stand in front of the command without being the command. `sudo bash`, `then curl …` | ||
| // inside an if, `( bash )`, `timeout 30 bash`, `xargs -0 bash -c`: in every one of them the thing | ||
| // that runs is further along the line, and reading the first word gets the wrong answer. | ||
| const GROUPING = new Set([ | ||
| 'if', 'then', 'else', 'elif', 'fi', 'do', 'done', 'while', 'until', 'for', 'case', 'esac', | ||
| 'time', '!', '{', '}', '(', ')', | ||
| ]); | ||
| const WRAPPERS = new Set([ | ||
| 'sudo', 'doas', 'env', 'nohup', 'setsid', 'stdbuf', 'nice', 'ionice', 'timeout', 'command', | ||
| 'builtin', 'exec', 'xargs', 'script', 'unbuffer', 'chroot', 'runuser', 'su', | ||
| ]); | ||
| // `FOO=1 sudo -E timeout 30 bash` runs bash. Everything in front of it comes off first, or a | ||
| // leading assignment, keyword or wrapper hides the command from every check below. | ||
| function effectiveCommand(stage) { | ||
| let text = String(stage).trim().replace(/^[\s(){]+/, '').replace(/[\s;&]+$/, ''); | ||
| // Strip a trailing `)` or `}` only when it closes something this text never opened, or `${X}` | ||
| // loses its brace and stops looking like an expansion. | ||
| for (;;) { | ||
| const last = text[text.length - 1]; | ||
| if (last !== ')' && last !== '}') break; | ||
| const opens = (text.match(last === ')' ? /\(/g : /\{/g) || []).length; | ||
| const closes = (text.match(last === ')' ? /\)/g : /\}/g) || []).length; | ||
| if (closes <= opens) break; | ||
| text = text.slice(0, -1).replace(/[\s;&]+$/, ''); | ||
| } | ||
| let toks = tokenize(text); | ||
| for (let guard = 0; guard < 20 && toks.length; guard++) { | ||
| const head = binaryNames(toks[0])[0] || ''; | ||
| if (GROUPING.has(head) || head === 'export') { toks = toks.slice(1); continue; } | ||
| if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(toks[0])) { toks = toks.slice(1); continue; } | ||
| if (WRAPPERS.has(head)) { | ||
| toks = toks.slice(1); | ||
| // A wrapper's own flags, its numeric argument (`timeout 30`) and any environment it sets are | ||
| // not the command either. | ||
| while (toks.length && (/^-/.test(toks[0]) || /^\d+(\.\d+)?[smhd]?$/.test(toks[0]) || /^[A-Za-z_][A-Za-z0-9_]*=/.test(toks[0]))) { | ||
| toks = toks.slice(1); | ||
| } | ||
| continue; | ||
| } | ||
| break; | ||
| } | ||
| return toks.join(' ').trim(); | ||
| } | ||
| // `ssh host "cat setup.sh" | bash` is the same shape as `curl … | bash`: bytes from another machine, | ||
| // executed here, unread. A local file piped to a shell (`cat deploy.sh | bash`) is deliberately not | ||
| // on this list, because it cannot be told apart from running your own script. | ||
| // The boundary has to exclude a hyphen or `ssh-agent` reads as `ssh`, which made | ||
| // `eval "$(ssh-agent -s)"` look like a download. | ||
| // Names, not a regex over raw text: the fetcher side has to be read exactly the way the target side | ||
| // is, or `"curl" … | bash` and `C=curl; $C … | bash` slip past while `curl … | "bash"` is caught. | ||
| const FETCHER_NAMES = new Set([ | ||
| 'curl', 'wget', 'iwr', 'invoke-webrequest', 'fetch', 'aria2c', 'httpie', 'http', 'ssh', | ||
| ]); | ||
| // Builtins that run whatever their argument turns out to be. | ||
| const EVAL_BUILTINS = new Set(['eval', 'source', '.', 'iex', 'invoke-expression']); | ||
| // Bytes can reach an interpreter without a pipe: `bash <(curl …)`, `source <(curl …)`, | ||
| // `bash <<< "$(curl …)"`. The download still runs. Only a fetch or a decode inside the substitution | ||
| // counts, so `diff <(sort a) <(sort b)` and `bash -c "echo $(date)"` stay quiet. | ||
| // `curl … > >(bash)` and `curl … | tee >(bash)` send the download into an interpreter through an | ||
| // output substitution rather than a pipe. Same bytes, same execution. | ||
| function outputSubstitutionInterpreter(stage) { | ||
| for (const m of String(stage).matchAll(/>\s*\(([^()]*)\)/g)) { | ||
| const first = tokenize(effectiveCommand(m[1] || ''))[0]; | ||
| if (!first) continue; | ||
| const name = binaryNames(first).find((n) => isInterpreter(n) || EVAL_BUILTINS.has(n)); | ||
| if (name) return name; | ||
| } | ||
| return null; | ||
| } | ||
| function embeddedSource(stage) { | ||
| for (const m of String(stage).matchAll(/<\(([^()]*)\)|\$\(([^()]*)\)|`([^`]*)`/g)) { | ||
| const inner = effectiveCommand(m[1] || m[2] || m[3] || ''); | ||
| if (!inner) continue; | ||
| if (binaryNames(tokenize(inner)[0] || '').some((n) => FETCHER_NAMES.has(n))) return 'fetch'; | ||
| if (isDecoder(inner)) return 'decode'; | ||
| } | ||
| return null; | ||
| } | ||
| // A sentinel that cannot occur in a command, marking the spot where an expansion could not be | ||
| // resolved. Written as an escape so the source stays plain text. | ||
| const UNRESOLVED_MARK = String.fromCharCode(0); | ||
| // What sits in an execution position: a name, something built by another command, or a variable | ||
| // whose value is not knowable from the text. The last two are the honest part. A command | ||
| // substitution can produce anything, so no static reading of it is possible, and saying so is the | ||
| // only correct answer. | ||
| function classifyBinary(stage, env) { | ||
| const text = effectiveCommand(stage); | ||
| if (!text) return null; | ||
| if (/^\$\(|^`/.test(text)) return { dynamic: true }; | ||
| const toks = tokenize(text); | ||
| const first = toks[0]; | ||
| if (!first) return null; | ||
| if (/\$\(|`/.test(first)) return { dynamic: true }; | ||
| // An expansion does not have to be the whole word: `${X}sh`, `b${Z}ash` and `${UNSET:-bash}` all | ||
| // produce a command name. Only the plain $VAR and ${VAR} spellings can be read from an assignment | ||
| // in the same command line. Every other form (`${VAR:-default}`, `${VAR#trim}`, `${!indirect}`, | ||
| // `$((…))`) is computed while the shell runs, so it is reported rather than guessed at. | ||
| if (first.includes('$')) { | ||
| let firstUnknown = null; | ||
| const resolved = first.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g, (m, braced, bare) => { | ||
| const name = braced || bare; | ||
| const value = Object.prototype.hasOwnProperty.call(env, name) ? env[name] : undefined; | ||
| if (typeof value === 'string' && value) return value; | ||
| if (!firstUnknown) firstUnknown = name; | ||
| return UNRESOLVED_MARK; | ||
| }); | ||
| if (resolved.includes(UNRESOLVED_MARK) || resolved.includes('$')) { | ||
| return { unresolved: firstUnknown || first, spelling: first }; | ||
| } | ||
| return { names: binaryNames(resolved), from: first }; | ||
| } | ||
| return { names: binaryNames(first) }; | ||
| } | ||
| // `X=bash` then `… | $X` is two pipelines, so assignments are collected across the whole command | ||
| // line rather than per stage. A value that is itself computed is recorded as unknown, which sends it | ||
| // down the unresolved path instead of inventing an answer. | ||
| function collectAssignments(stage, env) { | ||
| const toks = tokenize(stage); | ||
| let i = toks[0] === 'export' ? 1 : 0; | ||
| for (; i < toks.length; i++) { | ||
| const m = /^([A-Za-z_][A-Za-z0-9_]*)=([\s\S]*)$/.exec(toks[i]); | ||
| if (!m) break; | ||
| env[m[1]] = /[$`]/.test(m[2]) ? null : m[2]; | ||
| } | ||
| } | ||
| // Decoding a blob and running the result is the same shape as downloading and running it, and the | ||
| // blob does not have to arrive over the network. | ||
| // A flag of null means the command only ever decompresses, so its presence is enough. Compression is | ||
| // decoding: `zcat payload.gz | bash` hides what runs exactly as well as base64 does, and it does not | ||
| // need the network to get the blob onto the machine. | ||
| const DECODERS = [ | ||
| { bin: 'base64', flag: /^--?[A-Za-z]*[dD][A-Za-z]*$|^--decode$/ }, | ||
| { bin: 'base32', flag: /^--?[A-Za-z]*[dD][A-Za-z]*$|^--decode$/ }, | ||
| { bin: 'xxd', flag: /^-r$/ }, | ||
| { bin: 'openssl', flag: /^-d$/ }, | ||
| { bin: 'uudecode', flag: null }, | ||
| { bin: 'basenc', flag: /^--?[A-Za-z]*[dD][A-Za-z]*$|^--decode$/ }, | ||
| { bin: 'gpg', flag: /^-[a-z]*d[a-z]*$|^--decrypt$/ }, | ||
| { bin: 'gpg2', flag: /^-[a-z]*d[a-z]*$|^--decrypt$/ }, | ||
| { bin: 'gzip', flag: /^-[a-z]*d[a-z]*$|^--decompress$|^--uncompress$/ }, | ||
| { bin: 'gunzip', flag: null }, | ||
| { bin: 'zcat', flag: null }, | ||
| { bin: 'xz', flag: /^-[a-z]*d[a-z]*$|^--decompress$/ }, | ||
| { bin: 'unxz', flag: null }, | ||
| { bin: 'xzcat', flag: null }, | ||
| { bin: 'lzcat', flag: null }, | ||
| { bin: 'bzip2', flag: /^-[a-z]*d[a-z]*$|^--decompress$/ }, | ||
| { bin: 'bunzip2', flag: null }, | ||
| { bin: 'bzcat', flag: null }, | ||
| { bin: 'zstd', flag: /^-[a-z]*d[a-z]*$|^--decompress$/ }, | ||
| { bin: 'unzstd', flag: null }, | ||
| { bin: 'zstdcat', flag: null }, | ||
| { bin: 'lz4', flag: /^-[a-z]*d[a-z]*$|^--decompress$/ }, | ||
| { bin: 'brotli', flag: /^-[a-z]*d[a-z]*$|^--decompress$/ }, | ||
| // Extraction only counts when the archive is written to stdout, which is what feeds a pipe. | ||
| { bin: 'tar', flag: /^-[A-Za-z]*O[A-Za-z]*$|^--to-stdout$/ }, | ||
| { bin: 'unzip', flag: /^-[a-z]*p[a-z]*$/ }, | ||
| { bin: '7z', flag: /^-so$/ }, | ||
| { bin: '7za', flag: /^-so$/ }, | ||
| ]; | ||
| function isDecoder(stage) { | ||
| const toks = tokenize(effectiveCommand(stage)); | ||
| if (!toks.length) return false; | ||
| const names = binaryNames(toks[0]); | ||
| const d = DECODERS.find((x) => names.includes(x.bin)); | ||
| if (!d) return false; | ||
| if (!d.flag) return true; | ||
| return toks.slice(1).some((t) => d.flag.test(t)); | ||
| } | ||
| // Split a command line into pipelines, then each pipeline into its stages. Operators inside quotes | ||
| // are left alone; the scan tracks quoting as it goes. | ||
| function split(command) { | ||
| const text = stripHeredocBodies(command); | ||
| const text = expandIfs(stripComments(stripHeredocBodies(command))); | ||
| const pipelines = []; | ||
@@ -126,2 +382,15 @@ let cur = ''; | ||
| if (two === '&&' || two === '||') { pipelines.push(cur); cur = ''; i++; continue; } | ||
| // `|&` pipes stderr as well as stdout, and it is still a pipe. | ||
| if (two === '|&') { cur += '|'; i++; continue; } | ||
| // A lone `&` backgrounds what came before it and starts a new command, so `true & curl … | bash` | ||
| // is two commands, not one long one. | ||
| if (ch === '&') { pipelines.push(cur); cur = ''; continue; } | ||
| // A newline does not always end a command. A trailing backslash escapes it, and a line ending in | ||
| // `|` leaves the pipeline open. Both are ordinary README formatting, and treating them as | ||
| // terminators put the fetch and the shell in separate pipelines where nothing compared them. | ||
| if (ch === '\n') { | ||
| const trimmed = cur.replace(/[ \t\r]+$/, ''); | ||
| if (trimmed.endsWith('\\')) { cur = `${trimmed.slice(0, -1)} `; continue; } | ||
| if (trimmed.endsWith('|')) { cur = `${trimmed} `; continue; } | ||
| } | ||
| if (ch === ';' || ch === '\n') { pipelines.push(cur); cur = ''; continue; } | ||
@@ -247,23 +516,188 @@ cur += ch; | ||
| // Interpreters that can take their program as an argument instead of reading it from stdin. Keyed by | ||
| // the flag that carries the program. | ||
| const INLINE_PROGRAM_FLAGS = { | ||
| python: ['-c'], python3: ['-c'], perl: ['-e'], ruby: ['-e'], | ||
| node: ['-e', '-p', '--eval', '--print'], | ||
| sh: ['-c'], bash: ['-c'], zsh: ['-c'], ksh: ['-c'], dash: ['-c'], fish: ['-c'], csh: ['-c'], tcsh: ['-c'], | ||
| }; | ||
| // Modules that read stdin as a program rather than as data, so `-m` is not a promise of safety for | ||
| // these the way it is for `json.tool`. | ||
| const STDIN_RUNNING_MODULES = new Set(['code', 'pdb', 'runpy', 'idlelib', 'asyncio', 'timeit', 'py_compile', 'compileall']); | ||
| // Returns the program text when the interpreter was handed one on the command line, otherwise null. | ||
| // `python -` and a bare `python` both read the program from stdin, and `bash -s` does too, so those | ||
| // return null and stay on the risky path. | ||
| function inlineProgram(tokens, bare) { | ||
| const flags = INLINE_PROGRAM_FLAGS[bare]; | ||
| if (!flags) return null; | ||
| for (let i = 1; i < tokens.length; i++) { | ||
| const tok = String(tokens[i]); | ||
| if (tok === '-') return null; | ||
| // `python -m json.tool` runs a module that is already on the machine and reads the pipe as data, | ||
| // which is one of the most common shapes there is. The handful of modules that do execute stdin | ||
| // are named above and stay on the risky path. | ||
| if (tok === '-m' && /^python/.test(bare)) { | ||
| const mod = String(tokens[i + 1] || ''); | ||
| if (!mod) return null; | ||
| return STDIN_RUNNING_MODULES.has(mod.split('.')[0]) ? null : `module:${mod}`; | ||
| } | ||
| // `bash -c` with nothing after it is not an inline program. With xargs the piped data becomes | ||
| // that argument, so this must not read as an exemption. | ||
| if (flags.includes(tok)) return tokens[i + 1] === undefined ? null : String(tokens[i + 1]); | ||
| } | ||
| return null; | ||
| } | ||
| // An inline script can still read stdin and execute it, which is the original shape wearing a hat. | ||
| // Only the combination counts: reading stdin is ordinary, executing what it read is not. | ||
| function inlineProgramRunsStdin(src) { | ||
| if (!src) return false; | ||
| // `open(0)`, `/dev/fd/0`, `$(cat)` and perl's `<>` are all spellings of "read what was piped in", | ||
| // and each one showed up in a real attempt to get around the exemption. | ||
| const readsStdin = /stdin|\/dev\/stdin|\/dev\/fd\/0|readFileSync\s*\(\s*0|open\s*\(\s*0|\bARGF\b|\bfileinput\b|\$\(\s*cat\s*\)|`\s*cat\s*`|<>|\bcat\b|\binput\s*\(/i.test(src); | ||
| if (!readsStdin) return false; | ||
| return /\b(exec|eval|execfile|compile|Function|system|instance_eval|source|subprocess|popen|spawn|do)\b|^\s*\.\s/i.test(src); | ||
| } | ||
| // `sh -c 'curl … | bash'` hides a whole command line inside an argument. The text is right there, so | ||
| // it gets read the same way the outer one does. One level only: the point is to look inside a quoted | ||
| // program, not to chase an unbounded chain. | ||
| let nestingDepth = 0; | ||
| function nestedRisk(program) { | ||
| if (!program || nestingDepth > 0) return false; | ||
| if (!/[|;&]|<\(|\$\(|`/.test(program)) return false; | ||
| nestingDepth += 1; | ||
| try { | ||
| return parse(program).risky.length > 0; | ||
| } catch { | ||
| return false; | ||
| } finally { | ||
| nestingDepth -= 1; | ||
| } | ||
| } | ||
| // Command shapes that are worth a word regardless of any package name. Both of these hand control of | ||
| // the machine to whatever a server returns, which is not something to notice after the fact. | ||
| function riskyPatterns(pipeline) { | ||
| function riskyPatterns(pipeline, env = Object.create(null)) { | ||
| const out = []; | ||
| const stages = pipeline.stages; | ||
| const fetchers = /^(curl|wget|iwr|invoke-webrequest|fetch)\b/i; | ||
| for (let i = 0; i < stages.length - 1; i++) { | ||
| if (!fetchers.test(stages[i].trim().replace(/^\S*[\\/]/, ''))) continue; | ||
| const nextBin = tokenize(stages[i + 1])[0]; | ||
| if (!nextBin) continue; | ||
| const bare = String(nextBin).replace(/^.*[\\/]/, '').replace(/\.(exe|cmd|bat|ps1)$/i, '').toLowerCase(); | ||
| if (SHELLS.has(bare)) { | ||
| out.push({ | ||
| id: 'cmd-remote-to-shell', | ||
| severity: 'medium', | ||
| message: `This downloads something and runs it immediately (\`${stages[i].trim().slice(0, 60)} | ${bare}\`). Whatever that URL returns at the moment it is fetched gets executed, and nothing here has read it.`, | ||
| raw: pipeline.raw, | ||
| }); | ||
| const add = (id, message) => { out.push({ id, severity: 'medium', message, raw: pipeline.raw }); }; | ||
| // No pipe needed: `bash <(curl …)`, `source <(curl …)` and `bash <<< "$(curl …)"` hand the same | ||
| // bytes to the same interpreter through a file descriptor instead. | ||
| for (const stage of stages) { | ||
| if (out.length) break; | ||
| const target = classifyBinary(stage, env); | ||
| // `curl … > >(bash)` is one stage, and the interpreter is inside the redirect rather than at the | ||
| // far end of a pipe, so this runs before the check for what the stage itself is. | ||
| const outTarget = outputSubstitutionInterpreter(stage); | ||
| if (outTarget) { | ||
| const producerNames = (target && target.names) || []; | ||
| const isFetch = producerNames.some((n) => FETCHER_NAMES.has(n)); | ||
| if (isFetch || isDecoder(effectiveCommand(stage))) { | ||
| add(isFetch ? 'cmd-remote-to-shell' : 'cmd-decode-to-shell', `This sends what it ${isFetch ? 'downloads' : 'decodes'} straight into \`${outTarget}\` through an output substitution (\`${stage.trim().slice(0, 50)}\`), which runs it without a pipe ever appearing.`); | ||
| continue; | ||
| } | ||
| } | ||
| const name = target && target.names && target.names.find((n) => isInterpreter(n) || EVAL_BUILTINS.has(n)); | ||
| if (!name) continue; | ||
| // `sh -c 'curl … | bash'` carries a whole command line as an argument. Read it. | ||
| const carried = inlineProgram(tokenize(stage), name); | ||
| if (carried && nestedRisk(carried)) { | ||
| add('cmd-remote-to-shell', `This runs a command line handed to \`${name}\` as an argument (\`${String(carried).slice(0, 50)}\`), and that command line downloads or decodes something and runs it.`); | ||
| continue; | ||
| } | ||
| const embedded = embeddedSource(stage); | ||
| if (!embedded) continue; | ||
| if (embedded === 'fetch') { | ||
| add('cmd-remote-to-shell', `This downloads something and runs it immediately (\`${stage.trim().slice(0, 60)}\`). The download reaches ${name} through a file descriptor rather than a pipe, and nothing here has read it.`); | ||
| } else { | ||
| add('cmd-decode-to-shell', `This decodes something and runs the result (\`${stage.trim().slice(0, 60)}\`). Decoding first hides what runs from anything that reads the command, including this check.`); | ||
| } | ||
| } | ||
| // A source stage is one that brings in bytes nobody here has read: fetched from a URL, or decoded | ||
| // from a blob. Any interpreter downstream of one is running those bytes, whether the pipe is | ||
| // direct or has something in between. | ||
| for (let i = 0; i < stages.length - 1 && !out.length; i++) { | ||
| const source = effectiveCommand(stages[i]); | ||
| // The producing side goes through the same reading as the receiving side. Anything less makes | ||
| // the quoting and variable work above apply to only half the pipe. | ||
| const producer = classifyBinary(stages[i], env); | ||
| const fetched = !!(producer && (producer.names || []).some((n) => FETCHER_NAMES.has(n))); | ||
| const decoded = isDecoder(source); | ||
| const producerUnknown = !!(producer && (producer.dynamic || producer.unresolved)); | ||
| if (!fetched && !decoded && !producerUnknown) continue; | ||
| // The interpreter can be on the other side of a `>(…)` instead of downstream of the pipe. | ||
| const viaOutput = outputSubstitutionInterpreter(stages[i]) || (stages[i + 1] && outputSubstitutionInterpreter(stages[i + 1])); | ||
| if (viaOutput) { | ||
| add(fetched ? 'cmd-remote-to-shell' : 'cmd-decode-to-shell', `This sends what it ${fetched ? 'downloads' : 'decodes'} straight into \`${viaOutput}\` through an output substitution (\`${stages[i].trim().slice(0, 50)}\`), which runs it without a pipe ever appearing.`); | ||
| break; | ||
| } | ||
| for (let j = i + 1; j < stages.length; j++) { | ||
| const target = classifyBinary(stages[j], env); | ||
| if (!target) continue; | ||
| // An unreadable producer only matters when something runs what it produces. `$X | jq .` is | ||
| // nobody's business; `$X | bash` is. | ||
| if (producerUnknown) { | ||
| const runs = (target.names || []).find((n) => isInterpreter(n)); | ||
| if (!runs) continue; | ||
| const inlineHere = inlineProgram(tokenize(stages[j]), runs); | ||
| if (inlineHere !== null && !inlineProgramRunsStdin(inlineHere)) continue; | ||
| add('cmd-unresolved-exec', `What feeds this pipe is \`${stages[i].trim().slice(0, 40)}\`, which is built while the command runs, and \`${runs}\` executes whatever it produces. Neither half can be read beforehand, so this check cannot clear it either way.`); | ||
| break; | ||
| } | ||
| if (target.dynamic) { | ||
| add('cmd-dynamic-exec', `The command on the receiving end of this pipe is built by another command (\`${stages[j].trim().slice(0, 40)}\`), so what actually runs cannot be read before it runs. This check cannot clear it either way.`); | ||
| break; | ||
| } | ||
| if (target.unresolved) { | ||
| add('cmd-unresolved-exec', `The receiving end of this pipe is \`${target.spelling || '$' + target.unresolved}\`, and what it expands to is not knowable from this command, so what runs here is unknown. This check cannot clear it either way.`); | ||
| break; | ||
| } | ||
| // A hook runs this on every Bash call, so an unexpected shape reports nothing rather than | ||
| // throwing and taking the tool call down with it. | ||
| const shell = (target.names || []).find((n) => isInterpreter(n)); | ||
| if (!shell) continue; | ||
| // `… | python -c '<script>'` runs the script written right there and reads the pipe as data. | ||
| // That is a pipe into a parser, not a download being executed. | ||
| const inline = inlineProgram(tokenize(stages[j]), shell); | ||
| if (inline !== null && !inlineProgramRunsStdin(inline) && !nestedRisk(inline)) continue; | ||
| const spelling = target.from ? `${shell} (via ${target.from})` : shell; | ||
| if (fetched) { | ||
| add('cmd-remote-to-shell', `This downloads something and runs it immediately (\`${source.slice(0, 60)} | ${spelling}\`). Whatever that URL returns at the moment it is fetched gets executed, and nothing here has read it.`); | ||
| } else { | ||
| add('cmd-decode-to-shell', `This decodes something and runs the result (\`${source.slice(0, 40)} | ${spelling}\`). Decoding first hides what runs from anything that reads the command, including this check.`); | ||
| } | ||
| break; | ||
| } | ||
| } | ||
| // `eval` and `source` run whatever their argument turns out to be. The common shells-init idiom | ||
| // (`eval "$(pyenv init -)"`) is the same shape as the dangerous one, so the generators that | ||
| // everyone actually uses are named here rather than warned about forever. | ||
| const EVALS = new Set(['eval', 'source', '.', 'iex', 'invoke-expression']); | ||
| const KNOWN_INIT = /\b(ssh-agent|pyenv|rbenv|nodenv|jenv|goenv|direnv|starship|zoxide|fnm|mise|asdf|brew|conda|micromamba|thefuck|atuin|navi|oh-my-posh|keychain|gpg-agent)\b/; | ||
| for (const stage of stages) { | ||
| if (out.length) break; | ||
| const toks = tokenize(stage); | ||
| if (!toks.length) continue; | ||
| if (!binaryNames(toks[0]).some((n) => EVALS.has(n))) continue; | ||
| const argText = stage.trim().slice(toks[0].length); | ||
| if (!/\$\(|`|\$\{?[A-Za-z_]/.test(argText)) continue; | ||
| if (KNOWN_INIT.test(argText)) continue; | ||
| add('cmd-dynamic-exec', `\`${binaryNames(toks[0])[0]}\` here runs text that is produced when the command runs, so what executes cannot be read beforehand. This check cannot clear it either way.`); | ||
| } | ||
| // The PowerShell spelling has no pipe: iwr … | iex is covered above, but `iex (iwr …)` is not. | ||
@@ -285,3 +719,7 @@ if (/\b(iex|invoke-expression)\b[^|]*\b(iwr|invoke-webrequest|curl|wget|downloadstring)\b/i.test(pipeline.raw)) { | ||
| const risky = []; | ||
| // Assignments carry forward across pipelines, so the map is built as the command line is walked | ||
| // rather than per pipeline. | ||
| const env = Object.create(null); | ||
| for (const p of pipelines) { | ||
| for (const stage of p.stages) collectAssignments(stage, env); | ||
| for (const stage of p.stages) { | ||
@@ -291,3 +729,3 @@ const read = readStage(stage); | ||
| } | ||
| risky.push(...riskyPatterns(p)); | ||
| risky.push(...riskyPatterns(p, env)); | ||
| } | ||
@@ -294,0 +732,0 @@ // The same shape appearing twice in one command line is one thing to say, not two. |
+1
-1
| { | ||
| "name": "agent-guards", | ||
| "version": "0.2.1", | ||
| "version": "0.3.0", | ||
| "description": "Deterministic local security engines for AI agents: prompt-injection and obfuscation scanning, secret detection and redaction, code scanning, package reputation, email analysis, payment screening, and an SSRF-safe fetch.", | ||
@@ -5,0 +5,0 @@ "main": "index.js", |
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
399948
6.21%6784
6.35%