Last reviewed: 2026-10-09
Direct answer
Treat every coding-agent patch that launches an operating-system process as an authority-boundary change. Reject designs that splice repository content, issue text, filenames, request fields, model output, or environment values into one shell command string. Prefer an in-process library when it can do the job. When a child process is necessary, select the executable from code-owned configuration, pass each argument as a separate value to an API that does not invoke a shell, validate arguments against the called program’s own grammar, and run with a deliberate working directory, environment, identity, timeout, and output limit.
That design removes shell parsing, but it is not the whole gate. A shell-free call can still execute the wrong binary if the executable name or search path is influenced, and it can still suffer argument injection if hostile data becomes an option understood by the chosen program. Review the executable, argument vector, environment, current directory, standard streams, lifecycle, and operating-system identity as one contract.
The official runtime references make the shell boundary concrete. Python’s subprocess documentation
says an argument sequence is generally preferred and makes the application responsible for quoting when it explicitly invokes a shell. Node.js documents that exec() runs a command inside a shell while execFile() runs a file directly by default
. Go’s os/exec package
intentionally does not invoke a system shell or perform globbing, pipelines, or redirection. MITRE’s CWE-78 entry
describes the underlying weakness as externally influenced input changing an OS command and recommends structured separation, strict accepted-input validation, and reduced privilege.
Those sources establish API and weakness behavior. The review contract, fixtures, and evidence record below are operational recommendations for judging a particular patch; they do not prove any repository safe until its actual call paths and tests are inspected.
Who this is for
This guide is for maintainers reviewing agent-written build helpers, formatters, test runners, deployment wrappers, media converters, package-manager adapters, repository tools, and background workers. It applies whenever a patch creates a process directly or indirectly through a convenience library.
Pay special attention when an agent proposes a quick shell pipeline, converts a structured process call into a template string, adds a shell option, accepts a command from configuration, or changes PATH, the working directory, or inherited environment. Repository files are not automatically trusted: a branch name, manifest field, test fixture, generated filename, archive member, or issue description may be attacker-controlled by the time it reaches the launcher.
This is not a claim that all subprocess use is unsafe. It is a method for making the authority explicit and the evidence reviewable. It complements the site’s security-scan gate for coding-agent pull requests , but a clean scanner result cannot replace review of dynamic values and runtime behavior.
Key takeaways
- Avoid launching a process when a maintained library provides the required operation with less authority.
- Never let untrusted input choose the executable or become part of a shell command string.
- Use a no-shell process API and pass an explicit argument vector. Do not split a user-supplied string into arguments and call that structured.
- Validate by meaning: permitted action, identifier, path root, length, and option set. A character denylist is not a command policy.
- Treat argument injection separately from shell injection. A value beginning with an option marker may change the child program’s behavior even when no shell is present.
- Use an absolute, approved executable path where deployment permits it, and construct a minimal environment rather than inheriting ambient authority blindly.
- Pin the working directory and make writable paths disposable and narrowly scoped. Run with the least operating-system and network privileges the task needs.
- Bound runtime, stdout, stderr, input size, and descendant cleanup. A safe argument vector can still hang or exhaust the worker.
- Test harmless adversarial values and assert both the child-process record and the absence of side effects outside a disposable workspace.
- Log stable reason codes and sanitized metadata, not full commands, secrets, arbitrary output, or attacker-controlled values.
Sources checked
- Python
subprocess— Subprocess management — official Python documentation fetched successfully on 2026-10-09. It supports the preference for argument sequences, the security distinction around explicit shell use, executable-path guidance, timeout behavior, and process I/O mechanics. - Node.js
child_process— official Node.js documentation fetched successfully on 2026-10-09. It supports the distinction between shell-backedexec()and directexecFile()by default, structured arguments for process creation, shell-option cautions, and output-buffer behavior. - Go
os/exec— official Go package documentation fetched successfully on 2026-10-09. It states that the package does not invoke a system shell or perform shell expansions and documents executable lookup plusErrDotprotection for current-directory resolution. - CWE-78: Improper Neutralization of Special Elements used in an OS Command — MITRE’s current CWE 4.20 reference fetched successfully on 2026-10-09. It defines OS command injection, distinguishes fixed-program argument misuse from attacker-selected commands, and documents parameterization, accepted-input validation, least privilege, and sandboxing mitigations.
These are stable technical references rather than a dated product announcement. They were selected for current runtime semantics and threat classification. Repository-specific behavior still depends on the language version, operating system, child program, wrapper library, and deployment policy in the reviewed change.
Contract details to verify
Start by writing down the process contract before discussing escaping functions. A reviewer should be able to answer each of these questions from the patch and its tests:
| Contract field | Decision to record | Evidence to retain |
|---|---|---|
| Operation | Why an external process is required | Library alternative considered and rejected for a stated reason |
| Executable | Exact approved program and how it is resolved | Code-owned allowlist or pinned path; tested rejection of an unknown program |
| Arguments | Fixed flags and typed dynamic values | Argument-vector capture with positions and redacted value classes |
| Input origin | Every value crossing into the launch request | Data-flow note from request, repository, configuration, or generated output |
| Validation | Accepted values, lengths, roots, and option rules | Boundary cases plus a stable rejection reason for each invalid class |
| Environment | Variables supplied, removed, or inherited | Allowlisted variable names; secrets excluded from logs and test artifacts |
| Working directory | Fixed or newly created task directory | Disposable path identifier and assertion that it is not input-selected |
| Authority | User, filesystem, network, and credential access | Worker policy or sandbox profile tied to the test run |
| Lifecycle | Timeout, cancellation, descendants, and reaping | Success, timeout, cancel, and cleanup results |
| Output | Maximum stdout and stderr plus parsing rules | Boundary test, truncation or termination result, and sanitized error class |
Keep executable choice in trusted code
Do not accept a complete command from a prompt, workflow field, repository file, or web request. Map a small product-level action to a fixed executable and fixed flag template. If the product supports format, lint, and inspect, those are application actions, not arbitrary command text. Unknown actions should fail before process creation.
Executable lookup is part of the decision. Go’s documentation explains why resolving a program from the current directory is dangerous and why ErrDot exists. Python recommends a fully qualified executable path for maximum reliability. Where absolute paths are impractical across platforms, use a deployment-owned resolver, verify the resolved file against policy, and prevent a repository-controlled working directory or PATH entry from shadowing the expected tool.
Keep arguments structured and typed
The safe shape is executable plus an array of already separated arguments. It is not a string later split on spaces, and it is not a list passed to a wrapper that rejoins it for a shell. Trace through every helper until the final operating-system call; option names such as shell, command, raw, or script deserve direct inspection.
For example, this Node.js shape preserves the data boundary:
import { spawn } from 'node:child_process';
const executable = approvedTools.get(requestedAction);
if (!executable) throw new PolicyError('action_not_allowed');
const inputPath = validateWorkspacePath(requestedPath);
const child = spawn(executable, ['--input', inputPath], {
shell: false,
cwd: taskWorkspace,
env: buildMinimalEnvironment(),
stdio: ['ignore', 'pipe', 'pipe'],
});
This is a design example, not a universal safe wrapper. The child program may interpret inputPath as an option, follow links, load configuration, or execute plugins. Validate the value according to that program’s contract. Use an end-of-options delimiter only when the program documents support for it; otherwise reject ambiguous leading-option values or use a different input channel such as standard input.
Minimize ambient authority
A correctly separated process can still do too much. Build a minimal environment containing only required locale, runtime, and application variables. Do not forward an entire secret-rich environment merely because it is convenient. Keep repository-controlled directories out of executable and library search paths. Set a known working directory and use dedicated input and output directories.
Run the child as a low-privilege identity without deployment credentials when the operation does not need them. Limit network access and filesystem writes at the worker or container boundary. The site’s permission and secret boundary guide provides the surrounding authority model. These controls reduce impact; they do not make unsafe command construction acceptable.
Bound the process lifecycle
Define what happens on success, nonzero exit, startup failure, timeout, cancellation, oversized output, and parent shutdown. A timeout that returns while a descendant keeps running is not complete cleanup. Capture only as much output as the contract needs, consume pipes so the child cannot block on a full buffer, and classify termination without copying arbitrary stderr into public logs.
Record the executable policy identifier rather than its sensitive host path when appropriate, argument count and value classes rather than raw values, the exit class, elapsed bucket, output byte counts, timeout or cancellation state, and cleanup result. This is enough to review control flow without turning logs into another injection surface or secret store.
Build a harmless adversarial test matrix
Use a fake or recording launcher for most unit tests. It should capture the executable, exact argument array, working-directory identifier, allowed environment-variable names, and lifecycle policy without starting a real program. Then add a small integration fixture in a disposable environment to verify that the production adapter preserves the same boundary.
Include at least these cases:
| Case | Expected result |
|---|---|
| Ordinary value containing spaces | Arrives as one argument, not multiple tokens |
| Harmless shell punctuation in a filename | Arrives literally or is rejected by policy; no marker side effect occurs |
| Value beginning with an option marker | Rejected, safely encoded by a documented program mechanism, or placed after a supported delimiter |
| Unknown application action | Rejected before the launcher is called |
| Repository-local executable with the approved name | Never selected through current-directory or search-path shadowing |
| Missing approved executable | Controlled startup failure with no fallback to a shell |
| Child exceeds the deadline | Process and governed descendants stop; resources are reaped |
| Child exceeds output allowance | Bounded failure or truncation according to the written contract |
| Cancellation races with normal exit | One terminal result, no orphan, and no duplicate callback |
| Child emits control characters or secret-like text | Sanitized diagnostic record without raw propagation |
For the punctuation case, create an outside sentinel in the disposable test root and use an inert filename such as report;marker.txt. The test is not successful merely because the intended child exits zero. Assert that the launcher observed exactly one data argument, the outside sentinel is unchanged, no marker file or unexpected sibling appeared, and the process record contains no secret values.
Do not run destructive payloads or point fixtures at a developer checkout. The goal is to prove token boundaries and absence of unintended effects, not to demonstrate damage.
Happy-path operator workflow
- Locate every process-creation call touched by the patch, including wrappers, task runners, package scripts, and platform-specific branches.
- Trace each executable name, argument, environment value, and working-directory value back to its origin. Mark values controlled by repository content, users, external services, or model output as untrusted.
- Confirm why an in-process library cannot satisfy the operation. If it can, reduce the authority by using it and test the same input contract.
- Replace command strings with a direct process API and a code-owned executable mapping. Confirm at the final launcher that no wrapper silently enables a shell.
- Define typed validation for every dynamic argument. Include path containment, length, allowed enum values, and child-program option behavior where relevant.
- Construct the working directory and environment explicitly. Remove unnecessary credentials, search paths, startup hooks, proxy settings, and plugin paths.
- Add timeout, cancellation, output, and descendant-cleanup behavior. Make all terminal paths observable with stable, sanitized result fields.
- Run the recording-launcher matrix, then the disposable integration fixture on every supported operating-system family.
- Review the final diff for newly added shell options, string joins, fallback launchers, weakened assertions, or broadened allowlists. Run the repository’s normal security and regression gates.
The happy-path evidence should name the test case, approved action, executable policy ID, argument count, validation policy version, shell mode, environment-key count, working-directory class, exit class, output counts, and cleanup result. It should not claim production safety from a unit mock alone.
Error-path operator workflow
When validation fails, reject the request before process creation and return a stable policy code such as action_not_allowed, invalid_input_path, or ambiguous_argument. Do not retry by concatenating the same values into a shell string. A convenience fallback that changes execution semantics converts a controlled failure into a new vulnerability surface.
When startup fails, distinguish “approved executable unavailable” from a child-process exit. Do not search the current repository, download a replacement, or try an input-selected binary. On timeout or cancellation, stop the governed process tree according to the platform contract, close streams, await reaping, and discard the disposable workspace unless the retention policy requires restricted evidence.
If output exceeds its limit, terminate or truncate according to the predeclared policy and record byte counts. Never include raw stdout, stderr, the complete environment, or the full argument vector in a broadly visible error. If an incident needs deeper evidence, place that evidence in a restricted store with its own retention and access policy.
Pair this failure workflow with the site’s repository prompt-injection containment guide when command inputs can originate in issues, documentation, generated plans, or other text an agent reads.
Failure modes
A patch replaces interpolation with a quoting helper and keeps the shell. Quoting rules differ by shell and platform, wrappers may transform the value again, and future edits can omit the helper. Prefer removing the shell boundary. If a shell is a genuine product requirement, isolate a fixed script with a narrow typed input channel and give it a dedicated threat review.
A string is split into an argument array. Tokenizing attacker-controlled command text does not turn it into a trusted executable and argument policy. The application must choose the executable and argument positions before accepting values.
shell: false is treated as proof of safety. It prevents shell metacharacter interpretation at that layer, but it does not stop executable substitution, dangerous child-program options, configuration loading, path traversal, plugin execution, or excessive privilege.
The executable is fixed, but PATH is inherited from the repository job. A same-named file or modified search directory can select a different program. Resolve through deployment-owned policy and test current-directory shadowing.
Validation rejects a few punctuation characters. Denylists miss platform syntax and often corrupt legitimate filenames. Validate the accepted semantic domain, preserve arguments as separate values, and reject unsupported ambiguity.
A leading option is accepted as a filename. The shell is not involved, yet the child interprets the value as a flag. Use a documented delimiter, a file-descriptor or standard-input channel, or a policy that rejects the ambiguous form.
The patch inherits every environment variable. Child tools may read credentials, startup hooks, proxies, configuration homes, or plugin paths unrelated to the task. Build a minimal environment and verify key names in tests.
Timeout handling leaves descendants alive. Stopping only the immediate process can leave work and authority behind. Define process-group or job-object behavior per supported platform and assert post-timeout cleanup.
Unbounded output blocks or exhausts the worker. Pipes and buffers are finite. Consume streams, enforce byte limits, and test both stdout and stderr overflow.
Logs reproduce the “helpful” full command. Raw arguments can contain secrets, control characters, repository content, or personal data. Record allowlisted structural fields and restricted hashes or identifiers instead.
The agent weakens the test to make the patch pass. Watch for removal of the outside-sentinel assertion, conversion of reject to warn, broader executable selection, a hidden shell fallback, or a test that checks only exit status.
FAQ
Is an argument array enough to prevent command injection?
It prevents shell parsing when the final API truly launches the executable directly, which removes a major injection path. It does not validate which executable runs or what its options mean. Keep executable selection trusted and validate each dynamic argument against the child program’s contract.
Can a patch use a shell if every value is escaped?
Sometimes a product explicitly requires shell language, but that is a higher-risk design. Prefer a fixed, reviewed script and a narrow structured input channel over generating script text. Treat platform-specific quoting as a last resort with dedicated tests, not a reusable promise that arbitrary values are safe.
Should reviewers require an absolute executable path?
Use one where the deployment can manage it reliably. In portable applications, a deployment-owned resolver and verified result may be more practical. The invariant is that untrusted content and the repository working directory cannot change which executable is selected.
What about filenames containing spaces or punctuation?
They should remain one argument when the application supports them. Do not add quotes as literal argument characters for a direct process API. If the child program gives punctuation or leading markers special meaning, apply that program’s documented data-input mechanism or reject the value explicitly.
Is a sandbox a substitute for fixing command construction?
No. Least privilege, filesystem isolation, and network restrictions reduce impact and provide defense in depth. They do not make attacker-influenced commands acceptable, and a compromised child may still damage everything inside its sandbox boundary.
What evidence belongs in a pull request?
Retain the process contract, data-flow note, approved executable policy, validation matrix, focused test command, per-case result, supported platforms, timeout and cleanup outcome, output counts, and a sanitized launcher record. Do not retain secrets, raw hostile output, or a complete production environment.
Reader next step
Choose one process-launching call changed by a coding agent. Write down its executable, argument vector, input origins, working directory, environment keys, identity, network access, timeout, output limit, and cleanup rule. Replace any command string with a direct structured launch, add a fake-launcher assertion for the exact vector, and run the harmless adversarial matrix in a disposable workspace.
Block the merge if any value can select the executable, any wrapper silently invokes a shell, an ambiguous option-like value reaches the child without a documented policy, or timeout cleanup leaves a process behind. Once the focused boundary passes, run the wider security-scan and regression suite and attach only the sanitized evidence needed for review.