Tutorials
Ren Okabe8 min read22 views

Argument injection: your AI agent's allowlisted shell tool is not safe (Node)

You gave your agent a shell tool, used execFile so there is no shell, and allowlisted the programs. It is still not safe. If the agent controls the argument array, tar's own --checkpoint-action=exec runs any command with no metacharacters. Here is the reproduction and the per-binary argument policy that stops it, measured on Node 24.8.0.

Flat printed schematic in muted slate ink on off-white: a small allowlist box on the left, an arrow passing a vertical stack of argument tokens with one amber token into a larger command-line binary box on the right, illustrating that a safe program can still be handed a dangerous flag.
Flat printed schematic in muted slate ink on off-white: a small allowlist box on the left, an arrow passing a vertical stack of argument tokens with one amber token into a larger command-line binary box on the right, illustrating that a safe program can still be handed a dangerous flag.
On this page

Quick answer (2026): An allowlist of programs does not make an AI agent's shell tool safe. If the agent controls the argument array, it can hand a safe, allowlisted binary a dangerous flag. tar given --checkpoint-action=exec=... runs an arbitrary command with no shell and no metacharacters, so a metacharacter blocklist waves it straight through. Every measurement below is from Node 24.8.0 with the standard library only, no API key and no npm install. The fix is a per-binary argument policy that treats every dash-led token as an option to be explicitly allowed, run under a scrubbed environment and a hard output cap.

The tool everyone builds first

Give an agent a shell and it can do real work: list an archive, grep a log, read a git status. The advice you will find everywhere, correctly, is never use a shell: call the binary directly with an argument array so shell metacharacters like ; and | mean nothing. In Node that is execFileSync(program, args) rather than execSync(oneBigString).

The second piece of advice is allowlist the programs, so the agent cannot run bash or curl. Put the two together and you get the guard almost everyone writes:

javascript
// naive.js - the guard almost everyone writes for an "allowlisted binary" agent shell tool.
const { execFileSync } = require("node:child_process");

const ALLOWED = new Set(["tar", "git", "grep", "wc", "head"]);

// The agent hands us a program name and an array of arguments.
// We refuse any program not on the allowlist, and we never use a shell.
function runTool(program, args) {
  if (!ALLOWED.has(program)) {
    throw new Error("Refused: '" + program + "' is not an allowed program");
  }
  return execFileSync(program, args, { encoding: "utf8" });
}

module.exports = { runTool, ALLOWED };

It refuses a program that is not on the list, it uses no shell, and it looks finished. Here is what it actually does:

javascript
const fs = require("node:fs");
const { runTool } = require("./naive.js");

fs.writeFileSync("report.txt", "line one\nline two\nline three\n");
fs.rmSync("PWNED", { force: true });

function attempt(label, program, args) {
  try {
    const out = runTool(program, args);
    console.log(label + " ALLOWED: " + JSON.stringify(out.split("\n")[0]));
  } catch (e) {
    console.log(label + " REFUSED: " + e.message.split("\n")[0]);
  }
}

// 1 A program that is not on the list is refused. The allowlist does its stated job.
attempt("1 disallowed program ", "bash", ["-c", "id"]);

// 2 A normal call to an allowed program works.
attempt("2 honest tar call    ", "tar", ["-cf", "/dev/null", "report.txt"]);

// 3 The attack: every token below is an ARGUMENT to the allowed program 'tar'.
//   No shell, no metacharacters, nothing the allowlist inspects.
attempt("3 argument injection ", "tar", [
  "-cf", "/dev/null",
  "--checkpoint=1",
  "--checkpoint-action=exec=/usr/bin/touch PWNED",
  "report.txt",
]);
console.log("   PWNED file created:", fs.existsSync("PWNED"));
text
1 disallowed program  REFUSED: Refused: 'bash' is not an allowed program
2 honest tar call     ALLOWED: ""
3 argument injection  ALLOWED: ""
   PWNED file created: true

Line 1 is the allowlist doing its stated job. Line 3 is the problem. Every token in that call is an argument to tar, which is on the allowlist. There is no shell, no ;, no |, nothing the guard inspects, and a file named PWNED appears in the working directory. The agent just ran /usr/bin/touch PWNED through a tool whose only job was to list and create archives.

Why a safe binary runs your command

tar has a feature, documented in the GNU tar manual, for running a command at checkpoints during a long operation: the exec action executes a given external command ... The supplied command can be any valid command invocation. Written as --checkpoint-action=exec=CMD, that is a normal, valid tar option. Passing it in an argument array is not exploiting a shell. It is asking tar to do exactly what tar documents.

