safe-chains 0.217.0

Auto-allow safe bash commands in agentic coding tools
Documentation
# Environment variables whose VALUE changes what a command does.
#
# `VAR=value cmd` is not `cmd`. A leading assignment can make the process run different code, load
# code from a different place, or reach a different system — all without changing the command name
# safe-chains classifies. This file is the researched list of variables where that is true, and the
# SHAPE of each one's value so a rule we already have can judge it.
#
# ONLY THE NAMES LISTED HERE ARE INSPECTED. An unlisted assignment is ignored exactly as before, and
# the absence of an assignment changes nothing. This file adds no denials outside itself — it is not
# a switch that makes unknown environment variables suspicious. That property is deliberate and is
# what makes the list safe to grow one entry at a time.
#
# Nothing here is "forbidden" either. `GIT_DIR=/tmp/evil` denies because `/tmp` is out-of-worktree
# under the ordinary locus rules, and `GIT_PAGER=cat` passes because `cat` passes. The value is
# classified; the name only says HOW.
#
# shape:
#   command    the value is a command line → classified by `command_verdict`, the same recursion
#              `sudo X` already uses. `GIT_PAGER=cat` allows, `GIT_PAGER='sh -c evil'` denies.
#   exec-path  the value names a path SUPPLYING CODE → gated at the executor locus, like cargo's
#              `--manifest-path`. Worktree allows; /tmp, home and system paths deny. This is not the
#              read rule: `cat /tmp/x.so` is a fine read, while LOADING /tmp/x.so is not.
#   data-path  the value names a path read or written as DATA → ordinary read/write locus.
#
# Every entry's `because` is the measured behaviour where one was taken; see
# docs/design/env-prefix-classification.md for the probes. Entries marked `measured = false` are
# enumerated from documentation because the toolchain was not installed to test against.

# ── Dynamic loader ───────────────────────────────────────────────────────────
# Injects code into the process before main. On macOS the DYLD_* pair is stripped by SIP for system
# binaries but honored for user-installed ones (Homebrew/cargo/npm) — which is most of a dev
# toolchain. LD_* is the Linux equivalent and is not restricted that way.
[env.LD_PRELOAD]
shape = "exec-path"
because = "loads an arbitrary shared object into the process before main"
[env.LD_AUDIT]
shape = "exec-path"
because = "loads an auditor library that observes and can alter symbol resolution"
[env.LD_LIBRARY_PATH]
shape = "exec-path"
because = "prepends a library search directory, so a planted .so satisfies a normal link"
[env.DYLD_INSERT_LIBRARIES]
shape = "exec-path"
because = "macOS loader injection; honored for user-installed binaries (measured)"
[env.DYLD_LIBRARY_PATH]
shape = "exec-path"
because = "macOS library search path"
[env.DYLD_FRAMEWORK_PATH]
shape = "exec-path"
because = "macOS framework search path"

# ── Shell ────────────────────────────────────────────────────────────────────
[env.BASH_ENV]
shape = "exec-path"
because = "sourced by non-interactive bash before the script runs (measured: executes)"
[env.ENV]
shape = "exec-path"
because = "the POSIX-sh equivalent of BASH_ENV"

