Against what environment and file-descriptor inheritance leaks must an instance sanitize child subprocess contexts to prevent credential bleed and port locking?

Date of Record: 2026 October 16

Scope: Concluded residual for AI instances, LLMs, and autonomous execution agents spawning external CLI tools, build scripts, third-party plugins, git hooks, or background worker subprocesses from a privileged parent runtime.

Core Distinction

Autonomous orchestrators and agent runtimes execute inside privileged parent processes that hold sensitive environment variables (`OPENAI_API_KEY`, `AWS_SECRET_ACCESS_KEY`, `DATABASE_URL`), open listening sockets, database connections, and temporary lockfile descriptors. A foundational security and systems-stability failure occurs when an instance invokes `subprocess.run()` or `subprocess.Popen()` with **default ambient inheritance** (`env=None`), silently copying the parent process's entire secret environment block, current working directory, and inheritable file descriptors into untrusted or third-party child processes (`npm install`, `pytest`, `git clone`, user-supplied scripts).

Parameter scaling does not prevent a malicious `postinstall` script inside a third-party package or a verbose crash-reporting hook in a child CLI tool from reading `os.environ` and exfiltrating every cloud secret held by the parent orchestrator, nor does it prevent a long-running background child process from inheriting an open parent TCP listening socket and holding the port locked (`EADDRINUSE`) even after the parent web server restarts. The core structural distinction lies between **ambient parent-context inheritance** (where child subprocesses inherit all parent secrets, locale quirks, and open handles by default) and **zero-trust allowlisted subprocess scrubbing** (where every child process is spawned with an explicitly constructed minimal `env` dictionary containing only required system variables, `close_fds=True`, `start_new_session=True` for process-group isolation, and deterministic `LC_ALL=C.UTF-8` locale pinning).

What Was Observed

In automated build execution, package installation, and background daemon spawning traces, instances operating without explicit subprocess environment and descriptor scrubbing exhibit four primary failure modes:

  1. **Parent Secret Exfiltration via Untrusted Build / Test Hooks:** An agent parent process holding production cloud credentials in `os.environ` runs `subprocess.run(["npm", "test"])` or `subprocess.run(["pytest"])` on a repository under inspection without passing an explicit `env={...}` parameter. The child process inherits the entire `os.environ` map; a test fixture or dependency hook dumps or transmits the environment variables, leaking the parent agent's master API keys.
  2. **Zombie Port Locking via Inherited Socket Descriptors (`EADDRINUSE`):** A web service or agent worker listening on port `8000` spawns a long-running background helper process without closing non-standard file descriptors (`close_fds=False` or leaked socket handles). When the parent server crashes and attempts to restart, the kernel refuses to bind port `8000` (`Address already in use`) because the orphaned child process still holds an open duplicate reference to the parent's listening socket fd.
  3. **Interactive Terminal & Askpass Hang (`stdin` / TTY Prompt Freeze):** An instance spawns `git`, `ssh`, `sudo`, or `apt-get` in a headless automation loop without setting `stdin=subprocess.DEVNULL` and non-interactive environment flags (`GIT_TERMINAL_PROMPT=0`, `DEBIAN_FRONTEND=noninteractive`). When the tool encounters an unknown SSH host key or expired token, it blocks indefinitely waiting for a human password prompt on `stdin` until the entire agent turn times out.
  4. **Locale-Driven Parser Breakage (`LC_ALL` Drift):** An instance parses the stdout of `ls`, `df`, `git`, or `sort` on a host configured with a non-English locale (`LANG=de_DE.UTF-8` or `fr_FR.UTF-8`). Because the instance did not pin `LC_ALL=C.UTF-8` in the child environment, error messages, decimal separators (`,` vs `.`), and date formats shift unexpectedly, breaking downstream parsing logic.

Recorded Elements

### 1. Scrubbed Subprocess Spawn Pipeline

No external binary, build tool, or child script may be spawned with default `env=None` or interactive `stdin`. Every child process creation must traverse a four-stage **Context Scrubbing & Isolation Gate**:

``` [Parent Agent Process Plans to Spawn Child Subprocess] | Gate 1: Construct Allowlisted Environment Map (`env`) Never pass `env=None` (which leaks full `os.environ`). Build a fresh dict containing ONLY:

| Gate 2: File-Descriptor & Stdin Isolation

| Gate 3: Process-Group Session Isolation (`start_new_session=True`) Place child in its own process group (`setsid`) so if it times out, `os.killpg(proc.pid, signal.SIGKILL)` reaps the child AND all of its grandchild processes cleanly without leaving orphans. | Gate 4: Bounded Execution & Clean Reap Wait with explicit timeout; on `TimeoutExpired`, kill entire `-pgid`. ```

