Configuration Policy
Mutant takes no configuration from environment variables. Using Mutant must never require anyone to set, or even think about, an environment variable.
Mutant takes no configuration from environment variables. Using Mutant must never require anyone to set, or even think about, an environment variable.
This document states the rule, the reasoning behind it, the narrow set of environment reads that are permitted and why they are not configuration, and what replaces the configuration surface that used to be documented here.
The rule is machine-enforced. See The guard below.
1. The rule#
Three sub-rules, in order of strictness.
Rule 1 — no Mutant-prefixed variable name may appear in Go source. No exceptions, no allowlist.
The guard matches any string literal of the form MUTANT_ followed by upper-case
letters, digits and underscores. This is the load-bearing rule, because that
prefix is exactly the set of names someone could be told to set. If the name is
not in the binary, no instruction to set it can be true.
Rule 2 — reading a foreign environment variable is permitted only where the value is observed, never obeyed.
Sandbox and VM detection, debugger and injection indicators, and the
process_env forensic builtin all read the environment. None of them let the
environment change a decision a user asked for. Each site is allowlisted
individually with a justification. The same holds for the few places the
standard library reads it on Mutant's behalf -- a name the REPL prints, the
temporary directory -- and section 3 lists what the environment can still
change.
Rule 3 — writing the environment is permitted only when invoking a build-time
child go build.
GOOS, GOARCH and CGO_ENABLED have no command-line equivalent; the Go
toolchain accepts them only through the child process environment. This applies
to mutant release cross-target builds and the WASM REPL builder — never to a
Mutant program's own execution.
The distinguishing test#
Would a Mutant user ever set this variable in order to change what Mutant does?
If yes, it is prohibited — use a flag.
| A variable that... | Is set by | Verdict |
|---|---|---|
| turns on stage timing | the user, to see where a run spends its time | Prohibited. Replaced by --timing. |
| selects a protection profile | the user, to weaken or harden checks | Prohibited. Removed; the profile is fixed at standard. |
| carries a trusted public key in hex | the user, to supply a key | Prohibited. Key material never belongs in the environment, where it is invisible in the invocation and inherited by every child process. A key file path belongs on the command line: --trusted-key. |
| overrides the tamper response | the user, to soften a termination | Prohibited. Derived from the execution mode instead. |
SANDBOXIE |
Sandboxie, when it wraps a process | Permitted. Mutant observes it as evidence of where it is running. |
LD_PRELOAD |
whoever injected a library | Permitted. An indicator of compromise, read and reported. |
GOOS |
Mutant, for a child go build |
Permitted. Written, not read; no flag exists. |
HOME, USERPROFILE |
the shell or the launcher | Not read. The keystore once followed them; since 2.6.0 its location comes from the account database. |
TMPDIR |
the operating system | Permitted. It says where working files sit, never what Mutant reports. |
Every prohibited row above once existed and has been removed. Their names are
not reproduced here: a name in the documentation is exactly what leads someone
to try setting it, and none of them has done anything for some time. The
HOME row is not one of them: those variables belong to the system, and
Mutant stopped reading them (M26-DOC3-001).
The test is about direction of authority, not about the os package. Mutant
reading SANDBOXIE is Mutant looking at its surroundings. Mutant obeying a
variable of its own would be the surroundings looking at Mutant.
2. Why#
A run must be reproducible from its invocation alone. Mutant is a forensic tool. An analyst records the command they ran; that record has to be sufficient to reproduce the result. An environment variable is an unlogged, invisible control channel that never appears in that record — two analysts running the same command line can get different behaviour and have no way to see why.
The environment is the untrusted thing under examination. Mutant inspects processes and the machine it runs on. Taking instructions from the same environment it is examining inverts the trust relationship.
Nobody should need a setup step. Installing Mutant and running a program is the whole story. There is no variable to export, no shell profile to edit, and nothing to forget on a second machine.
3. What is permitted, and where#
Six categories. Every site is listed in policy.EnvAccessAllowlist
(policy/env_policy.go) with a line budget, so a new
read added inside an already-allowlisted function still fails the guard.
Detection — Mutant observing where it is running#
Sandbox, VM, container and debugger detection is core Mutant infrastructure and is not configuration. Knowing whether the binary is running inside Sandboxie, Cuckoo, VirtualBox, WSL, Windows Defender Application Guard, the macOS App Sandbox, or under a debugger with a library injected is a product capability. The variables it reads are set by that other software, never by a Mutant user.
| Site | Reads |
|---|---|
security/sandbox_helpers.go — envSet |
the single choke point all sandbox env probing funnels through |
security/sandbox_windows.go — detectSandboxWindows |
USERNAME, USERPROFILE, and the Windows sandbox indicator set |
security/antidebug_linux.go — hasDebuggerEnvironmentMarkers |
LD_PRELOAD and Linux debugger markers |
security/antidebug_darwin.go — hasDebuggerEnvironmentMarkersDarwin |
DYLD_INSERT_LIBRARIES and macOS debugger markers |
security/antitamper_detectors.go — detectFridaPtrace |
Frida instrumentation markers |
Accepted property. Because detection can halt a secure-mode run, someone who can set
SANDBOXIE=1in Mutant's environment can make Mutant refuse to execute. This is inherent to environment-based sandbox detection, it fails closed rather than open, and it is accepted rather than treated as a defect. It is not a configuration channel: the only outcome it can produce is a refusal, and it cannot weaken any check.
Evidence — Mutant reporting on a subject process#
builtin/system_forensics.go — ProcessEnv, backing the process_env builtin.
It returns another process's environment, or its own, as forensic output. The
value is reported, never acted on. This is the product, not a setting.
Toolchain — build-time `go build`#
generator/release_assets.go — buildReleaseRuntimeBinary,
cmd/wasmreplserve/main.go — ensureWasmBinary, and
builtin/fingerprint_builtins_test.go — TestImphashOnWindowsPE. Each appends
GOOS/GOARCH/CGO_ENABLED to a child go build's environment. Removing them
would break cross-target releases and the WASM REPL, and there is no flag
equivalent. buildReleaseRuntimeBinary also builds in the temporary directory,
as the scratch sites below do.
Display — a value Mutant prints#
repl/repl.go — welcome. The REPL greeting prints the account's display
name. Built without cgo on Linux, os/user answers for a uid that is not in
/etc/passwd from USER and HOME. That answer has no display name, so the
greeting names no one, and nothing else uses it. With either variable unset
that lookup fails and the REPL panics (M26-TOOL-031).
Scratch — working files in the temporary directory#
builtin/sqlite_builtins.go — withSQLiteCopy, the copy of a database an
sqlite_* builtin queries, so the evidence file is never opened for writing;
cmd/sweep/main.go — resolveMutantBinary and newScratch, the binary and the
copy of the examples the sweep runs. Each creates its files in the operating
system's temporary directory (TMPDIR; TMP, TEMP or USERPROFILE on
Windows) and deletes them when the work is done. Where they sit changes no
result.
Regression tests — proving a variable is not obeyed#
security/key_bootstrap_test.go — TestTheKeystoreDoesNotFollowTheEnvironment
points HOME and USERPROFILE at two directories in turn and requires the
keystore not to move. Setting a variable to show that nothing follows it is the
rule's own test, and the guard allows it only in a test file.
What the environment can still change#
Mutant reads no variable of its own, and the guard holds every read in its code
to the sites above. What remains is read on Mutant's behalf by the Go runtime,
the standard library and the libraries Mutant links. This is the complete list
for 2.6.0, from a survey on 2026-09-28 of every package linked into mutant and
mlsp for Windows, Linux and macOS, and the probes that followed it:
| Variables | What they change | Status |
|---|---|---|
PATH (with PATHEXT on Windows) |
which program runs when Mutant starts one by name: go for mutant release, the shell exec_string and cmd_run start, the system tools the sandbox and debugger probes call, clear in the REPL |
accepted: whoever sets PATH already chooses every program the session runs |
| every variable | is inherited by each child process, so the Go toolchain's own variables (GOFLAGS, GOTOOLCHAIN, GOPROXY and the rest) apply to the go build that mutant release runs |
accepted: a child is configured the way that program is configured |
TMPDIR (TMP, TEMP, USERPROFILE on Windows), SQLITE_TMPDIR |
where working files and SQLite's temporary files go | accepted: see Scratch above |
TZ |
the zone local time is in: the offset with which time_now and the fs_* listings' modification times are printed, and a Rego policy's "Local" zone (Linux and macOS); and the zone SQLite's localtime modifier converts to (every system, on Windows only in the C library's own form, such as UTC or EST5EDT) |
accepted where a time is printed with its offset, since the instant printed does not change; where it is not -- a Rego clock, SQLite's localtime -- accepted for 2.6.0: a policy or query that must not depend on the machine names UTC and does not use localtime (M26-DAT-031) |
ZONEINFO |
the rules of a zone a Rego policy names in time.format, time.date, time.clock, time.weekday, time.add_date or time.diff |
accepted for 2.6.0: a policy that must not depend on the machine names UTC; zone rules carried inside Mutant are planned for the next release (M26-DAT-031) |
PWD (Linux and macOS) |
how an absolute path Mutant makes from a relative one is spelled, when the working directory was reached through a symbolic link | accepted: Go uses it only when it names the working directory |
GODEBUG, GOGC, GOMEMLIMIT, GOMAXPROCS, GOTRACEBACK |
the Go runtime's memory, scheduling and crash output, and standard-library defaults, among them TLS, X.509 and archive-path checks | accepted: every Go program reads them before its first line runs |
Three libraries are handed what they would otherwise look up. The http_*
builtins and lua_run_http send through a transport that names no proxy, so
HTTP_PROXY, HTTPS_PROXY and NO_PROXY go unread (M26-NET-010). On Linux,
http_* requests over TLS, net_tls_connect and net_tls_upgrade_client read
the system's certificate authorities from where Go reads them with
SSL_CERT_FILE and SSL_CERT_DIR unset, so neither variable decides whom they
trust (M26-NET-010). gopsutil is told that /proc is /proc, so HOST_PROC
and the other HOST_* variables no longer decide what the process_* builtins
read (M26-NET-025). A Rego policy is not offered the builtins that reach the
network: http.send, whose client took its proxy from the environment and its
timeout from HTTP_SEND_TIMEOUT (M26-DAT-030), and json.match_schema and
json.verify_schema, which fetch a schema's remote $ref through the same proxy
(M26-DAT-032). OPA still reads HTTP_SEND_TIMEOUT when it starts, for a builtin
no policy can call.
Everything else a linked library reads is on a path Mutant does not take, or
changes nothing it reports: Lua's os.getenv is removed before a script runs,
the evtx message resolver and the PE parser behind it are never called, gRPC,
protobuf, logrus, SQLite's memory auditor and OPA's WebAssembly compiler read
settings only for features Mutant does not use, and net/http's
DEBUG_HTTP2_GOROUTINES turns on internal assertions and nothing else.
The home directory is not on the list. The keystore takes it from the account
database: the token's profile directory on Windows, the directory service on
macOS, /etc/passwd on Linux, read directly because os/user built without
cgo answers from HOME for an account that file does not list. Such an account
-- one only NSS or LDAP knows, or an unnamed container uid -- is refused, not
located through HOME.
4. What replaces configuration#
Command-line flags, and nothing else.
A configuration file was considered and rejected. The knobs the removed
environment variables claimed to control no longer exist in the code — a
mutant.toml would have almost nothing to put in it, and would reintroduce the
same problem the environment had: behaviour that is not visible in the
invocation. Per-run decisions belong on the command line, where they are
recorded.
Preference order for anything new:
- A CLI flag for a per-run decision.
- A file path passed by flag for anything larger than a flag value — a key file, an allowlist, a ruleset. Never the content itself, and never key material: a path is safe to record in case notes, a private key is not.
- A fixed default if neither fits. Most things belong here.
Credentials are the exception to rule 1#
A password is the one input that must not travel as a flag value, and the policy above is what forces the shape of the alternative.
An environment variable would be the obvious answer and is ruled out here: it is inherited by every child process, survives in the parent shell, and never appears in the command line an analyst records. That it is also a configuration read makes it doubly excluded, but it would be the wrong answer even if the policy allowed it.
An argv flag is not much better. --password <value> is visible in ps, Task
Manager, and process-creation EDR telemetry for the whole life of the process,
and it lands in shell history. The command line being recorded is exactly the
property this document wants everywhere else, and exactly the property a
credential must not have.
So credentials use rule 1's structure with the value removed:
| Source | Shape |
|---|---|
| Interactive prompt | Nothing is recorded anywhere. The default. |
--password-file <path> |
Rule 2: a path on argv, content on disk. |
--password-stdin |
A pipe. Nothing on argv, nothing on disk. |
A bare --password (or --pwd) is refused since 2.6.0, as 2.5.0 announced.
--password-insecure <value> keeps the argv form for someone who accepts the
exposure and says so on the command line. See credential/credential.go.
The --password-file permission check is POSIX-only. On Windows the mode bits
os.FileInfo reports are synthesised from the read-only attribute rather than
read from the ACL that governs access, so enforcing 0600 there would pass for
a file granted to Everyone and fail for nothing. It is skipped rather than
faked.
What is not configurable, and why#
So a reader does not go looking for a switch that is not there:
| Behaviour | Actual value | Where |
|---|---|---|
| Protection profile | fixed at standard; minimal/paranoid remain only so V3 release trailers can be read back |
security/profile.go — ResolveProtectionProfile |
| Tamper response | derived from mode: terminate in secure mode, warn in --compat/--dev |
security/response_policy.go — ResolveTamperResponse |
| Tamper delay | 250 ms constant, and no mode selects the delay response it belongs to, so no run waits on it |
security/response_policy.go — DefaultTamperDelayMs |
| Process protection | always on; its five probes run on Windows only, so on Linux and macOS it measures nothing and the run says so on stderr | runner/runner.go — isProcessProtectionEnabled |
| Process-protection terminate threshold | confidence 80 |
runner/runner.go — processProtectionTerminateConfidence |
| Anti-tamper probe | on by default; the setter exists for tests | security/antitamper_probe.go — antiTamperProbeEnabled |
| Remote process scan | off; reachable only from tests | security/processscan_config.go |
| Capability configuration | none, and none is planned. A builtin runs with this process's own permissions; declined 2026-10-10. | no gate exists; the decision is note 1 in SECURITY_LLD_TRACEABILITY.md |
Mode selection (--secure / --compat / --dev), signer enforcement
(--signer-auth / --no-signer-auth), the trusted key file (--trusted-key),
security log level (--security-log-level) and stage timing (--timing) are
all flags. Run mutant --help for the current list.
5. The guard#
go test ./policy/..., which runs as part of the ordinary go test ./...,
parses every .go file in the repository and fails on:
- any string literal that is a Mutant-prefixed variable name (rule 1, no allowlist consulted);
- any
Getenv/LookupEnv/Setenv/Unsetenv/ExpandEnv/Environ/EnvironWithContext/Clearenvselector outsidepolicy.EnvAccessAllowlist; - a standard-library call that reads the environment inside itself, under
whatever name the file imports its package by:
os.UserHomeDir,os.UserCacheDir,os.UserConfigDirandos.TempDir;os.MkdirTemp,os.CreateTemp,ioutil.TempDirandioutil.TempFilewith an empty directory;net/http'sDefaultClient,DefaultTransportandProxyFromEnvironment, its package-levelGet,Head,PostandPostForm, and anhttp.Clientmade with no transport of its own, all of which send through the proxy the environment names; and, in a file that builds for Linux without cgo,user.Current,user.Lookup,user.LookupIdandx509.SystemCertPool; - a dot-import of
os,os/userorsyscall; - an assignment to a
.Envfield, or aCmdcomposite literal with anEnv:key, outside the allowlist; - an allowlist entry whose line budget no longer matches, and an allowlist entry that matches nothing at all.
It works on the AST rather than on text, which is why it has no false positives
against the ~73 object.Environment / NewEnvironment occurrences in
evaluator/, vm/, repl/ and webrepl/. It parses build-tagged files
regardless of the host OS, which matters because most detection code lives
behind //go:build windows|linux|darwin.
Failing the guard is not a defect. It means either the access does not belong there — use a flag — or the allowlist needs an entry and the commit needs to say why.
Known gaps#
The guard is Go-only. The VS Code extension's .mjs build scripts are not
machine-checked by it, and since the project runs no CI, nothing checks them on
a schedule either. The extension's own build-target variable was removed in
favour of a --target argument, but nothing prevents a new one being added.
The guard sees only the calls it names. A library that reads the environment by
itself -- net/http's proxy default inside another library, gopsutil's
HOST_PROC behind a call that names no directory, crypto/x509's store behind
a TLS client that names no certificate authorities, OPA's zone names through
ZONEINFO -- is invisible to it, and so is a new dependency that does the same.
The list in What the environment can still change
is kept by hand (M26-TEST-013).
6. Limits#
Every value that bounds what Mutant will do -- how much it reads into memory,
how deep it recurses, how long it waits, how many entries it keeps, how many
times it retries -- is a named constant in the package that enforces it,
explained where it is declared, and listed with its value in
LIMITS_REFERENCE.md, which go run ./cmd/gendocs
generates from the source.
A limit is not configuration. None is read from the environment or from a file; the few a user may change are command-line flags, and the reference names the flag beside the value.
A limit is declared like this:
// maxArchiveEntries bounds how many members an archive may list before the
// reader stops, so a crafted index cannot exhaust memory.
//
//mutant:limit count
const maxArchiveEntries = 1 << 20
The directive names a unit (bytes, kibibytes, bits, count, depth,
duration, milliseconds, microseconds, iterations, percent, ratio or
score) and, when a flag overrides the value, flag=--name. A constant that
counts in kibibytes or in whole milliseconds says so, and the reference prints it
as the size or the time it is. The prose above it is required: a limit whose
reason is not written down is the hidden default this section exists to remove.
A value fixed by a file format or a protocol is not a limit; it is marked
//mutant:format <specification> and stays out of the reference. The reference
lists the limits of a run first, and after them those of the tools that build,
test and document Mutant, which bound no run.
go test ./policy/ enforces it (policy/limit_guard_test.go, with the rules in
policy/limitscan). It fails on a limit written as a bare literal in a shape it
recognises:
| Rule | Shape |
|---|---|
| L1 | an allocation of 1024 or more (make) |
| L2 | an input/output cap: Scanner.Buffer, LimitReader, CopyN, New*Size, MaxBytesReader |
| L3 | a duration: n * time.Second, time.Duration(n) |
| L4 | a comparison with a depth, retry, budget or similar bound, or len/cap against 1024 or more |
| L5 | a retry loop, or a loop of 1000 or more iterations |
| L6 | a command-line flag default |
| L7 | a limit-named field or variable given a literal |
| L8 | a limit constant declared inside a function |
| L9 | a size passed as an argument: built with << or * to 1024 or more, or 65536 or more |
| N1 | a constant named like a limit (max…, …Timeout, …Size) without a directive |
It also fails on a malformed directive. Hexadecimal, octal and binary literals in
a comparison, or given to a limit-named field, are left alone -- that is how this
tree writes format checks and layout tables, such as len(data) < 0x40 and
Size: 0x40.
What was already in the tree when the guard arrived is held in a budget,
policy/limit_budget.go, seeded on 2026-09-24 at 174 findings in 82 files. It
lists each finding by its rule, the function it is in and the expression as
written, rather than keeping a count per file, so naming one limit cannot make
room for a new one beside it. It only shrinks: a finding it does not list fails,
and so does an entry no finding answers to, so an entry comes out in the same
change that names its limit.
The guard is a floor, not a proof. It recognises limits by shape and by name, so a limit in a shape none of the rules describes is not caught. Binary-format offsets are deliberately not guarded, on the stated grounds that their bounds are for fuzzing to test.
Nine fuzz targets answer that in part. They sit in lexer/fuzz_test.go,
parser/fuzz_test.go and lsp/internal/server/fuzz_test.go, and between them
they hold the lexer to reaching the end of its input, to a literal that matches
the token type it is given, and to positions that index the text it was handed;
the parser to terminating inside its recovery caps, to recorded ranges that are
ranges in the source, and to printing every message with a position; and the
formatter to a parse, format and re-parse round trip that returns the same tree.
The round trip found a defect in the lexer's float flag within half a minute of
first being run, which is the argument for the whole exercise.
All nine read source text. None of them reads a binary container, so the offsets
in the format readers under builtin/ are at present held by neither the rules
above nor a fuzzer, and the exemption they are given records an intention rather
than a coverage claim. A reader who wants those bounds checked should read the
corpus tests beside each reader, which compare a decode against a known image,
and not this exemption.
This document is the one place in the repository where the prohibited name prefix appears, because the rule cannot be stated without it. No specific variable name appears anywhere in the documentation or in Go source.