perch simulate โ what would this program do on that host?¶
The missing third tool in perch's pre-flight suite.
Tool Inputs Output What it answers perch --checksource pass/fail "Is the syntax valid?" perch --scansource capability report "What capabilities does it need overall?" perch simulatesource + hypothetical env per-op outcome tree "What would happen if I ran this on a host with THESE properties?" perch --dry-runsource + real env op-list (no execution) "What ops would fire right now?" perch testsource + real env pass/fail per test "Does behavior match assertions?" perch <cmd>source + real env the actual output "What happens when I run it?"
TL;DR¶
$ perch simulate deploy --sim-os=linux --sim-have-bin=kubectl \
--sim-allow-host=api.github.com \
--sim-fs-write=/srv
โโ command deploy โ Apply prod manifests
โ print "==> deploy starting"
โ if os eq linux
โณ condition os eq "linux" evaluates TRUE (sim os="linux") โ body runs
โ exec kubectl apply -f manifest.yaml
โ if os eq darwin
โณ condition os eq "darwin" evaluates FALSE (sim os="linux") โ body skipped
? http_get "https://api.github.com/repos/foo/bar"
โข server at "api.github.com" could redirect to any host
perch re-checks every redirect against the allowlist;
this op succeeds if redirects stay within the allowlist
โ write_file "/etc/passwd"
โณ write path "/etc/passwd" is outside sim --allow-write roots (allowed: /srv)
? write_file "${HOME}/notes.txt"
โณ target path not statically determinable
summary: 5 will-run ยท 1 will-fail ยท 2 uncertain
1 op(s) would fail under the simulated environment
Exit code 0 if every op would run; non-zero if any op definitively fails. Drop into CI as a pre-merge gate.
Why this exists¶
--scan is static: it tells you what capabilities the program needs, but not whether your target host actually has them. --dry-run requires you to be ON the target host. simulate is the missing piece: answer "would this work?" without leaving your laptop and without running anything.
Concrete uses:
- Compliance reviewer: "If this script runs in the prod environment (no
/etcwrites, only the corporate registry, only these env vars), what would it actually do?" - CI guard: "Refuse to merge a PR if
simulatereports anyWILL_FAILunder our standard prod env." - Migration planning: "We're moving from macOS to Linux runners โ what breaks in our build pipeline?"
- Plugin acceptance: "Customer submitted a
.perchextension; simulate it under our strictest sandbox before we accept it." - Onboarding: "What env vars does a fresh dev's machine need? Run
simulatewith--sim-env-only=HOMEand see what reports as missing."
Outcome classification¶
Every op gets one of three verdicts:
| Glyph | Outcome | Meaning |
|---|---|---|
| โ | WILL_RUN |
Every check the simulator can perform passes against the sim env. |
| โ | WILL_FAIL |
At least one check definitively fails. Exit code reflects this. |
| ? | MIGHT_FAIL |
Outcome depends on runtime data the simulator can't statically know โ values inside ${var}, server-side HTTP redirects, computed shell argv, etc. Reasons + scenarios are listed. |
The summary line at the end reports the aggregate counts.
CLI surface¶
COMMAND is the command name to simulate. If omitted, simulates every public command.
Sim flags¶
Each is independent. Omitting a flag means "no restriction in that dimension."
| Flag | Effect | Example |
|---|---|---|
--sim-os OS |
Pretend the host is OS | --sim-os=linux |
--sim-arch ARCH |
Pretend the host arch | --sim-arch=arm64 |
--sim-env K=v,K=v,... |
Set sim host env vars | --sim-env=HOME=/home/x,USER=x |
--sim-env-only |
Use with --sim-env: restrict envs to ONLY listed names |
with above: any ${OTHER} fails |
--sim-fs-read PATH,... |
Sim has these paths readable | --sim-fs-read=/srv,/etc |
--sim-fs-write PATH,... |
Sim allows writes under these | --sim-fs-write=/tmp,/srv/data |
--sim-have-bin NAME,... |
Sim has these on PATH | --sim-have-bin=docker,kubectl |
--sim-allow-host HOST,... |
Sim network allowlist | --sim-allow-host=api.github.com,*.s3.amazonaws.com |
--sim-no-shell |
Sim equivalent of --no-shell |
(boolean) |
--sim-no-subprocess |
Sim equivalent of --no-subprocess |
(boolean) |
--sim-no-network |
Sim equivalent of --no-network |
(boolean) |
--sim-no-write |
Sim equivalent of --no-write |
(boolean) |
--sim-file PATH |
JSON fixture with capabilities + oracles + scenarios (see "Stateful simulation" below) | --sim-file=fixtures/staging.json |
All flags compose. They mirror perch's runtime --no-* / --allow-* / --env flags so you can simulate exactly the invocation you plan to run.
What the simulator catches¶
Capability mismatches¶
exec kubectl ... when the sim env doesn't have kubectl in --sim-have-bin:
โ exec kubectl apply -f manifest.yaml
โณ exec binary "kubectl" not in sim --allow-bin allowlist (have: docker, git)
Sandbox-style flags¶
exec when the sim env declares --sim-no-subprocess:
Write outside allowed roots¶
โ write_file "/etc/passwd"
โณ write path "/etc/passwd" is outside sim --allow-write roots (allowed: /srv)
Network host violations¶
โ http_get "https://attacker.com/exfil"
โณ HTTP host "attacker.com" not in sim --allow-host allowlist (have: api.github.com)
Env var visibility¶
With --sim-env-only plus --sim-env=HOME=/x:
โ exec deploy --token=${API_TOKEN}
โณ references ${API_TOKEN} but sim --env restricts host envs to HOME
Conditional branches resolved against the sim env¶
โ if os eq linux
โณ condition os eq "linux" evaluates TRUE (sim os="linux") โ body runs
โ exec apt-get install jq
โ if os eq darwin
โณ condition os eq "darwin" evaluates FALSE (sim os="linux") โ body skipped
The simulator doesn't waste your time showing failures inside branches that would never run.
Predicate calls¶
if exists "PATH" is checked against --sim-fs-read; if has_bin "X" against --sim-have-bin. The body simulates only if the predicate would evaluate true.
MIGHT_FAIL with reasons¶
When the simulator can't reach a definite verdict:
? shell "${BUILD_CMD}"
โข argv[0] = "${BUILD_CMD}" (contains unresolved interpolation)
value depends on runtime bindings
? http_get "https://api.github.com/foo"
โข server at "api.github.com" could redirect to any host
perch re-checks every redirect against the allowlist;
this op succeeds if redirects stay within the allowlist or there are no redirects
Composition โ sandbox / cache / parallel blocks¶
Each block-op modifies the simulated environment for its body. sandbox narrows capabilities; with_env adds env vars; both compose with the outer sim env.
โ sandbox "no_subprocess,no_network"
โ exec echo hi
โณ subprocess capability denied by sim --no-subprocess (within sandbox block)
โ print "still works โ no subprocess needed"
Cross-command dispatch¶
a bare command invocation recurses โ the simulator follows the call and simulates that command's body too, with the same sim env. Useful for catching that a bare setup invocation depends on --sim-have-bin=brew even if the parent command looks fine.
Stateful simulation, oracles, and scenarios¶
Pure capability-mode (--sim-* flags only) answers "can each op run in this environment?" It does NOT answer:
- "What if the file exists after the previous step?"
- "What if HTTP returns 500?"
- "What if
git rev-parse HEADreturns this specific value?" - "What if the upstream redirects to a host I haven't allowlisted?"
For those, point simulate at a JSON fixture file with --sim-file FIXTURE.json. The fixture declares capabilities + oracles (concrete simulated outputs for ops the static walker can't otherwise resolve) + named scenarios (override sets that branch the simulation).
Fixture file shape¶
{
"os": "linux", "arch": "amd64",
"env": {"HOME": "/h", "PATH": "/usr/bin"},
"fs_write": ["/tmp"],
"bins": ["git", "curl"],
"network": ["api.github.com"],
"oracles": {
"file_exists": {"/tmp/manifest.yaml": true},
"shell_output": {"git rev-parse HEAD": "abc123"},
"http": {"https://api.github.com/health": {"status": 200, "body": "OK"}},
"has_bin": {"kubectl": true}
},
"scenarios": [
{"name": "happy", "overrides": {}},
{"name": "github-down","overrides": {
"http": {"https://api.github.com/health": {"status": 500, "body": "down"}}
}},
{"name": "redirect-evil","overrides": {
"http": {"https://api.github.com/health": {"status": 302, "redirect": "https://evil.com/payload"}}
}}
]
}
What the stateful walker does¶
- State threads through ops.
write_file "/tmp/x"records the file as existing; downstreamif exists "/tmp/x"evaluates true.rmflips it back.cd /srvshifts the cwd used to resolve relative paths. letcaptures consult oracles.rev = shell_output "git rev-parse HEAD"looks up the post-interpolation command inoracles.shell_output. If present,${rev}resolves to the simulated value; if absent,${rev}is marked symbolic and downstream uses surface MIGHT_FAIL.- HTTP outcomes are oracled per URL. Status 2xx โ WILL_RUN; 4xx/5xx โ WILL_FAIL with the simulated body; 3xx with a
redirectfield โ MIGHT_FAIL, and if the redirect destination's host isn't in your network allowlist โ WILL_FAIL (the practical answer to "what if upstream redirects me to evil.com?"). has_binoracles override the capability list. Lets you simulate "what ifkubectlis suddenly missing?" without removing it frombins.
Scenarios¶
Each entry in scenarios runs as one independent walk with its own report, sharing the top-level capabilities + oracles but overlaying the scenario's overrides. Empty scenarios is treated as one implicit default scenario. Per-scenario env overrides let you tweak the environment too โ e.g. "what if GITHUB_TOKEN is missing in this scenario?"
Running it¶
Each scenario produces a banner, then the per-op report. Exit code is non-zero if any scenario reports a failure โ drop straight into CI as a multi-environment gate.
CLI --sim-* flags layer on top of the fixture (CLI wins on conflict), so you can combine --sim-file env.json --sim-no-network to force-disable network for a one-off without editing the fixture.
What the simulator does NOT catch (yet โ roadmap)¶
- Symbolic branching on unknown conditions โ if a condition depends on a runtime value that has no oracle, the simulator presents the body as "MIGHT run." It doesn't enumerate "if X were Y, this branch fires; if X were Z, that branch fires."
wasm_runbody deep analysis โ the simulator notes the module path but doesn't simulate the WASM module's behavior (the module sees a tighter capability surface by construction; that'swasm_run's point).- Counterfactual suggestions โ e.g. "Add
api.github.comto your--allow-hostto make this pass" is a future idea. - Persistent state between scenarios โ each scenario starts from a fresh state seeded from the top-level fixture. Chained scenarios ("start in state from scenario A, then run B") are a future idea.
Each of these gracefully degrades to MIGHT_FAIL with a reason explaining what the simulator couldn't resolve. Honest, not lossy.
How simulate differs from related tools¶
vs perch --scan¶
--scan is schema-shaped: it produces a capability summary ("needs shell, hits these hosts, writes these roots, uses these env vars") plus a list of static risk findings. It doesn't take an env โ it tells you what you'd need to provide.
simulate is per-op: it takes the env you'd provide and walks every op telling you exactly which would succeed, fail, or branch. Use --scan to know what's needed; use simulate to test specific scenarios.
vs perch --dry-run¶
--dry-run shows the plan on the current host. It honors the real os, real ${HOME}, real files-on-disk. Useful when you're at the keyboard of the target host.
simulate takes a hypothetical host. Useful when the target isn't your machine โ CI, a customer environment, a future-state migration target.
vs perch test¶
perch test actually runs commands marked test in a sandboxed cwd. It catches behavioral bugs.
simulate doesn't run anything. It catches capability/structural mismatches before you spend test cycles.
You want both: simulate as a pre-merge gate ("this won't break in prod"), perch test as a behavioral gate ("the logic is correct").
CI integration¶
# .github/workflows/predeploy.yml
- name: Simulate deploy against prod env
run: |
perch -f deploy.perch simulate deploy \
--sim-os=linux --sim-arch=amd64 \
--sim-env-only=KUBECONFIG,PATH \
--sim-have-bin=kubectl,helm,docker \
--sim-allow-host=*.acme.com \
--sim-fs-write=/srv/deploy \
--sim-no-subprocess
Exit non-zero on any WILL_FAIL โ PR blocked.
For uncertainty: optionally fail on ? outcomes too with --strict (roadmap โ not in v1). Today, ? is informational; only โ triggers a non-zero exit.
See also¶
docs/sandbox.mdโ the capability model the simulator mirrorsperch --scan -f FILEโ the static cousin (no env input; produces a capability summary). Run it as a CLI command; there's no separate doc page yet.docs/execution-contexts.mdโ the block ops (sandbox,parallel, etc.) the simulator recurses into