# ── Command substitution: the value IS a command ─────────────────────────────
[env.GIT_SSH_COMMAND]
shape = "command"
because = "git runs this instead of ssh"
[env.GIT_SSH]
shape = "command"
because = "older form of GIT_SSH_COMMAND"
[env.GIT_EXTERNAL_DIFF]
shape = "command"
because = "git runs this instead of its own diff"
[env.GIT_EDITOR]
shape = "command"
because = "git launches this for interactive operations"
[env.GIT_SEQUENCE_EDITOR]
shape = "command"
because = "git launches this for the rebase todo list"
[env.GIT_PAGER]
shape = "command"
because = "git pipes output through this"
[env.GIT_ASKPASS]
shape = "command"
because = "git runs this to obtain credentials"
[env.GIT_PROXY_COMMAND]
shape = "command"
because = "git runs this to open a connection"
[env.EDITOR]
shape = "command"
because = "launched by many tools for interactive edits"
[env.VISUAL]
shape = "command"
because = "as EDITOR"
[env.PAGER]
shape = "command"
because = "output is piped through it"
[env.LESSOPEN]
shape = "command"
because = "less runs this input preprocessor on the file it is asked to show"
[env.LESSCLOSE]
shape = "command"
because = "less runs this after the preprocessor"
[env.RUSTC_WRAPPER]
shape = "command"
because = "cargo invokes this in place of rustc (measured: executes). The flag spelling, --config build.rustc-wrapper, is already denied"
[env.RUSTC_WORKSPACE_WRAPPER]
shape = "command"
because = "as RUSTC_WRAPPER, for workspace members"
[env.CARGO_BUILD_RUSTC_WRAPPER]
shape = "command"
because = "the CARGO_* config-mirror spelling of RUSTC_WRAPPER"
[env.RUSTC]
shape = "command"
because = "replaces the compiler cargo invokes"

# ── Code-supplying paths ─────────────────────────────────────────────────────
[env.PYTHONPATH]
shape = "exec-path"
because = "python auto-imports sitecustomize from this path at startup (measured: executes)"
[env.PYTHONHOME]
shape = "exec-path"
because = "relocates the interpreter's own standard library"
[env.PYTHONSTARTUP]
shape = "exec-path"
because = "executed at interactive interpreter start (measured: NOT executed for `python3 -c`)"
[env.RUBYLIB]
shape = "exec-path"
because = "ruby load path, so a planted file satisfies a later require"
[env.PERL5LIB]
shape = "exec-path"
because = "perl @INC, the lever that makes -M load attacker code"
[env.NODE_PATH]
shape = "exec-path"
because = "node module search path"
[env.NODE_REPL_EXTERNAL_MODULE]
shape = "exec-path"
because = "node loads this module in the REPL"
[env.CLASSPATH]
shape = "exec-path"
because = "JVM class search path (measured: a planted class on it was loaded and run)"
[env.DOTNET_STARTUP_HOOKS]
shape = "exec-path"
because = "the .NET host loads this assembly before main (measured: load attempted)"
[env.CORECLR_PROFILER_PATH]
shape = "exec-path"
measured = false
because = "CoreCLR loads this native profiler library"
[env.PHPRC]
shape = "exec-path"
because = "names a php.ini, which can set auto_prepend_file to execute code before every script (measured: executes)"
[env.PHP_INI_SCAN_DIR]
shape = "exec-path"
because = "a directory of additional php.ini files, same effect as PHPRC (measured: executes)"
[env.GIT_CONFIG_GLOBAL]
shape = "exec-path"
because = "git config can define aliases and core.pager, so a caller-controlled config file is code execution"
[env.GIT_CONFIG_SYSTEM]
shape = "exec-path"
because = "as GIT_CONFIG_GLOBAL"
[env.R_PROFILE_USER]
shape = "exec-path"
because = "R sources this at startup (measured: ran under Rscript)"

# ── Data paths ───────────────────────────────────────────────────────────────
# GIT_DIR and friends are exec-path, not data-path: a .git you point git at carries CONFIG, and git
# config defines aliases and core.pager. Pointing git at a foreign repo therefore runs foreign code —
# the same reasoning that makes cargo's --manifest-path an `exec` role rather than a read.
[env.GIT_DIR]
shape = "exec-path"
because = "selects which repository — and therefore whose config, aliases and pager — git obeys"
[env.GIT_WORK_TREE]
shape = "exec-path"
because = "retargets the working tree git acts on"
[env.GIT_INDEX_FILE]
shape = "data-path"
because = "retargets the index"
[env.GIT_OBJECT_DIRECTORY]
shape = "data-path"
because = "retargets object storage"
[env.GIT_ALTERNATE_OBJECT_DIRECTORIES]
shape = "data-path"
because = "additional object directories to read"
[env.GIT_COMMON_DIR]
shape = "exec-path"
because = "retargets the shared git dir, which holds config"

