Subshells and Execution Environments

🌀 Intermediate Topic 4 — Subshells and Execution Environments

One of the most common sources of baffling bugs in bash scripts is the variable that "disappears" — a counter that never increments, a flag that's always unset after a loop, an array that's empty when it should have elements. The culprit, almost every time, is an unexpected subshell. Understanding exactly when bash creates a subshell, what it copies into it, what it doesn't, and how information can (and can't) flow back to the parent is the key to writing scripts that behave as intended. This chapter maps the entire execution environment model.

1 — The Execution Environment

Every running bash shell has an execution environment: the complete set of state that determines what commands do. When bash creates a subshell it uses fork() — the OS creates an exact copy of the current process. The subshell starts with an identical environment but then diverges: any changes it makes are local to its copy and never flow back to the parent.

The execution environment — what each shell instance holds: Variables — shell variables (only exported ones pass to child processes) Functions — defined with fn() { } or function fn { } Aliases — defined with alias Options — set -e, set -u, set -x, shopt -s extglob, etc. Traps — registered with trap (NOT inherited by subshells) Working dir — current directory (cd in a subshell stays local) Umask — file creation mask Open FDs — file descriptors (inherited, but exec in subshell stays local) $0, $1… — positional parameters $$ — PID (subshell keeps the parent's PID — use $BASHPID for own PID) What a subshell gets (fork copy): Everything above, at the moment of fork What flows back to the parent: Nothing — ever. Only the exit code. Exception — exporting via stdout: capture output with $() and re-assign in parent
🐧 Proving the isolation — variables don't propagate back
x=10 # Subshell created by ( ) ( x=99 echo "inside subshell: x=$x" → inside subshell: x=99 ) echo "after subshell: x=$x" → after subshell: x=10 ← unchanged # Same for cd — directory changes don't escape a subshell echo "before: $(pwd)" ( cd /tmp echo "inside: $(pwd)" → /tmp ) echo "after: $(pwd)" → /original/dir ← unchanged # $$ vs $BASHPID echo "parent PID via \$\$: $$" echo "parent PID via \$BASHPID: $BASHPID" ( # $$ still shows the PARENT pid (historical bash quirk) # $BASHPID shows the actual subshell PID echo "subshell \$\$: $$" → same as parent (quirk!) echo "subshell \$BASHPID: $BASHPID" → different PID (correct) ) # $BASH_SUBSHELL — depth counter (0 = top-level, increments with nesting) echo "depth: $BASH_SUBSHELL" → 0 ( echo "depth: $BASH_SUBSHELL" → 1 ( echo "depth: $BASH_SUBSHELL" → 2 ) )

2 — What Creates a Subshell?

Many constructs create subshells silently. Knowing them all is essential to predicting where your variables will and won't be visible.

ConstructSubshell?Notes
( commands )YesExplicit subshell group — always a fork
{ commands; }NoBrace group — runs in the current shell
$(commands)YesCommand substitution — output captured, variables lost
cmd1 | cmd2YesBoth sides of a pipe run in subshells (by default)
cmd &YesBackground job — fork + runs in background
<(cmd)YesProcess substitution — cmd runs in a subshell
>(cmd)YesOutput process substitution
bash script.shYesNew process entirely (child process, not just subshell)
source script.sh / . script.shNoRuns in the current shell — variables persist
exec cmdNoReplaces the current shell — no fork
if cmd; then / while cmdNoThe compound command itself runs in current shell
cmd1 && cmd2 / cmd1 || cmd2NoBoth sides run in current shell
The pipeline subshell rule: In bash (unlike ksh/zsh), every command in a pipeline — including the last one — runs in a subshell. This means a while read loop at the end of a pipe cannot modify variables in the parent script. This is the single most common "my variable is always zero" bug.

The last-pipe option — shopt -s lastpipe

🐧 lastpipe — run the last pipeline command in the current shell
# ── The classic bug ──────────────────────────────────────────── count=0 cat data.txt | while IFS= read -r line; do (( count++ )) done echo "count: $count" → 0 (always zero — loop ran in subshell) # ── Fix 1: process substitution (Topic 2 pattern) ───────────── count=0 while IFS= read -r line; do (( count++ )) done < <(cat data.txt) echo "count: $count" → correct count (loop in current shell) # ── Fix 2: shopt -s lastpipe (bash 4.2+, non-interactive only) ─ shopt -s lastpipe count=0 cat data.txt | while IFS= read -r line; do (( count++ )) done echo "count: $count" → correct (lastpipe runs last cmd in current shell) # Note: lastpipe only works when job control is disabled (set +m), # which is the default in non-interactive scripts # ── Fix 3: redirect a file directly (no pipe, no subshell) ───── count=0 while IFS= read -r line; do (( count++ )) done < data.txt echo "count: $count" → correct (no subprocess at all)

3 — ( ) vs { } — Subshell vs Brace Group

These two constructs look similar and do similar things — but one creates a subprocess, the other doesn't. Knowing which to reach for is one of the most impactful micro-decisions in bash scripting.

( ) — Subshell group — new process
# Creates a child process via fork() # Variables, cd, set options — all isolated # Traps are NOT inherited # More expensive (fork + exec overhead) # No semicolon needed before closing ) ( cd /tmp # stays in subshell set -e # only affects subshell x=99 # invisible to parent risky_cmd # if it fails, only subshell exits )
{ } — Brace group — same process
# No fork — runs in the current shell # Variables and cd persist to parent # Traps ARE shared # Fast (no subprocess overhead) # REQUIRES semicolon before closing } { cd /tmp # changes current shell's dir set -e # affects the whole script x=99 # visible after the block risky_cmd # failure propagates out }
🐧 When to use each deliberately
# ── Use ( ) when you WANT isolation ────────────────────────── # Safe cd: change directory, do work, return automatically ( cd /path/to/project && make ) # No risk of stranding the parent in /path/to/project # Try a risky operation without affecting the script's set -e if ( ssh -o ConnectTimeout=3 "$host" 'exit 0' ); then echo "$host is reachable" fi # Modify options or traps without affecting the outer script ( set +e # temporarily disable exit-on-error some_flaky_cmd rc=$? # set -e is restored automatically when subshell exits ) # Back to set -e here # ── Use { } when you want grouping WITHOUT isolation ────────── # Group redirections over multiple commands without a subshell { date uptime df -h } > system_report.txt # Short-circuit with || and still affect parent state { echo "Error: missing argument" >&2; exit 1; } # Group a multi-line pipeline that needs to set a variable { echo "start" process_data echo "end" } | tee output.log # Note: brace group itself is still subshelled when it's in a pipeline!
Even a { } brace group runs in a subshell if it appears inside a pipeline (e.g. { ...; } | cmd). The pipeline is what creates the subshell, not the braces. Braces only prevent an extra fork that ( ) would add.

4 — Environment Variables and Inheritance

There are two kinds of variables in bash: shell variables (local to the current shell) and environment variables (inherited by child processes via the process's environ). The distinction matters whenever you launch an external command, a script, or a subshell.

🐧 export, env, and inheritance rules
# Shell variable — NOT inherited by child processes local_var="hello" bash -c 'echo "$local_var"' → (empty — child doesn't see it) # Exported variable — IS inherited export ENV_VAR="world" bash -c 'echo "$ENV_VAR"' → world # export makes an existing variable part of the environment my_var="value" export my_var # promote to environment # Inline export for one command only — does not persist API_KEY="secret" curl https://api.example.com # API_KEY is only in curl's environment, not the shell # Functions are NOT inherited by child processes unless exported greet() { echo "Hello, $1"; } bash -c 'greet "world"' → greet: command not found export -f greet # export the function bash -c 'greet "world"' → Hello, world # unexport — remove from environment (keep as shell variable) export -n my_var # demote from environment # env — run command with a clean or modified environment env -i bash -c 'echo "$HOME"' → (empty — -i clears all env vars) env -i HOME=/tmp PATH=/bin bash # start with minimal env # Inspect the environment printenv # list all exported variables printenv PATH # print a specific variable declare -x # same as printenv but in declare format

5 — source vs Execute: Loading vs Running

When you run a script with bash script.sh or ./script.sh, bash forks a new process. When you source a script with source script.sh or . script.sh, the commands run directly in the current shell — no fork. This distinction determines whether the script's variables, functions, and directory changes affect the calling environment.

bash script.sh — new process
# Completely separate process # Inherits exported variables only # Its functions/vars stay inside it # Its cd doesn't move the parent # Parent gets exit code only # script.sh: MY_FUNC="defined" cd /tmp # After running it: echo "$MY_FUNC" → (empty) pwd → original dir
source script.sh — current shell
# No fork — runs in THIS shell # All variables become available # Functions are defined here # cd actually changes your dir # exit in sourced file exits YOU # After sourcing it: echo "$MY_FUNC" → defined pwd → /tmp
🐧 Practical sourcing patterns
# ── Library files — source to load functions ────────────────── # lib/logging.sh defines log(), die(), warn() etc. source "$(dirname "$0")/lib/logging.sh" # or with dot syntax (POSIX-compatible alias): . "$(dirname "$0")/lib/logging.sh" # ── Config files — source to set variables ──────────────────── # config.env contains: DB_HOST=prod.db.example.com [[ -f config.env ]] && source config.env # ── Guard against sourcing vs executing ─────────────────────── # Detect if the script is being sourced or executed directly if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then # Running as a standalone script main "$@" else # Being sourced as a library — only define functions, don't run true fi # This is the standard "library with optional main" pattern # ── BASH_SOURCE array — stack of sourced files ──────────────── # ${BASH_SOURCE[0]} = current file (or "" if interactive) # ${BASH_SOURCE[1]} = the file that sourced us # ${BASH_SOURCE[-1]} = the original script where_am_i() { echo "Called from: ${BASH_SOURCE[1]}:${BASH_LINENO[0]}" echo "Defined in: ${BASH_SOURCE[0]}" } # Robust path to current script (works whether sourced or executed) SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
Never use exit in a file intended to be sourced — it will exit the calling shell. Use return instead; it exits the sourced context only, just like returning from a function. This is a common gotcha in library scripts.

6 — exec: Replacing the Current Process

exec with a command replaces the running shell with the specified program — no fork, no parent left behind. It's used to hand off control to a final command (saving a process slot), to wrap scripts around another program, and — when used without a command — to set persistent redirections (covered in Topic 2).

🐧 exec patterns for process replacement
# ── Hand off to the real program ────────────────────────────── #!/usr/bin/env bash # wrapper.sh — set up environment then become the real program export APP_CONFIG=/etc/myapp/config.yaml export LOG_LEVEL="INFO" ulimit -n 65536 # raise file descriptor limit exec /opt/myapp/bin/myapp "$@" # This shell is GONE — myapp IS the process now # The PID stays the same; myapp gets it # Nothing after exec ever runs # ── Use exec to save a process slot in pid-1 containers ─────── # In Docker, PID 1 receives signals. If your entrypoint is a shell # and it calls exec, the app becomes PID 1 and gets signals directly #!/bin/sh # docker-entrypoint.sh setup_config exec "$@" # exec whatever was passed as the docker CMD # ── exec in a subshell — exits the subshell, not the parent ─── ( exec cat /etc/hostname # exec replaces the subshell process with cat # parent shell is unaffected ) echo "parent still running" # ── exec for privilege separation ───────────────────────────── # Drop privileges before exec'ing the app if [[ $EUID -eq 0 ]]; then exec su -s /bin/bash -c "exec \"$0\" \"$@\"" appuser fi # Script re-runs itself as appuser; this branch only fires as root

7 — Returning Data from Subshells

Since variables can't propagate from a subshell to its parent, you need an explicit channel. There are four patterns, each with different trade-offs.

🐧 The four patterns for getting data out of a subshell
# ── Pattern 1: stdout capture with $() ──────────────────────── # Clean, composable, but creates a subshell and only returns a string get_hostname() { hostname -f } host=$(get_hostname) # Limitation: trailing newlines are stripped by $() with_newlines=$'line1\nline2\n' captured="$(echo "$with_newlines")" # trailing \n stripped! # Workaround: add a sentinel, strip it after captured="$(echo "$with_newlines"; echo x)" captured="${captured%x}" # remove sentinel # ── Pattern 2: exit code only ────────────────────────────────── # When you only need a pass/fail result — most efficient if ( grep -q 'error' logfile.txt ); then echo "Errors found" fi # ── Pattern 3: temp file ──────────────────────────────────────── # For large or structured data; allows multiple outputs tmpf=$(mktemp) trap 'rm -f "$tmpf"' EXIT ( result1="computed" result2="also computed" declare -p result1 result2 # write declarations to the file ) > "$tmpf" source "$tmpf" # reload the declarations in parent echo "$result1 $result2" → computed also computed # ── Pattern 4: namerefs (preferred for functions) ────────────── # From Topic 1 — pass a variable name, write via nameref # No subshell created — works inside functions in the same shell compute() { local -n _out="$1" _out="the result" } compute my_var echo "$my_var" → the result # Note: functions called as $() STILL run in a subshell # val=$(compute result) ← nameref useless here, $() makes a subshell

8 — Performance, Gotchas, and Best Practices

🐧 Avoiding unnecessary subshells — the performance angle
# Every $() creates a fork. In a tight loop, this adds up. # Benchmark on a modern Linux box: ~1–2ms per fork # ── SLOW: $() in a loop ─────────────────────────────────────── for i in {1..100}; do ts=$(date '+%s') # fork + exec date, 100 times echo "$i: $ts" done # ── FAST: use printf %(%s)T (bash built-in, no fork) ────────── for i in {1..100}; do printf -v ts '%(%s)T' -1 # bash built-in timestamp, no fork echo "$i: $ts" done # ── Replace common $() patterns with built-ins ──────────────── # dirname / basename — use parameter expansion instead path=/usr/local/bin/myprog dir="$(dirname "$path")" # fork dir="${path%/*}" # no fork — much faster base="$(basename "$path")" # fork base="${path##*/}" # no fork # tr for case conversion — use ${var^^} / ${var,,} instead upper="$(echo "$str" | tr '[:lower:]' '[:upper:]')" # 2 forks upper="${str^^}" # no fork # wc -c for string length — use ${#var} len="$(echo -n "$str" | wc -c)" # 2 forks len="${#str}" # no fork # printf -v — assign to variable without a subshell result="$(printf '%05d' $n)" # fork for $() even though printf is builtin printf -v result '%05d' "$n" # no fork — assigns directly
🐧 Common gotchas checklist
# ── Gotcha 1: exit in a subshell only exits the subshell ─────── function check() { ( [[ -f "$1" ]] || exit 1 # exits the () subshell, not the function echo "file exists" ) } check /nonexistent echo "still running" → still running (script continues!) # Fix: check the subshell's exit code with $? # ── Gotcha 2: set -e doesn't propagate into $() ───────────── set -e result=$(false; echo "still ran") # 'false' fails but set -e doesn't kill the script here # The $() subshell exits non-zero, and THAT propagates up # So the assignment fails and set -e fires — but the echo ran first # ── Gotcha 3: traps reset in subshells ────────────────────────── trap 'echo "parent cleanup"' EXIT ( trap -p EXIT # shows empty — EXIT trap is NOT inherited by subshells ) # ── Gotcha 4: read after a pipe ─────────────────────────────── # (the classic bug — covered in section 2) declare -a lines cat file.txt | mapfile -t lines echo "${#lines[@]}" → 0 (mapfile ran in a subshell) # Fix: mapfile -t lines < file.txt # no pipe, no subshell # or: mapfile -t lines < <(generate_lines) # process substitution

9 — Quick Reference

Construct comparison

ConstructSubshell?Variables persist?cd persists?Traps inherited?
( cmds )YesNoNoNo
{ cmds; }NoYesYesYes
$(cmds)YesNoNoNo
cmd1 | cmd2Both sidesNoNoNo
cmd &YesNoNoNo
<(cmd)YesNoNoNo
source f / . fNoYesYesYes
bash fNew processNoNoNo
exec cmdReplaces shelln/an/an/a

Key special variables for execution context

VariableContent
$$PID of the original shell (same value in subshells — a quirk)
$BASHPIDPID of the current shell/subshell (correct value)
$BASH_SUBSHELLSubshell nesting depth (0 = top level)
${BASH_SOURCE[0]}Path of the current file (or empty if interactive)
${BASH_SOURCE[-1]}Path of the outermost script
${FUNCNAME[0]}Name of the current function (or "main")
${BASH_LINENO[0]}Line number where the current function was called from

✏️ Exercises

Each exercise is designed to expose a real-world consequence of subshell behaviour — the kinds of bugs that quietly ship to production. Trace through each one mentally before running it, then verify your predictions.

Exercise 1
The following script has a silent bug: the total variable is always 0 at the end. Identify exactly why, then rewrite it in three different correct ways — using process substitution, using shopt -s lastpipe, and using direct file redirection. All three versions must produce the correct sum.
total=0 seq 1 10 | while IFS= read -r n; do (( total += n )) done echo "Total: $total" → Total: 0 (should be 55)
Sample Solution
# The bug: the pipe creates a subshell for the while loop. # $total is incremented inside the subshell's copy, never the parent's. # When the subshell exits, its copy of $total vanishes. # ── Fix 1: process substitution — while loop runs in current shell total=0 while IFS= read -r n; do (( total += n )) done < <(seq 1 10) echo "Total: $total" → Total: 55 # ── Fix 2: shopt -s lastpipe — last cmd in pipe runs in current shell shopt -s lastpipe total=0 seq 1 10 | while IFS= read -r n; do (( total += n )) done echo "Total: $total" → Total: 55 shopt -u lastpipe # restore default if needed # ── Fix 3: direct file redirection — no subprocess at all total=0 tmpf=$(mktemp) seq 1 10 > "$tmpf" while IFS= read -r n; do (( total += n )) done < "$tmpf" rm -f "$tmpf" echo "Total: $total" → Total: 55 # Bonus: the cleanest one-liner (awk, no bash variable involved at all) seq 1 10 | awk '{s+=$1} END{print "Total:", s}' → Total: 55
Exercise 2
Write a script called safe_cd.sh that implements a with_dir() function which: changes to a specified directory, runs a command, then automatically returns to the original directory — even if the command fails. The function should work correctly whether it's a single command or a pipeline. Demonstrate it with three calls: a successful command, a failing command (verify the original directory is restored), and a command that uses the changed directory for a glob pattern.
Hint: the simplest implementation wraps the work in a subshell ( cd "$dir" && "$@" ). The cd is local to the subshell. For pipelines, use bash -c "..." or a function with eval. Think about how to handle a command that needs to be a pipeline.
Sample Solution
#!/usr/bin/env bash # safe_cd.sh set -uo pipefail with_dir() { # Usage: with_dir DIRECTORY COMMAND [ARGS...] local dir="${1:?with_dir: directory required}" shift [[ -d "$dir" ]] || { echo "with_dir: '$dir' is not a directory" >&2; return 1; } # Subshell: cd is local, command runs, shell exits, parent is unaffected ( cd "$dir" || return 1 "$@" ) } with_dir_eval() { # Variant that accepts a pipeline string (uses bash -c) local dir="${1:?with_dir_eval: directory required}" local cmd="$2" [[ -d "$dir" ]] || { echo "with_dir_eval: '$dir' is not a directory" >&2; return 1; } ( cd "$dir" && eval "$cmd" ) } # ── Demonstration ───────────────────────────────────────────── echo "Starting directory: $(pwd)" # 1. Successful command echo "\n--- Test 1: successful command ---" with_dir /etc ls -1 hosts passwd echo "After: $(pwd)" → original dir (unchanged) # 2. Failing command — directory must still be restored echo "\n--- Test 2: failing command ---" with_dir /tmp ls /this/does/not/exist || echo "(command failed, as expected)" echo "After: $(pwd)" → original dir (still unchanged) # 3. Command using a glob in the changed directory echo "\n--- Test 3: glob in changed directory ---" with_dir /etc bash -c 'echo *.conf | tr " " "\n" | head -5' echo "After: $(pwd)" → original dir # 4. Pipeline variant echo "\n--- Test 4: pipeline ---" with_dir_eval /var/log 'ls -lt | head -3' echo "After: $(pwd)" → original dir
Exercise 3
Write a library file called lib_utils.sh that: (1) can be both sourced and executed, (2) defines three utility functions: trim(), is_number(), and repeat(), (3) when executed directly (not sourced), runs a self-test that exercises each function and reports pass/fail, and (4) uses ${BASH_SOURCE[0]} to detect which mode it's in. When sourced, it should define the functions silently. When executed, it should print a test report.
Hint: the pattern is if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then run_tests; fi. For trim(), use parameter expansion from Topic 1. For is_number(), use a regex with [[ =~ ]]. For repeat(), use a loop or printf. Write the test function to call each and compare actual to expected output.
Sample Solution
#!/usr/bin/env bash # lib_utils.sh — utility library (sourceable and self-testing) # ── Library functions ───────────────────────────────────────── trim() { # Trim leading and trailing whitespace from a string # Usage: trim STRING or result=$(trim STRING) local str="$1" # Strip leading whitespace str="${str#"${str%%[! $'\t']*}"}" # Strip trailing whitespace str="${str%"${str##*[! $'\t']}"}" printf '%s' "$str" } is_number() { # Return 0 (true) if the argument is an integer or float # Usage: is_number VALUE [[ "$1" =~ ^-?[0-9]+(\.[0-9]+)?$ ]] } repeat() { # Repeat a string N times # Usage: repeat STRING COUNT local str="$1" n="${2:?repeat: count required}" local i for (( i=0; i < n; i++ )); do printf '%s' "$str" done printf '\n' } # ── Self-test (only runs when executed directly) ────────────── _run_tests() { local pass=0 fail=0 _assert_eq() { local desc="$1" got="$2" want="$3" if [[ "$got" == "$want" ]]; then printf '\033[32m PASS\033[0m %s\n' "$desc"; (( pass++ )) else printf '\033[31m FAIL\033[0m %s\n got: [%s]\n want: [%s]\n' \ "$desc" "$got" "$want"; (( fail++ )) fi } _assert_true() { local desc="$1"; shift if "$@"; then printf '\033[32m PASS\033[0m %s\n' "$desc"; (( pass++ )) else printf '\033[31m FAIL\033[0m %s (returned false)\n' "$desc"; (( fail++ )) fi } printf '\033[1mlib_utils.sh — self test\033[0m\n' printf '%.0s─' {1..40}; echo # trim() _assert_eq "trim: leading spaces" "$(trim ' hello')" "hello" _assert_eq "trim: trailing spaces" "$(trim 'hello ')" "hello" _assert_eq "trim: both sides" "$(trim ' hello world ')" "hello world" _assert_eq "trim: already trimmed" "$(trim 'no spaces')" "no spaces" _assert_eq "trim: empty string" "$(trim '')" "" # is_number() _assert_true "is_number: integer" is_number 42 _assert_true "is_number: negative" is_number -7 _assert_true "is_number: float" is_number 3.14 _assert_true "is_number: rejects word" ! is_number "abc" _assert_true "is_number: rejects mixed" ! is_number "12abc" # repeat() _assert_eq "repeat: 3 times" "$(repeat 'ab' 3)" "ababab" _assert_eq "repeat: 0 times" "$(repeat 'x' 0)" "" _assert_eq "repeat: 1 time" "$(repeat '-' 1)" "-" printf '%.0s─' {1..40}; echo printf '%d passed %d failed\n' "$pass" "$fail" (( fail == 0 )) } # ── Entry point detection ────────────────────────────────────── if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then # Executed directly: run self-test _run_tests fi # When sourced: functions are silently defined, no tests run
Exercise 4
Write a script called env_sandbox.sh that runs a command in a controlled environment: it accepts a list of KEY=VALUE overrides as arguments followed by a -- separator and then the command. It should run the command with: (a) the parent's environment as a base, (b) only specified overrides applied, and (c) a set of variables always removed (configurable via SANDBOX_STRIP, defaulting to AWS_SECRET_ACCESS_KEY:GITHUB_TOKEN:DATABASE_URL). Print the final environment and exit code. Use a subshell for isolation.
Hint: parse arguments until you hit --, collecting KEY=VALUE pairs into an array. Use env -u KEY to unset specific variables. The command to run is everything after --. Use env KEY=VAL ... CMD to set overrides and unset sensitive vars in one call.
Sample Solution
#!/usr/bin/env bash # env_sandbox.sh [KEY=VAL ...] -- COMMAND [ARGS...] set -uo pipefail SANDBOX_STRIP=${SANDBOX_STRIP:-"AWS_SECRET_ACCESS_KEY:GITHUB_TOKEN:DATABASE_URL"} # Parse: collect overrides until --, then the command overrides=() cmd=() found_sep=0 for arg in "$@"; do if (( found_sep )); then cmd+=( "$arg" ) elif [[ "$arg" == "--" ]]; then found_sep=1 elif [[ "$arg" == *"="* ]]; then overrides+=( "$arg" ) else echo "Usage: env_sandbox.sh [KEY=VAL ...] -- COMMAND [ARGS]" >&2 exit 1 fi done (( ${#cmd[@]} > 0 )) || { echo "No command specified after --" >&2; exit 1; } # Build env -u flags for stripped variables unset_flags=() IFS=':' read -r -a strip_vars <<< "$SANDBOX_STRIP" for v in "${strip_vars[@]}"; do unset_flags+=( -u "$v" ) done # Print sandbox summary printf '\033[36m[sandbox]\033[0m Command: %s\n' "${cmd[*]}" printf '\033[36m[sandbox]\033[0m Overrides: %s\n' "${overrides[*]:-none}" printf '\033[36m[sandbox]\033[0m Stripped: %s\n' "$SANDBOX_STRIP" echo # Run in a subshell — isolation is belt-and-suspenders here ( env "${unset_flags[@]}" "${overrides[@]}" "${cmd[@]}" ) rc=$? printf '\n\033[36m[sandbox]\033[0m Exit code: %d\n' "$rc" exit "$rc" # Example usage: # DATABASE_URL=postgres://prod ./env_sandbox.sh DATABASE_URL=postgres://test -- env | grep DB # GITHUB_TOKEN=secret ./env_sandbox.sh -- env | grep GITHUB ← GITHUB_TOKEN is stripped