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.
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.
| Construct | Subshell? | Notes |
|---|---|---|
( commands ) | Yes | Explicit subshell group — always a fork |
{ commands; } | No | Brace group — runs in the current shell |
$(commands) | Yes | Command substitution — output captured, variables lost |
cmd1 | cmd2 | Yes | Both sides of a pipe run in subshells (by default) |
cmd & | Yes | Background job — fork + runs in background |
<(cmd) | Yes | Process substitution — cmd runs in a subshell |
>(cmd) | Yes | Output process substitution |
bash script.sh | Yes | New process entirely (child process, not just subshell) |
source script.sh / . script.sh | No | Runs in the current shell — variables persist |
exec cmd | No | Replaces the current shell — no fork |
if cmd; then / while cmd | No | The compound command itself runs in current shell |
cmd1 && cmd2 / cmd1 || cmd2 | No | Both sides run in current shell |
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
# ── 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.
# 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
)
# 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
}
# ── 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!
{ } 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.
# 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.
# 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
# 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
# ── 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)
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).
# ── 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.
# ── 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
# 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
# ── 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
| Construct | Subshell? | Variables persist? | cd persists? | Traps inherited? |
|---|---|---|---|---|
( cmds ) | Yes | No | No | No |
{ cmds; } | No | Yes | Yes | Yes |
$(cmds) | Yes | No | No | No |
cmd1 | cmd2 | Both sides | No | No | No |
cmd & | Yes | No | No | No |
<(cmd) | Yes | No | No | No |
source f / . f | No | Yes | Yes | Yes |
bash f | New process | No | No | No |
exec cmd | Replaces shell | n/a | n/a | n/a |
Key special variables for execution context
| Variable | Content |
|---|---|
$$ | PID of the original shell (same value in subshells — a quirk) |
$BASHPID | PID of the current shell/subshell (correct value) |
$BASH_SUBSHELL | Subshell 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.
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)
# 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
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.( 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.#!/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
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.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.#!/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
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.--, 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.#!/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