Sign In

agent-guards

Package Overview
Dependencies
Maintainers
1
Versions
5
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

agent-guards - npm Package Compare versions

Comparing version
0.2.1
to
0.3.0
+457
-19
engines/shellcmd.js

@@ -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",