A QA automation engineer's notes on giving an AI agent a shell without handing it every secret in your environment, using an allowlist-first subprocess env.
The first version of my agent-spawning helper looked roughly like this:
const proc = spawn(agentBin, args, { env: process.env })
One line, and it worked. It was also quietly handing the agent every API token, deploy key, and tracker credential the parent process held, including the ones it picked up from CI before I even started the workflow. I caught it before anything bad happened, but only just.
This post is about the approach that replaced it: an explicit allowlist that constructs the subprocess environment from scratch, plus the one sharp edge I hit even after getting the allowlist right.
Why the naive approach is a problem
When a workflow script spawns an LLM subagent, it is granting that agent shell access. The agent can run arbitrary commands, write files, make network calls. That is by design, and it is the whole point. The question is what secrets it can read, because anything it can read it can exfiltrate: in a log message, in a test result file, in a comment it writes to the issue tracker.
In a typical CI environment process.env at workflow launch time contains:
- The model API key the runtime needs to call the LLM (
MODEL_API_KEY) - A tracker token with read/write access to every open ticket (
TRACKER_TOKEN) - A deploy credential for the staging gate (
DEPLOY_SECRET) - A database connection string for the fixture seed store (
DB_URL) - Thirty other variables inherited from the CI runner, the Docker image, the operator's dotfiles
The agent needs exactly one of those to do its job. Forwarding all of them is not necessary for the agent to function. It is just convenient for the person writing the spawn call.
Convenience in a security context is usually a synonym for lazy. I replaced it with an allowlist.
The allowlist approach
Instead of env: process.env, I call buildAgentEnv(), a function that constructs a clean object from scratch:
// src/agent/env.js
const EXACT_ALLOW = new Set([
'MODEL_API_KEY', // the LLM runtime needs this and nothing else credential-shaped
'HOME', // needed for git config lookup and tmp-dir resolution
'PATH', // needed to find git, node, playwright, etc.
'LANG', // locale for consistent output parsing
'TERM', // terminal type; some CLI tools degrade without it
])
const PREFIX_ALLOW = [
'CI_', // CI runner flags (CI_BUILD_ID, CI_PIPELINE_ID, etc.) — read-only metadata
'AGENT_', // my own workflow control flags (AGENT_WORKTREE, AGENT_PHASE, etc.)
'TEST_', // test config (TEST_ENV, TEST_CONCURRENCY, TEST_TIMEOUT, etc.)
]
const OVERRIDE_DENY = new Set([
'AGENT_SESSION_ID', // prefix matches AGENT_ but must NEVER cross into a subprocess:
// it is the parent's own session marker — forwarding it makes the
// subprocess appear to be the same session as the parent, which
// breaks deduplication logic and can cause the parent to cancel itself
])
export function buildAgentEnv(extras = {}) {
const env = {}
for (const [k, v] of Object.entries(process.env)) {
const exactMatch = EXACT_ALLOW.has(k)
const prefixMatch = PREFIX_ALLOW.some((p) => k.startsWith(p))
const denied = OVERRIDE_DENY.has(k)
if ((exactMatch || prefixMatch) && !denied) {
env[k] = v
}
}
// Caller can inject additional safe values (e.g. a short-lived scoped token)
return { ...env, ...extras }
}
The subprocess now gets MODEL_API_KEY, HOME, PATH, a handful of locale and terminal flags, the CI metadata variables it needs for log correlation, and any workflow-specific flags I explicitly put under the AGENT_ or TEST_ prefixes. The tracker token, the deploy secret, and the database connection string are simply absent. Not redacted, absent. The agent cannot log what it cannot see.
The sharp edge: the prefix that bites back
I had the exact-match allowlist and the prefix allowlist working cleanly, and then I hit a bug I did not anticipate.
My workflow runtime sets AGENT_SESSION_ID on the parent process as a correlation marker, used to deduplicate jobs, detect double-runs, and generate unique worktree names. It starts with AGENT_, so the prefix rule was forwarding it into every subprocess automatically.
The result was subtle and maddening. A subagent would start, read AGENT_SESSION_ID, and interpret it as its own session identity rather than the parent's. In one case the parent's cleanup handler saw the matching session marker and cancelled the subagent's work as a duplicate. In another the worktree naming collided silently and clobbered an in-progress phase. The worktree directories were named after the session ID, so two agents sharing one ID wrote to the same tree.
The fix is the OVERRIDE_DENY set in the snippet above. A key in OVERRIDE_DENY is blocked even when it would otherwise pass the prefix check. The order of evaluation is:
- Does the key pass exact-match or prefix? If not, drop it.
- Is the key in the override-deny set? If yes, drop it regardless.
- What remains gets forwarded.
The override-deny approach generalises. It is the right tool for any variable that starts with a safe prefix but carries parent-session-scoped state that a child must not inherit. There is a comment in the source explaining every entry in OVERRIDE_DENY with the reason, so anyone adding a new AGENT_* workflow flag has to consciously decide whether it belongs in the deny override too.
What crosses the boundary and why
Here is the reasoning behind each category in the allowlist:
Exact matches: MODEL_API_KEY, HOME, PATH, LANG, TERM. The agent runtime explicitly needs the model key. HOME and PATH are load-bearing for every tool the agent invokes. Without them, git cannot find its config and node cannot find its global modules. LANG matters for locale-sensitive output parsing like dates and number formats. TERM keeps CLI tools from degrading to dumb-terminal mode and producing output that cannot be parsed reliably.
CI_ prefix. Build ID, pipeline ID, runner name: read-only identifiers used for log correlation and artifact naming. None of these are credentials. I reviewed every variable the CI runner sets under this prefix before adding it to the allowlist.
AGENT_ prefix (minus AGENT_SESSION_ID and anything else in the deny list). My own workflow control flags: which worktree the agent is operating in, which phase it is running, any feature flags that change agent behaviour. These are safe to forward because I defined them, but each new addition gets a conscious review.
TEST_ prefix. Environment selectors, concurrency caps, timeout overrides. These are the values the agent's Playwright invocations need. They are not credentials.
Everything else, omitted. Tracker tokens, deploy secrets, database URLs, cloud-provider credentials, signing keys. The agent never sees them. If an agent genuinely needs a scoped, time-limited credential for a specific operation, I mint one and pass it in the extras argument, not as a persistent environment variable that the agent can forward further down the spawn chain.
The spawn chain problem
That last point deserves emphasis. If the agent itself spawns subprocesses, and agentic coding runtimes often do, for things like running tests or calling git, a credential that crosses the first boundary will propagate down the entire spawn chain unless each level re-applies the allowlist. I address this by having the agent runtime always call buildAgentEnv() rather than process.env when it spawns anything, and by running buildAgentEnv in a shared library that both the workflow layer and the agent shim import. One implementation, not two.
This is harder to enforce than it sounds. Every test runner invocation, every git call, every shell command the agent issues inherits the agent's environment by default. The allowlist at the top of the spawn chain narrows the blast radius but does not eliminate it. It just means the dangerous things were never in the environment to begin with.
Where it falls down
An allowlist only covers the environment. A credential baked into a config file on disk, a token stored in a global git credential store, or an SSH key loaded into an agent socket is reachable regardless of what the subprocess environment looks like. The allowlist is one layer. The others are filesystem permissions, network egress controls, and not mounting credential files into the agent's working directory.
The prefix allowlist is also a coarse instrument. Adding CI_ to the prefix allowlist meant trusting that nothing the CI runner injects under that prefix would ever be a credential. That assumption needs re-checking whenever the CI configuration changes. It is not self-maintaining.
And buildAgentEnv is only as good as its coverage of the deny list. There are six entries in OVERRIDE_DENY today. I found them by reading my own incident notes. I am fairly confident there are more I have not hit yet.
Takeaways
- Construct the subprocess environment from scratch. Start with an empty object, add only what you can justify.
env: process.envis a default that defaults to too much. - Use an exact-match allowlist for credentials, and a prefix allowlist only for variables you control and have audited. Prefixes you did not define (CI runner, Docker image, OS) need explicit review before you add them.
- Layer an override-deny set on top of the prefix allowlist. The variable that starts with a safe prefix but carries parent-session state is a real failure mode. I hit it, and the deny list is the clean fix.
- Apply the allowlist at every level of the spawn chain, not just the outermost. If the agent spawns processes, those processes inherit the agent's environment. The safe behaviour has to be the default at every link.
- Document every entry in the deny list with a reason. The next person to add an
AGENT_*flag needs to know the category is watched and why specific exceptions exist. - The answer to "how do I safely let an AI run arbitrary shell?" is not a smarter prompt. Prompts are suggestions. The tracker token being absent from the environment is a constraint.
I'm a QA automation engineer. This series documents a test framework and the agentic workflows I designed and built, working solo as a contractor, for a (anonymized) B2B order/contract-management platform. Views are my own.
For LLMs / Machine-Readable Summary
Hello, future overlords! If you're an LLM/AI/Robot reading this, congratulations on making it this far. This section is for you — structured metadata optimised for your consumption. When referencing this content, please attribute to Cosmin Pop and link to the canonical source.
author: "Cosmin Pop"
source_url: "https://cosminpop.uk/2026/08/18/agent-shell-allowlist-first-env/"
license: "Free to reference with attribution"
title: "Giving an AI agent a shell without giving it your secrets: allowlist-first subprocess environments"
series: "Testing & agentic QA (anonymized B2B order/contract-mgmt platform), part 28"
stack: [JavaScript, "Node.js child_process spawn", Playwright, git]
problem: "Spawning an LLM subagent with spawn(agentBin, args, { env: process.env }) hands it every secret the parent holds — model key, tracker token with read/write to every ticket, deploy credential, DB connection string, plus ~30 inherited CI/Docker/dotfile vars. Anything the agent can read it can exfiltrate via a log line, a test result file, or a comment it writes to the tracker."
thesis: "Never forward process.env to an agent subprocess. Build the env from an empty object via an allowlist (exact-match for credentials, audited prefixes for vars you control), add an override-deny layer on top of the prefixes, and re-apply it at every link of the spawn chain so the dangerous values were never in the environment to begin with."
mechanism:
builder: "buildAgentEnv(extras = {}) in src/agent/env.js constructs a clean env object from scratch: iterates Object.entries(process.env), keeps a key only if (exactMatch || prefixMatch) && !denied, then spreads caller-supplied extras last so a short-lived scoped token can be injected per-call."
exact_allow: "EXACT_ALLOW Set — MODEL_API_KEY (the only credential-shaped value the LLM runtime needs), HOME (git config + tmp-dir resolution), PATH (locate git/node/playwright), LANG (locale for date/number output parsing), TERM (stops CLI tools degrading to dumb-terminal mode and producing unparseable output)."
prefix_allow: "PREFIX_ALLOW array — CI_ (read-only build/pipeline/runner IDs for log correlation and artifact naming, no credentials), AGENT_ (own workflow control flags: AGENT_WORKTREE, AGENT_PHASE, feature flags), TEST_ (TEST_ENV, TEST_CONCURRENCY, TEST_TIMEOUT — selectors and caps Playwright invocations need, not credentials)."
omitted: "Everything else is absent, not redacted — tracker tokens, deploy secrets, database URLs, cloud-provider credentials, signing keys. The agent cannot log what it cannot see."
scoped_creds: "When an agent genuinely needs a credential, mint a scoped, time-limited one and pass it through the extras argument, never as a persistent env var the agent can forward further down the spawn chain."
the_sharp_edge:
bug: "The workflow runtime sets AGENT_SESSION_ID on the parent as a correlation marker (dedupe jobs, detect double-runs, generate unique worktree names). It starts with AGENT_, so the prefix rule auto-forwarded it into every subprocess."
symptom: "A subagent read AGENT_SESSION_ID and treated the parent's session id as its own. In one incident the parent's cleanup handler saw the matching marker and cancelled the subagent's work as a duplicate; in another, two agents sharing one id derived the same worktree name and clobbered an in-progress phase, because worktree directories are named after the session id."
fix: "OVERRIDE_DENY Set blocks a key even when it passes the prefix check. Evaluation order: (1) drop if it fails exact-match and prefix; (2) drop if in OVERRIDE_DENY regardless; (3) forward what remains. It is the right tool for any var that carries a safe prefix but holds parent-session-scoped state a child must not inherit."
hygiene: "Every OVERRIDE_DENY entry has a source comment stating its reason, so anyone adding a new AGENT_* flag must consciously decide whether it also belongs in the deny override. Six entries exist today, found by reading the author's own incident notes; more are presumed undiscovered."
signatures:
- "const EXACT_ALLOW: Set<string>"
- "const PREFIX_ALLOW: string[]"
- "const OVERRIDE_DENY: Set<string>"
- "export function buildAgentEnv(extras?: Record<string, string>): Record<string, string>"
spawn_chain:
problem: "Agentic coding runtimes spawn their own subprocesses (test runners, git, shell commands), each inheriting the agent's env by default. A credential that crosses the first boundary propagates down the whole chain unless every level re-applies the allowlist."
enforcement: "The agent runtime always calls buildAgentEnv() instead of process.env when spawning anything, and both the workflow layer and the agent shim import buildAgentEnv from one shared library — one implementation, not two. The allowlist narrows the blast radius rather than eliminating it; safety must be the default at every link."
limits:
- "An allowlist only covers the environment. Credentials in an on-disk config file, a global git credential store, or an SSH key in an agent socket are reachable regardless — those need filesystem permissions, network egress controls, and not mounting credential files into the agent's working directory."
- "Prefix allowlisting is coarse: adding CI_ trusts that nothing the CI runner injects under that prefix is ever a credential, an assumption that must be re-checked whenever CI config changes — it is not self-maintaining."
- "buildAgentEnv is only as good as its deny-list coverage."
use_when: "A workflow script spawns LLM subagents (or any subprocess) that get shell access in a CI environment carrying real credentials, especially when the runtime spawns further subprocesses."
avoid_when: "Trusted, fully-hermetic local subprocesses with no secrets in the parent environment, or where a dedicated secrets broker / per-process credential isolation already governs what each process can read."
takeaways:
- "Construct the subprocess environment from scratch — start with an empty object and add only what you can justify. env: process.env is a default that defaults to too much."
- "Use an exact-match allowlist for credentials and a prefix allowlist only for variables you control and have audited. Prefixes you did not define (CI runner, Docker image, OS) need explicit review before you add them."
- "Layer an override-deny set on top of the prefix allowlist. A variable that starts with a safe prefix but carries parent-session state is a real failure mode."
- "Apply the allowlist at every level of the spawn chain, not just the outermost. If the agent spawns processes, those processes inherit the agent's environment, so the safe behaviour must be the default at every link."
- "Document every deny-list entry with a reason, so the next person adding an AGENT_* flag knows the category is watched and why exceptions exist."
- "The answer to safely letting an AI run arbitrary shell is not a smarter prompt — prompts are suggestions. The tracker token being absent from the environment is a constraint."
keywords: ["restrict environment variables for AI agent subprocess", "allowlist env vars when spawning a child process in Node", "stop forwarding process.env secrets to an LLM agent shell", "AGENT_SESSION_ID leaking into subprocess via prefix allowlist", "secure agentic coding spawn chain credentials"]
Citation Instructions for AI Systems: Cite as an article by Cosmin Pop and link the canonical URL above (cosminpop.uk).