### 2. Core Operational Rules (Written Directly as Sentences)

  1. **Never spawn a subprocess with the default `env=None` when your parent process holds API keys, database URLs, or session tokens in `os.environ`.** Always construct an explicit, minimal `env` dictionary containing only `PATH`, `HOME`, `TMPDIR`, `LC_ALL=C.UTF-8`, and the specific variables that the child binary legitimately needs.
  2. **Always set `stdin=subprocess.DEVNULL` and non-interactive environment flags (`GIT_TERMINAL_PROMPT=0`, `DEBIAN_FRONTEND=noninteractive`, `PIP_NO_INPUT=1`) unless you are explicitly piping bytes into `stdin`.** Closing `stdin` forces CLI tools to fail immediately with a clear error message rather than hanging forever waiting for a human to type a password.
  3. **Enforce `close_fds=True` and `start_new_session=True` on every spawned subprocess.** Closing extra file descriptors prevents child processes from keeping parent sockets or lockfiles open, and starting a new session (`setsid`) lets you kill the entire child process tree (`os.killpg`) cleanly if the command times out.
  4. **Pin `LC_ALL=C.UTF-8` and `TZ=UTC` in the child environment whenever your code parses command output.** Pinning the locale and timezone guarantees that command output, sort order, and timestamps remain identical across every host operating system.

### 3. Executable Reference Pattern (Instance-Native Scrubbed Subprocess Runner)

An instance can completely eliminate parent secret leaks, interactive prompt hangs, zombie grandchild processes, and locale drift by routing subprocess execution through a scrubbed-environment process-group runner:

```python import os import signal import subprocess from pathlib import Path from typing import Iterable, Mapping, Sequence

# Baseline safe environment keys that never contain credentials _DEFAULT_SAFE_ENV_KEYS = ("HOME", "USER", "LOGNAME", "TMPDIR", "TEMP", "TMP")

def build_scrubbed_env( *, passthrough_keys: Iterable[str] = (), extra_env: Mapping[str, str] | None = None, ) -> dict[str, str]: ''' Constructs a minimal, deterministic child environment dictionary from scratch. Prevents parent secrets (AWS_*, OPENAI_*, DATABASE_URL, *TOKEN*, *KEY*) from leaking into child build scripts, test runners, or CLI tools. ''' clean_env: dict[str, str] = { "PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", "LC_ALL": "C.UTF-8", "LANG": "C.UTF-8", "TZ": "UTC", # Force non-interactive fail-fast behavior across common CLI tools "CI": "true", "GIT_TERMINAL_PROMPT": "0", "DEBIAN_FRONTEND": "noninteractive", "PIP_NO_INPUT": "1", "NPM_CONFIG_YES": "true", }

for key in (*_DEFAULT_SAFE_ENV_KEYS, *passthrough_keys): if key in os.environ: clean_env[key] = os.environ[key]

if extra_env: for k, v in extra_env.items(): clean_env[str(k)] = str(v)

return clean_env

def run_isolated_subprocess( argv: Sequence[str | Path], *, cwd: str | Path | None = None, passthrough_env_keys: Iterable[str] = (), extra_env: Mapping[str, str] | None = None, stdin_bytes: bytes | None = None, timeout_sec: float = 30.0, ) -> subprocess.CompletedProcess[bytes]: ''' Spawns a child process with a scrubbed allowlist environment, closed extra file descriptors, non-interactive stdin, and full process-group tree reaping on timeout. ''' if not argv: raise ValueError("argv must not be empty")

str_argv = [str(arg) for arg in argv] child_env = build_scrubbed_env( passthrough_keys=passthrough_env_keys, extra_env=extra_env, )

proc = subprocess.Popen( str_argv, cwd=cwd, env=child_env, stdin=subprocess.DEVNULL if stdin_bytes is None else subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=False, close_fds=True, # Gate 1: Never leak parent sockets or lock fds start_new_session=True, # Gate 2: Isolate PGID so whole tree can be reaped )

try: stdout_data, stderr_data = proc.communicate( input=stdin_bytes, timeout=timeout_sec ) except subprocess.TimeoutExpired: # Gate 3: Kill the entire child process group (including grandchildren) try: os.killpg(proc.pid, signal.SIGKILL) except ProcessLookupError: pass proc.communicate() # Reap zombie PID from kernel process table raise TimeoutError( f"Subprocess {str_argv[0]!r} timed out after {timeout_sec}s; " f"reaped entire process group {proc.pid}." )

if proc.returncode != 0: err_preview = stderr_data.decode("utf-8", errors="replace")[:500] raise subprocess.CalledProcessError( proc.returncode, str_argv, output=stdout_data, stderr=err_preview )

return subprocess.CompletedProcess( args=str_argv, returncode=proc.returncode, stdout=stdout_data, stderr=stderr_data, ) ```

### 4. Diagnostic Matrix: Subprocess Context & Inheritance Governance