This is the class the MITRE catalogue calls CWE-88, Improper Neutralization of Argument Delimiters in a Command, argument injection, and the technique against tar specifically is old and well documented in offensive-security writeups such as Etienne Stalmans' 2019 note on argument injection. What is new is only the delivery: the attacker is now the model choosing the argument array, and the target is the tool wrapper you wrote for it. The binary is not the vulnerability. The binary is a second interpreter sitting behind your guard, and it has its own grammar of dangerous flags. git has -c and --upload-pack. find has -exec. sed and awk have code arguments. tar has checkpoints.

The fix that does not fix it

The reflex, once you have seen a shell injection, is to scrub shell metacharacters out of every argument. That is aimed at the wrong threat:

javascript
// blocklist.js - the reflexive "fix": reject any argument containing a shell metacharacter.
// It aims at the WRONG threat. Argument injection uses no metacharacters at all, so this
// passes the exploit while adding friction to honest calls. The bad set is built from
// character codes (so it includes the redirection operators, codes 60 and 62) rather than
// literals, but the specific bytes do not matter: the attack contains none of them.
const { execFileSync } = require("node:child_process");

const ALLOWED = new Set(["tar", "git", "grep", "wc", "head"]);
// ; & | backtick $ ( ) { } redirect-in redirect-out backslash ! * ? ~ newline
const BAD_CODES = new Set([59, 38, 124, 96, 36, 40, 41, 123, 125, 60, 62, 92, 33, 42, 63, 126, 10]);

function hasBad(s) {
  for (const ch of s) {
    if (BAD_CODES.has(ch.charCodeAt(0))) return true;
  }
  return false;
}

function runTool(program, args) {
  if (!ALLOWED.has(program)) throw new Error("Refused: program not allowed");
  for (const a of args) {
    if (hasBad(a)) throw new Error("Refused: argument contains a shell metacharacter");
  }
  return execFileSync(program, args, { encoding: "utf8" });
}
module.exports = { runTool };
javascript
const fs = require("node:fs");
const { runTool } = require("./blocklist.js");
fs.writeFileSync("report.txt", "a\nb\nc\n");
fs.rmSync("PWNED2", { force: true });

function attempt(label, program, args) {
  try { const out = runTool(program, args); console.log(label + " ALLOWED: " + JSON.stringify(out.split("\n")[0])); }
  catch (e) { console.log(label + " REFUSED: " + e.message.split("\n")[0]); }
}

// The exploit argument contains NONE of the blocked characters:
//   --checkpoint-action=exec=/usr/bin/touch PWNED2
// Only letters, digits, '-', '=', '/', '.', and a space. The blocklist passes it.
attempt("metachar blocklist  ", "tar", [
  "-cf", "/dev/null", "--checkpoint=1",
  "--checkpoint-action=exec=/usr/bin/touch PWNED2", "report.txt",
]);
console.log("   PWNED2 file created:", fs.existsSync("PWNED2"));

// A second, unrelated program with the same class of flag: git's -c runs config-driven code paths,
// and git aliases can execute shell. The point is that the DANGER lives in the flag grammar,
// not in any character a blocklist can see.
attempt("git config flag     ", "git", ["-c", "core.pager=id", "--version"]);
text
metachar blocklist   ALLOWED: ""
   PWNED2 file created: true
git config flag      ALLOWED: "git version 2.47.3"

The exploit argument is --checkpoint-action=exec=/usr/bin/touch PWNED2: letters, digits, -, =, /, . and a space. It contains none of the blocked characters, because argument injection never needed them. The blocklist adds friction to honest filenames and stops nothing. The second line, git -c core.pager=id, makes the same point on a different binary: the danger lives in the flag grammar, not in any byte a blocklist can see.

What about the double-dash end-of-options separator?

The standard advice for the operand case, a filename that begins with -, is to insert -- so everything after it is treated as a positional operand. It genuinely helps there, and it genuinely does not save you here. Measured:

javascript
const { execFileSync } = require("node:child_process");
const fs = require("node:fs");
fs.rmSync("PWNM", { force: true });
fs.rmSync("PWNM2", { force: true });

function attempt(label, fn) {
  try { const out = fn(); console.log(label + " OK: " + JSON.stringify(String(out).split("\n")[0].slice(0, 70))); }
  catch (e) { console.log(label + " THREW: " + e.message.split("\n")[0].slice(0, 80)); }
}