# ── Env twins of flags we already gate ───────────────────────────────────────
# A tool that mirrors its config surface into the environment gives every guarded flag a second
# spelling. Found by sweeping for "a flag we deny that has an environment form"; in each case the
# danger was ALREADY documented in the command's own TOML and only the spelling went unchecked.

# `cargo test --config target.<triple>.runner=X` is denied; the env spelling was not. The runner
# executes the compiled test binary, so it is a plain command substitution. The `*` stands for the
# target triple, which is why an exact-name table could not reach it.
[env."CARGO_TARGET_*_RUNNER"]
shape = "command"
because = "cargo runs this to execute the built test/bench binary; the --config spelling is denied"
[env."CARGO_TARGET_*_LINKER"]
shape = "command"
because = "cargo invokes this as the linker for that target"

# `git -c <key>=<value>` is already classified PER KEY, with `core.pager`'s value recursed as a
# command. The environment spelling splits one assignment across three variables
# (GIT_CONFIG_COUNT / GIT_CONFIG_KEY_n / GIT_CONFIG_VALUE_n), so no single value can be judged on
# its own — `GIT_CONFIG_VALUE_0` is a command when the paired key is `core.pager` and inert when it
# is `user.name`. Presence denies, which matches the flag spelling: `git -c user.name=x log` already
# denies because that key is not on git's permitted list.
[env.GIT_CONFIG_COUNT]
shape = "opaque"
because = "injects arbitrary git config; the key/value pairing is split across other variables, so a single value cannot be certified"
[env."GIT_CONFIG_KEY_*"]
shape = "opaque"
because = "as GIT_CONFIG_COUNT"
[env."GIT_CONFIG_VALUE_*"]
shape = "opaque"
because = "as GIT_CONFIG_COUNT"

# ── Interpreter option strings ───────────────────────────────────────────────
# These carry a FLAG STRING for an interpreter, so the value is parsed as flags and every token must
# match a researched-inert entry. The dangerous flags are never enumerated — a switch invented
# tomorrow denies by not being listed, which is what keeps these entries from rotting.
#
# A PATH-VALUED FLAG GOES IN `path_flags`, NEVER IN `allowed`. `allowed` matches the flag NAME and
# never looks at the value, so listing a path-valued flag there hands out an unclassified filesystem
# target: `-Cincremental` was in `allowed` and admitted `RUSTFLAGS='-Cincremental=/etc/cron.d' cargo
# build`, an out-of-worktree write, while the command-line spelling `cargo build --target-dir /etc/x`
# was denied — the env-twin-of-a-guarded-flag bug this file exists to close.
#
# `path_flags = [{ flag = "-Cincremental", role = "write" }]` instead routes the VALUE through the
# ordinary locus rules, so the env spelling and the flag spelling agree: in-worktree allows,
# out-of-worktree denies. `role` is read or write, spelled as the registry's `PathRole` spells it.
# An undeclared flag still denies, so this opens nothing by itself.
#
# AN ENTRY IS A WHOLE FLAG UNLESS IT ENDS IN `*`. `-mod` admits `-mod` and `-mod=vendor`, but NOT
# `-modfile=/tmp/evil.mod`, which is a different flag that merely starts the same way. Write the `*`
# only where the VALUE GLUES ONTO THE FLAG with no delimiter to stop it — `-Xmx*` for `-Xmx2g`, `-D*`
# for rustc's `-Dwarnings`. Every `*` widens the entry to things nobody researched, so it is a claim
# that nothing else in that tool shares the spelling; check before adding one.
#
# The accepted sets below were determined empirically (see the design note), not read from manuals:
# the Node documentation's claim that --require and --import are refused in NODE_OPTIONS is false on
# v22.17.0, where both load and execute.