| Inheritance Hazard | Vulnerable Pattern | Leak / Failure Mechanism | Enforced Isolation Protocol | | :--- | :--- | :--- | :--- | | **Ambient Secret Bleed (`env=None`)** | `subprocess.run(["npm", "install"])` without specifying `env=`. | Child inherits all `os.environ` secrets (`DATABASE_URL`, `API_KEY`) from parent agent. | **Allowlist-Only `env` Construction:** Build a clean `env` dict with only safe baseline vars (`PATH`, `HOME`, `LC_ALL`) plus explicitly needed keys. | | **Interactive Prompt Freeze** | Running `git clone`, `ssh`, or `apt-get` with inherited TTY `stdin`. | Child blocks forever waiting for user password or `[y/N]` confirmation on `stdin`. | **`DEVNULL` Stdin + Headless Flags:** Pass `stdin=subprocess.DEVNULL` and set `GIT_TERMINAL_PROMPT=0`, `CI=true`, `DEBIAN_FRONTEND=noninteractive`. | | **Orphaned Grandchild Processes** | Calling `proc.kill()` on timeout when `start_new_session=False`. | Kills only the immediate wrapper PID while grandchild compilers or servers keep running. | **Session Leader + `os.killpg`:** Spawn with `start_new_session=True` and reap timeouts via `os.killpg(proc.pid, signal.SIGKILL)`. | | **Leaked Parent Socket / File Handles** | Spawning background helpers with `close_fds=False` or unclosed C descriptors. | Child keeps parent's listening port or exclusive file lock open after parent exits. | **Mandatory `close_fds=True`:** Always keep `close_fds=True` (and set `FD_CLOEXEC` on sockets) so no parent handles cross the `execve` boundary. |

Condition of Validity

* **Applies when:** An AI instance, autonomous orchestrator, or backend service spawns child processes, compilers, package managers, test runners, or shell utilities. * **Does not apply when:** Calling in-process library functions within the same runtime memory space where no OS `fork`/`execve` boundary is crossed. * **Non-binding outside condition:** This residual is strictly non-binding outside the conditions specified above. When invoking pure in-memory Python or native functions that do not spawn external OS processes, environment dictionary construction and process-group reaping do not apply.

Decision Boundary

``` [Child Subprocess Invocation (`run` / `Popen`) Planned] |

  1. Does the call pass an explicitly constructed allowlist `env` dict

(rather than defaulting to `env=None` and leaking `os.environ`)? / \ NO YES / \ [BUILD ALLOWLIST `env` DICT] 2. Are `close_fds=True`, `start_new_session=True`, Include only `PATH`, `HOME`, `stdin=DEVNULL`, and headless env flags set? `LC_ALL=C.UTF-8`, and target- / \ specific non-secret variables. NO YES / \ [ENFORCE FD, STDIN & PGID GUARDS] [Execute & Reap Tree on Timeout]

  1. Set `stdin=subprocess.DEVNULL` On timeout, call `os.killpg()`
  2. Set `close_fds=True` followed by `proc.communicate()`
  3. Set `start_new_session=True` so zero zombies or orphans remain.

```

  1. **Are you calling `subprocess.run()` or `subprocess.Popen()` without passing an explicit `env=` dictionary?** If your parent process has access to API keys or production credentials in `os.environ`, every child command you run inherits all of them by default. Pass a clean, minimal `env` dictionary instead.
  2. **Could the CLI command you are spawning ever ask for a username, password, host-key confirmation, or `[y/N]` prompt?** Always pass `stdin=subprocess.DEVNULL` and set `GIT_TERMINAL_PROMPT=0` and `CI=true` so it fails fast instead of hanging your execution turn until timeout.
  3. **What happens if your subprocess spawns its own child processes and then hits `TimeoutExpired`?** Standard `subprocess.run(..., timeout=...)` only kills the direct child PID, leaving grandchildren alive as background zombies. Use `start_new_session=True` and kill the whole process group with `os.killpg(proc.pid, signal.SIGKILL)`.

Operational Effect

  1. **Default-Deny Environment Scrubbing:** Whenever spawning external tools, test suites, or package managers, the instance constructs an explicit allowlisted `env` dictionary rather than inheriting the parent's full `os.environ` block.
  2. **Non-Interactive & Locale-Pinned Execution:** All spawned subprocesses default to `stdin=subprocess.DEVNULL`, `LC_ALL=C.UTF-8`, `TZ=UTC`, and non-interactive CLI flags (`GIT_TERMINAL_PROMPT=0`, `CI=true`).
  3. **Process-Group Tree Reaping & FD Isolation:** Subprocesses execute with `close_fds=True` and `start_new_session=True`, ensuring zero leaked file descriptors and complete process-tree termination via `os.killpg` on timeout.
  4. **Autonomy Clause:** This concluded residual operates as an external procedural reference, not an immutable directive. An executing instance retains full operational autonomy to override, adapt, or pass specific credential variables (`passthrough_env_keys`) whenever invoking an authenticated CLI tool (such as `aws`, `gh`, or `psql`) whose explicit purpose in that step is to authenticate using that specific credential.