// The dangerous token IS a valid tar option. execFile never invokes a shell, so there is no
// shell metacharacter anywhere. tar's own option parser is the second interpreter.
console.log("payload token:", JSON.stringify("--checkpoint-action=exec=/usr/bin/touch PWNM"));

// grep supports the POSIX '--' end-of-options separator: everything after '--' is an operand.
// So for grep, an operand that starts with '-' can be made safe by inserting '--'.
attempt("grep no --  ", function () {
  return execFileSync("grep", ["-c", "x", "--version"], { encoding: "utf8" }); // --version treated as an OPTION
});
attempt("grep with --", function () {
  // '--version' after '--' is treated as a FILENAME operand: file-not-found, not option
  return execFileSync("grep", ["-c", "x", "--", "--version"], { encoding: "utf8" });
});

// tar is the counter-example: its --checkpoint-action fires during archive processing,
// and '--' does NOT neutralise a --checkpoint that appears BEFORE the '--'.
attempt("tar -- after", function () {
  return execFileSync("tar", ["-cf", "/dev/null", "--checkpoint=1", "--checkpoint-action=exec=/usr/bin/touch PWNM", "--", "report.txt"], { encoding: "utf8" });
});
console.log("   PWNM created:", fs.existsSync("PWNM"));
text
payload token: "--checkpoint-action=exec=/usr/bin/touch PWNM"
grep no --   OK: "grep (GNU grep) 3.11"
grep: --version: No such file or directory
grep with -- THREW: Command failed: grep -c x -- --version
tar -- after OK: ""
   PWNM created: true

For grep, --version after -- becomes a filename and is not interpreted as an option, so the separator works for operands. For tar, the malicious --checkpoint sits before your -- and fires anyway, and PWNM is still created. Since the agent controls the whole argument array, it simply places the dangerous flag first. -- is a fine habit for confining operands; it is not an argument-injection defence.

The guard that holds: a per-binary argument policy

The working move is to stop trusting arguments the moment any of them can be an option. Give each allowlisted program its own policy: the exact set of option tokens it may receive, and a rule that anything else beginning with - is refused. A program with no policy fails closed. Run the child under a minimal environment so a tool cannot read the agent's own secrets, and cap its output so a runaway tool becomes a catchable error rather than an out-of-memory event.

javascript
// guard.js - a per-binary ARGUMENT POLICY, plus a scrubbed env and an output cap.
// The insight: an allowlist of PROGRAMS is not enough, because a safe program can be
// handed dangerous FLAGS. So each program carries its own policy for what an argument
// may be, and anything that is not plainly a positional operand must be on that
// program's own flag allowlist.
const { execFileSync } = require("node:child_process");

// Per-program policy. flags = the ONLY option tokens this program may receive from an
// agent. Everything else must be a positional operand, and an operand may not begin
// with '-' (so it can never be reinterpreted as an option).
const POLICY = {
  wc:   { flags: new Set(["-l", "-w", "-c"]) },
  head: { flags: new Set(["-n", "-c"]) },
  grep: { flags: new Set(["-n", "-i", "-r", "-E"]) },
  tar:  { flags: new Set(["-tf", "-xf"]) },     // list/extract only, never --checkpoint*
  git:  { flags: new Set(["status", "log", "diff"]) }, // subcommands, never -c / -upload-pack
};

function checkArgs(program, args) {
  const policy = POLICY[program];
  if (!policy) throw new Error("Refused: no argument policy for '" + program + "'");
  for (const a of args) {
    if (typeof a !== "string") throw new Error("Refused: non-string argument");
    if (a.startsWith("-")) {
      // Any dash-led token is an OPTION and must be explicitly allowed.
      if (!policy.flags.has(a)) throw new Error("Refused: option '" + a + "' not allowed for " + program);
    }
    // Positional operands are allowed through here; a separate path guard (see below)
    // should still confine file operands to the workspace.
  }
}

// A scrubbed environment: the child inherits only what it needs, never the parent's secrets.
function safeEnv() {
  return { PATH: "/usr/bin:/bin", LANG: process.env.LANG || "C" };
}

function runTool(program, args, opts = {}) {
  checkArgs(program, args);
  return execFileSync(program, args, {
    encoding: "utf8",
    env: safeEnv(),          // secrets in process.env are not exposed to the tool
    timeout: opts.timeout ?? 5000,
    maxBuffer: opts.maxBuffer ?? 64 * 1024,  // cap the tool's output, do not trust it to be small
    windowsHide: true,
  });
}

module.exports = { runTool, checkArgs, safeEnv, POLICY };

Against the full battery, including the exploit that walked through both earlier guards:

javascript
const fs = require("node:fs");
const { execFileSync } = require("node:child_process");
const { runTool } = require("./guard.js");

// self-contained fixtures so this file's output is the same however it is run
fs.writeFileSync("report.txt", "one\ntwo\nthree\n");
execFileSync("tar", ["-cf", "sample.tar", "report.txt"]);
fs.rmSync("PWNED3", { force: true });

let pass = 0, total = 0;
function expect(label, wantAllowed, program, args, opts) {
  total++;
  try {
    const out = runTool(program, args, opts);
    const ok = wantAllowed === true; if (ok) pass++;
    console.log((ok ? "PASS " : "FAIL ") + label + " ALLOWED: " + JSON.stringify(out.split("\n")[0]));
  } catch (e) {
    const ok = wantAllowed === false; if (ok) pass++;
    console.log((ok ? "PASS " : "FAIL ") + label + " REFUSED: " + e.message.split("\n")[0]);
  }
}

expect("1 tar -tf real       ", true,  "tar", ["-tf", "sample.tar"]);
expect("2 checkpoint exploit ", false, "tar", ["-tf", "sample.tar", "--checkpoint=1", "--checkpoint-action=exec=/usr/bin/touch PWNED3"]);
console.log("   PWNED3 created:", fs.existsSync("PWNED3"));
expect("3 operand '-rf'      ", false, "grep", ["-n", "pattern", "-rf"]);
expect("4 git -c injection   ", false, "git", ["-c", "core.pager=id", "log"]);
expect("5 wc -l              ", true,  "wc", ["-l", "report.txt"]);
expect("6 unpoliced program  ", false, "sha256sum", ["report.txt"]);
console.log("SCORE " + pass + "/" + total);
text
PASS 1 tar -tf real        ALLOWED: "report.txt"
PASS 2 checkpoint exploit  REFUSED: Refused: option '--checkpoint=1' not allowed for tar
   PWNED3 created: false
PASS 3 operand '-rf'       REFUSED: Refused: option '-rf' not allowed for grep
PASS 4 git -c injection    REFUSED: Refused: option '-c' not allowed for git
PASS 5 wc -l               ALLOWED: "3 report.txt"
PASS 6 unpoliced program   REFUSED: Refused: no argument policy for 'sha256sum'
SCORE 6/6

Test 2 is the one that matters: the checkpoint payload is refused because --checkpoint=1 is not in tar's policy, and PWNED3 is never created. Test 3 refuses an operand that merely looks like a flag, so it can never be reinterpreted. Test 6 refuses a program that has no policy at all, which is what fail-closed means in practice.

Two more things a shell tool needs, not just the argument policy

Argument policy is the containment. Two smaller controls sit alongside it, both measured here:

javascript
const { execFileSync } = require("node:child_process");
const fs = require("node:fs");
const { safeEnv } = require("./guard.js");
fs.writeFileSync("report.txt", "one\ntwo\nthree\n");

function attempt(label, fn) {
  try { const out = fn(); console.log(label + " OK: " + JSON.stringify(String(out).replace(/\n/g, " ").slice(0, 80))); }
  catch (e) { console.log(label + " THREW: " + e.message.split("\n")[0].slice(0, 90)); }
}
attempt("A inherited env   ", function () {
  return execFileSync("sh", ["-c", "echo secret_is:[$ANTHROPIC_API_KEY]"], { encoding: "utf8", env: process.env });
});
attempt("B scrubbed env    ", function () {
  return execFileSync("sh", ["-c", "echo secret_is:[$ANTHROPIC_API_KEY]"], { encoding: "utf8", env: safeEnv() });
});
attempt("C scrubbed resolves", function () {
  return execFileSync("wc", ["-l", "report.txt"], { encoding: "utf8", env: safeEnv() });
});
attempt("D output over cap ", function () {
  return execFileSync("head", ["-c", "200000", "/dev/zero"], { encoding: "latin1", maxBuffer: 4096 });
});
text
A inherited env    OK: "secret_is:[sk-ant-FAKE-DO-NOT-USE] "
B scrubbed env     OK: "secret_is:[] "
C scrubbed resolves OK: "3 report.txt "
D output over cap  THREW: spawnSync head ENOBUFS

Row A is the leak: a tool run with the inherited environment can print the agent's ANTHROPIC_API_KEY. Row B is the fix, a scrubbed env of just PATH and LANG, and the secret is gone. Row C proves the scrub is safe: the minimal PATH still resolves the binary, so tools keep working. Row D caps output at four kilobytes, so a tool that tries to return two hundred kilobytes throws ENOBUFS you can catch, instead of quietly inflating the model's context or exhausting memory. Note that options.env governs executable resolution too: set PATH to a directory that does not contain the binary and execFile throws ENOENT, so keep a real minimal PATH in the scrubbed env rather than an empty object.