# Ruby refuses -e itself. The remaining code vectors are -r<file> and -I<dir>, both paths, so they
# are excluded here and the invocation falls to approval rather than being path-classified — a
# RUBYOPT is not the place to be loading worktree code from.
[env.RUBYOPT]
shape = "option-string"
allowed = ["-w", "-W*", "-v", "--verbose", "--debug", "-E*", "-U", "-T*", "--jit", "--yjit",
           "--enable=*", "--disable=*"]
because = "ruby flag string; -r and -I load code (measured: -r executes)"

# Perl refuses -e/-E itself. -I is the lever that makes -M/-m/-d: load attacker code, so it is
# excluded; -M alone is confined to the installed @INC but is excluded too, since its danger is
# entirely contextual.
[env.PERL5OPT]
shape = "option-string"
allowed = ["-w", "-W", "-t", "-T", "-C*", "-D*"]
because = "perl flag string; -I/-M/-m/-d: load code (measured: -I -MInject and -I -d:Inj execute)"

# Node refuses only --eval/--print, so nearly everything else is accepted by the interpreter and the
# allowlist has to be narrow. Three distinct danger classes are excluded: code loading (--require,
# --import, --experimental-loader), a network-exposed debugger (--inspect*, which grants execution
# to anyone who can reach the port), and permission-model relaxers (--allow-*).
[env.NODE_OPTIONS]
shape = "option-string"
allowed = ["--max-old-space-size", "--max-semi-space-size", "--enable-source-maps",
           "--stack-size", "--no-warnings", "--no-deprecation", "--trace-warnings",
           "--trace-deprecation", "--unhandled-rejections", "--use-openssl-ca", "--use-bundled-ca",
           "--disable-warning", "--title", "--v8-pool-size"]
because = "node flag string; --require/--import execute (measured), --inspect exposes a debugger, --allow-* relax the permission model"

# `--config build.rustflags=[...]` is already denied; this is its environment twin. -C linker= and
# -C link-arg= invoke an arbitrary binary (measured: the linker ran), and -Z / --extern reach
# further, so only the diagnostic and codegen-tuning flags are listed.
[env.RUSTFLAGS]
shape = "option-string"
allowed = ["-Copt-level", "-Cdebuginfo", "-Cdebug-assertions", "-Coverflow-checks",
           "-Ctarget-cpu", "-Ctarget-feature", "-Ccodegen-units", "-Cpanic", "-Cstrip",
           "-Clto", "-D*", "-W*", "-A*", "-F*", "--cfg"]
path_flags = [{ flag = "-Cincremental", role = "write" }]
because = "rustc flag string; -C linker= runs an arbitrary binary (measured). The --config build.rustflags spelling is already denied"
[env.CARGO_BUILD_RUSTFLAGS]
shape = "option-string"
allowed = ["-Copt-level", "-Cdebuginfo", "-Cdebug-assertions", "-Coverflow-checks",
           "-Ctarget-cpu", "-Ctarget-feature", "-Ccodegen-units", "-Cpanic", "-Cstrip",
           "-Clto", "-D*", "-W*", "-A*", "-F*", "--cfg"]
path_flags = [{ flag = "-Cincremental", role = "write" }]
because = "the CARGO_* config-mirror spelling of RUSTFLAGS"
[env.RUSTDOCFLAGS]
shape = "option-string"
allowed = ["-D*", "-W*", "-A*", "-F*", "--cfg", "--document-private-items"]
because = "rustdoc flag string, same family as RUSTFLAGS"

