python3 -m traust.cli util safe-exec is the enforced command validator every harness step uses when it executes something derived from an audited checkout — build systems, test suites, scanners that load target code, and string-form PoC steps in live validation. Gate rule S10 (python3 -m traust.cli check skill-security) makes routing through it mandatory at commit time: a skill or script that invokes target build machinery without referencing safe_exec fails the pre-commit and CI gates.
The design keeps the containment that matters for an auditing harness — argv
allowlisting, env scrubbing, and shell-free execution — and deliberately does
not deny scripting engines or git clone outright, because cloning and
building targets is the harness's core function.
A repository under audit is hostile code. Its Makefile, go.mod directives,
npm lifecycle scripts, and build tags run with whatever the invoking process
holds — ambient cloud credentials, GITHUB_TOKEN, kube contexts. safe_exec
bounds that in three ways:
- Argv validation — the command's head binary must be granted by the
active profile; shells are never grantable (pipelines execute natively via
subprocess chaining, so a quoted newline or
;is data, not a second command). Hard-denied binaries, dangerousgit -ckeys, git network subcommands, curl exfil flags (-F/-T/-d @/--config/--netrc/file://), and kubectl/oc override flags (--kubeconfig/--context/--token/--as) are rejected regardless of profile. - Env scrubbing — the child sees a minimal environment plus the profile's
keep_envallowlist. A hostile build cannot read tokens that were never in its environment. Assignments to protected vars (GIT_*, PATH, LD_*, …) in command strings are rejected. - No shell, ever — commands and pipelines are parsed and executed as
argv vectors.
safe_execmay not re-invoke itself, and interpreter heads (python3 -c,node -e) are denied unless the profile grants the interpreter explicitly.
The same threat has a second execution surface: the agent runtime itself. A
target repository can ship its own agent configuration — .claude/,
CLAUDE.md, hooks, settings — and an agent launched with its working
directory inside the clone will load that configuration and run the target's
hooks with the auditor's credentials. So every headless or batch agent launch
runs with:
- its working directory outside the untrusted checkout (a scratch directory; the clone is passed as a path argument), and
- target-supplied agent configuration never loaded as configuration — it is data under audit, read by the analysis and never honoured by the runtime.
Gate rule S9 enforces this at commit time: any file that launches a headless agent must state the isolation. This is the execution-side half of the adversarial-content doctrine (adversarial-content-doctrine.md rule 4); the doctrine says why, this section says what the launch must look like.
Policy lives in $TRAUST_CONFIG_HOME/safe-exec-profiles.yaml (an embedded
fallback copy of the validation-step profile lives in the module — keep them
in sync; the harness ships the template as
config/safe-exec-profiles.example.yaml). Each profile declares allow
(binary heads), optional allowed_path_heads (e.g. ./gradlew),
allow_pipelines, and keep_env. Current profiles and their consumers:
| Profile | Used by | Notes |
|---|---|---|
validation-step |
validate-findings adapters (string-form PoC steps) | pipelines allowed; KUBECONFIG + VF_OAUTH_TOKEN kept (the token keeps bearer-auth probes sound) |
go-scan |
python3 -m traust.cli adapters govulncheck | govulncheck type-checks and builds the target module — hostile build tags/cgo see only the scrubbed env |
go-fuzz, java-build, python-test, node-build, rust-fuzz, generic-build |
build/test lanes (fuzz-harness and patch-verification steps) | dependency-manager egress from build tools is accepted residual risk: safe_exec constrains argv and environment, not child-process sockets; hermetic prefetch is a deferred hardening |
SAFE_EXEC_MODE=warn|enforce—warnlogs what enforce would have blocked and proceeds; used only during a calibration window for a newly routed lane, after which the lane flips to enforce.SAFE_EXEC_DISABLED=<reason>— loud operator bypass. Library callers do NOT honour it by default (honor_bypass=False); when honoured it prints to stderr and appends to the bypass log (~/.local/state/…, 0700). A bypass with no recorded reason is a finding, not a convenience.- Blocked commands return exit 126 with a
[safe_exec blocked: …]reason. In validate-findings, a blocked step becomes aninconclusiveverdict — never a silent pass, and never grounds for arefuted(refutation-soundness gate).
# validate one argv without running it
python3 -m traust.cli util safe-exec check --profile validation-step -- oc get pods -A
# vet a command string (pipelines parsed, no shell)
python3 -m traust.cli util safe-exec check --profile validation-step --string 'oc get po | grep x'tests/test_safe_exec.py in traust-engine covers the deny classes, curl and
kube flag hardening, pipeline parsing, env protection, and the recursion cap;
each consumer carries its own regression file. Rule S9 and S10 gate tests live
in the harness's tests/test_check_skill_security.py.