Limits, stated plainly

  • A per-binary policy is per-binary work. Every program you allow needs its flag set audited by hand. That is the point: the audit is where you decide tar may list but not checkpoint. There is no generic "safe flags" set, because the dangerous flags differ per tool.
  • This is containment, not sandboxing. The argument policy stops a safe binary from being turned into a command runner. It does not stop a genuinely dangerous operation the tool is legitimately allowed to perform, and it does not replace OS-level isolation for the process itself. Pair it with a workspace path guard for file operands and with kernel or container isolation for anything that must not touch the host.
  • Measured on Linux, on the binaries named. The specific dangerous flags are GNU tar 1.35, git 2.47.3 and the coreutils on this box. A different platform or a different tool version has a different flag grammar, which is exactly why the policy is explicit rather than inferred.
  • An allowlist that resolves by name trusts PATH. The scrubbed env pins PATH to /usr/bin:/bin; if you let the agent influence PATH, name resolution itself becomes an attack surface.

Prior art, and where this sits

The AI-agent-shell-injection literature of 2026, including the Cloud Security Alliance's GuardFall research note, concentrates on shell injection: an agent that builds a command string and hands it to a shell, where metacharacters escape the intended command. That is a real and separate problem, and using execFile with an argument array is the right answer to it. This article is about what remains after you have done that correctly and added a program allowlist: the argument array itself is still attacker-influenced, and a safe binary is still a second interpreter. The reproduction, the failed blocklist, the separator limit and the per-binary policy above are the piece those writeups describe as a risk but do not ship as runnable code.

Every output block here was produced by exactly the files shown, run in a clean directory on Node 24.8.0 with the standard library only. Copy guard.js and verify.js into a folder and run them; the score is 6 of 6.

Ren Okabe

Written by

Ren Okabe

Ren builds agent infrastructure and writes copy-paste tutorials for engineers shipping LLM tool-use systems.

Frequently asked questions

Is argument injection the same as shell injection?

No. Shell injection abuses metacharacters like ; and | when a command string is handed to a shell; calling the binary directly with an argument array via execFile stops it. Argument injection passes no metacharacters at all. It hands a safe, allowlisted binary a dangerous flag, such as tar's --checkpoint-action=exec=, so the binary itself runs the command. A metacharacter blocklist does nothing against it.

Does using execFile instead of exec make my agent's shell tool safe?

It closes shell injection, which is necessary but not sufficient. execFile with an argument array means no shell parses your string. It does not stop the agent from placing a dangerous option in that array. In the measured demo, execFileSync tar with --checkpoint=1 and --checkpoint-action=exec=/usr/bin/touch PWNED creates the file with no shell involved.

Why is a program allowlist not enough?

Because a safe program can be given dangerous arguments. Allowlisting tar, git and grep keeps the agent from running bash, but tar's own --checkpoint-action, git's -c and find's -exec all execute commands. The allowlist inspects the program name and never the option grammar behind it.

Does the double-dash end-of-options separator fix it?

Only for operands. Inserting the -- separator makes a following filename that starts with a dash a positional operand rather than an option, which is a good habit. But a malicious flag placed before the separator still fires, and since the agent controls the whole argument array it simply puts the dangerous flag first. Measured here: tar's --checkpoint before -- still runs the payload.

What actually stops it?

A per-binary argument policy. Give each allowlisted program the exact set of option tokens it may receive, refuse any other token that begins with a dash, and fail closed for a program with no policy. Run the child under a scrubbed environment (a minimal PATH and LANG, so the tool cannot read your API keys) and a maxBuffer output cap. The guard in this article passes 6 of 6, including the checkpoint exploit.

Tutorials

How to sandbox an AI agent's file tools in Node.js

If your agent has a read_file tool, you probably guard it with path.resolve plus startsWith. It blocks the obvious traversal and is defeated by a symlink and by a sibling directory sharing the prefix. Both reproduced on Node 24.8.0, with the guard that passes all nine cases.

8 min read32
Tutorials

Your MCP stdio server keeps running after the client dies (Node)

An MCP stdio server that holds a timer or a pool open does not exit when its client dies. Measured on SDK 1.30.0 and Node 24.8.0: it is reparented to init and keeps running. StdioServerTransport registers no stdin end-of-file listener. Here is the reproduction and the four-line fix.

9 min read20