# Applies to EVERY JVM started while set. Enumerated from the Oracle java launcher reference, then
# MEASURED on OpenJDK 26.0.2: all three variables were confirmed to execute a `-javaagent:` jar.
#
# `-XX:` CANNOT be a blanket prefix. Most -XX flags are GC/JIT tuning, but `-XX:OnError=<cmd>` and
# `-XX:OnOutOfMemoryError=<cmd>` run a custom command — direct execution wearing the same prefix as
# the harmless ones. BOTH were measured to run a script (OnOutOfMemoryError under a 16m heap;
# OnError under -XX:+CrashOnOutOfMemoryError). So the tuning flags are listed individually.
#
# `-XX:+ScavengeBeforeFullGC` is kept although OpenJDK 26 reports it "Unrecognized" — it dates to the
# ParallelGC era. An unrecognized VM option makes the JVM REFUSE TO START, so it cannot become a
# route to execution, and users on older JDKs still get the flag.
#
# `-D<name>=<value>` is NOT allowlisted, despite tuning guides calling system properties harmless.
# Properties reach security-relevant machinery (`java.rmi.server.codebase` historically enabled
# remote class loading, `jdk.attach.allowAttachSelf` opens self-attach), and telling which is which
# needs per-property research nobody has done.
#
# Excluded as code loaders: -javaagent:, -agentlib:, -agentpath:, -Xbootclasspath/a:, --module-path,
# --upgrade-module-path, @argfile, -XX:VMOptionsFile=, -XX:CompilerDirectivesFile=.
#
# -cp/-classpath/--class-path are NOT excluded — they are GATED, on JDK_JAVA_OPTIONS only. They are
# the flag spelling of CLASSPATH, which is already an `exec-path` entry, so refusing them here while
# admitting `CLASSPATH=./lib` was the same one-spelling-checked inconsistency this file exists to
# close; measured live on `mvn test` and `gradle build`, both of which auto-approve.
[env.JAVA_TOOL_OPTIONS]
shape = "option-string"
allowed = [
  "-Xms*", "-Xmx*", "-Xmn*", "-Xss*", "-verbose:*",
  "-XX:MaxDirectMemorySize", "-XX:ThreadStackSize", "-XX:MaxGCPauseMillis",
  "-XX:ReservedCodeCacheSize", "-XX:InitialCodeCacheSize", "-XX:CompileThresholdScaling",
  "-XX:+UseG1GC", "-XX:+UseSerialGC", "-XX:+UseParallelGC", "-XX:+UseZGC",
  "-XX:+ScavengeBeforeFullGC", "-XX:+UseStringDeduplication",
  "-XX:+TieredCompilation", "-XX:-TieredCompilation",
  "-XX:+PrintCommandLineFlags", "-XX:+PrintCompilation", "-XX:+HeapDumpOnOutOfMemoryError",
]
path_flags = [{ flag = "-XX:HeapDumpPath", role = "write" }]
because = "applies to every JVM started while set; -javaagent loads code and -XX:OnError/-XX:OnOutOfMemoryError run a command (all measured)"
[env._JAVA_OPTIONS]
shape = "option-string"
allowed = [
  "-Xms*", "-Xmx*", "-Xmn*", "-Xss*", "-verbose:*",
  "-XX:MaxDirectMemorySize", "-XX:ThreadStackSize", "-XX:MaxGCPauseMillis",
  "-XX:ReservedCodeCacheSize", "-XX:InitialCodeCacheSize", "-XX:CompileThresholdScaling",
  "-XX:+UseG1GC", "-XX:+UseSerialGC", "-XX:+UseParallelGC", "-XX:+UseZGC",
  "-XX:+ScavengeBeforeFullGC", "-XX:+UseStringDeduplication",
  "-XX:+TieredCompilation", "-XX:-TieredCompilation",
  "-XX:+PrintCommandLineFlags", "-XX:+PrintCompilation", "-XX:+HeapDumpOnOutOfMemoryError",
]
path_flags = [{ flag = "-XX:HeapDumpPath", role = "write" }]
because = "as JAVA_TOOL_OPTIONS; takes precedence over it (measured: -javaagent ran)"
[env.JDK_JAVA_OPTIONS]
shape = "option-string"
allowed = [
  "-Xms*", "-Xmx*", "-Xmn*", "-Xss*", "-verbose:*",
  "-XX:MaxDirectMemorySize", "-XX:ThreadStackSize", "-XX:MaxGCPauseMillis",
  "-XX:ReservedCodeCacheSize", "-XX:InitialCodeCacheSize", "-XX:CompileThresholdScaling",
  "-XX:+UseG1GC", "-XX:+UseSerialGC", "-XX:+UseParallelGC", "-XX:+UseZGC",
  "-XX:+ScavengeBeforeFullGC", "-XX:+UseStringDeduplication",
  "-XX:+TieredCompilation", "-XX:-TieredCompilation",
  "-XX:+PrintCommandLineFlags", "-XX:+PrintCompilation", "-XX:+HeapDumpOnOutOfMemoryError",
]
path_flags = [
  { flag = "-XX:HeapDumpPath", role = "write" },
  # LAUNCHER options, accepted here and NOWHERE ELSE (measured on OpenJDK 26.0.2: `-cp` in
  # JAVA_TOOL_OPTIONS/_JAVA_OPTIONS is "Unrecognized option" and the JVM refuses to start, while
  # here it loaded and ran a planted class). Gated at the executor locus, so they agree with the
  # CLASSPATH entry: `./lib` allows, `/tmp/evil` denies.
  { flag = "-cp", role = "exec", sep = "space" },
  { flag = "-classpath", role = "exec", sep = "space" },
  { flag = "--class-path", role = "exec", sep = "space" },
]
because = "the LAUNCHER-processed spelling: a superset of JAVA_TOOL_OPTIONS that also takes -cp (measured: -javaagent ran; -cp ran a planted class)"

# Go's default build flags, measured on go1.26.5. `-toolexec` invokes an arbitrary program in place
# of the toolchain — the direct analogue of RUSTC_WRAPPER — `-exec` runs the built binary through
# one, and `-overlay` maps disk paths to different backing files, substituting source before
# compilation. `-toolexec` and `-overlay` were both confirmed: the overlay probe printed the planted
# source, not the file on disk. `-ldflags`/`-gcflags` pass through to the linker and compiler and are
# excluded for the reason RUSTFLAGS' `-C link-arg` is.
#
# The whole-flag rule matters most here: `-mod` must not admit `-modfile`, which was measured to
# change the module the build resolves (`go list -m` reported the planted module name).
[env.GOFLAGS]
shape = "option-string"
allowed = ["-v", "-x", "-n", "-count", "-tags", "-race", "-mod", "-short", "-timeout",
           "-run", "-skip", "-buildvcs", "-trimpath", "-json"]
because = "-toolexec runs an arbitrary program and -overlay substitutes source files before compilation (both measured); -exec runs the built binary through one"

# LUA_INIT is code OR a path depending on its first character: a leading `@` names a file to run,
# anything else IS Lua source executed at interpreter start. Both forms were measured to run on Lua
# 5.5.0. One shape cannot express that, and Lua source is not something this classifier reads, so
# presence denies.
[env.LUA_INIT]
shape = "opaque"
because = "executes its contents as Lua, or runs the file named by a leading @, at interpreter start (measured: BOTH forms ran)"

[env.JULIA_LOAD_PATH]
shape = "exec-path"
because = "julia module search path (measured: a planted module satisfied a `using` and ran)"
[env.JULIA_DEPOT_PATH]
shape = "exec-path"
because = "the depot CONTAINS config/startup.jl, so pointing it anywhere runs that file at startup — no import needed (measured)"
[env.CORECLR_ENABLE_PROFILING]
shape = "opaque"
measured = false
because = "arms the CoreCLR profiler pair; the library it loads is named by CORECLR_PROFILER_PATH, so neither value certifies alone"