Using perch today¶
Read this if you want to use perch right now. A lot of recent design docs (sandboxed-by-design, trust-by-manifest) describe where perch is heading — default-deny capabilities, named capability handles, inline
|/&&/||, WASM manifests. That part is roadmap, not shipped. But several pieces have landed: theexecverb (shell-free subprocess), thepipe … endblock,glob, the line toolbox (grep/head/…),read/writepath scopes, and#comments all work today. This page documents what actually works in the current build, with syntax that parses today.
The model that's live today¶
perch runs a .perch file. The same file is a CLI, a web UI (--server), a REPL (--shell), an MCP tool surface (perch-mcp), and a portable binary (--build).
Security today is ambient-by-default, restricted two ways:
- The operator restricts at launch with flags:
--no-shell,--no-network,--no-write,--no-subprocess,--allow-bin,--allow-host,--env,--untrusted. - The author can add a
requiresblock to the file (opt-in). When present, it enforces strictly — undeclared shell bins / hosts / env reads error.
The planned inversion to zero ambient authority (nothing works unless declared) is roadmap, not current. Today a file with no
requiresblock and no CLI flags has full ambient access.
A complete, working file¶
Everything below parses and runs in the current build:
name "myapp"
about "Build and ship myapp"
version "0.3.0"
BUILD_DIR = "./builds"
requires
bin "go"
bin "git"
env "HOME"
host "api.github.com"
write "./builds" # the filesystem is external too — declare what you write
read "./cmd" # ...and what you read
end
command build
description "Compile myapp for one target"
arg target
type string
default "darwin"
description "Target OS"
end
do
print "Building for ${target}"
mkdir "${BUILD_DIR}/${target}"
with_env "GOOS=${target}"
go build -o ${BUILD_DIR}/${target}/myapp ./cmd/myapp
end
size = file_size "${BUILD_DIR}/${target}/myapp"
print "built ${size} bytes"
end
end
command setup
description "Install dev deps, per OS"
do
if os == "darwin"
brew install jq ripgrep
end
if os == "linux"
sudo apt-get install -y jq ripgrep
end
end
end
catch passthrough
description "Forward unknown verbs to git"
proxy_args
do
shell "git ${proxy_args}"
end
end
Run it:
perch -f myapp.perch build # or just `perch build` if the file is commands.perch
perch -f myapp.perch build -target=linux
perch -f myapp.perch --help
perch -f myapp.perch --check # static validation
perch -f myapp.perch status # falls through catch → `git status`
Top-level sections (all shipped)¶
| Section | Purpose |
|---|---|
name / about / version |
Metadata for --help / --version. |
NAME = value (top level) |
Bindings shared by every command; UPPER_SNAKE ones also reach shell/exec as env. |
requires … end |
The file-declared manifest — see below. Opt-in; enforces strictly when present. |
bundle … end |
Files to embed in the --build fat binary (include "./x" as alias). |
template NAME … end |
Reusable op-sequence stamps, expanded at each bare NAME invocation. |
command NAME … end |
A typed verb. |
catch NAME … end |
Fallback for unknown verbs. Add proxy_args to bind ${proxy_args}. |
import "./other.perch" |
Pull in another file's commands/templates. |
requires — the manifest, as it works now¶
requires
bin "docker" # must exist on PATH
bin "kubectl"
hash "sha256:abc123…" # optional: pin exact bytes (read-only, no exec)
end
bin "jq" optional # absence is not fatal
env "KUBECONFIG" # must be set (non-empty)
env "DEBUG" optional
host "api.github.com" # allowed HTTP destination
host "*.amazonaws.com" # wildcard suffix
read "./src" # filesystem read scope (paths/dirs)
write "./dist" # filesystem write scope (a write root implies read)
os "linux" # host OS must match (one per line)
arch "amd64" # host arch must match (one per line)
end
When this block is present:
- Preflight (once, before any op): every required
binmust exist on PATH; everyhash/hash_file-pinned bin is byte-compared (no execution); every requiredenvmust be set; hostos/archmust match. - Runtime:
shell "X …"whereXisn't a declared bin →bin_not_declared;http_*to an undeclared host →host_not_declared;get_env "Y"whereYisn't declared →env_not_declared; a filesystem op (mkdir,write_file,read_file,cp, …) whose path falls outside every declaredread/writeroot →read_not_declared/write_not_declared. Preflight failures →requirement_unmet. perch --checkflags undeclared literal usage statically, before running. Interpolated args (shell "${cmd}") are deferred to the runtime guard.
There is no version checking. Verifying a version means executing the binary before the sandbox exists, and a trojaned binary lies. Pin a hash instead, or check a version inside a command with shell_output + regex_match. Full reference: requires.md.
Declaring a requirement is not a capability grant — sandbox flags (--allow-bin, etc.) still gate the invocation. The manifest describes the program; flags are the policy for the run.
Running things — shell and the op catalog¶
Today you run external tools with the shell op (a string command):
For most data work you don't need a shell at all — perch ships ~140 cross-platform ops. Prefer them over shelling out:
body = http_get "https://api.github.com/repos/me/app"
stars = json_get "${body}" ".stargazers_count"
h = sha256_file "./dist/app"
mkdir "./out"
write_file "./out/manifest.json" "${body}"
Capture an op's result with let. See the full list in op-reference.md.
Argument forms (shipped)¶
url = get_env "API_URL"
print "${url}" # string form
print url # bare ident — resolves the binding directly
u = upper url # let-captured op with a bare-ident arg
Bare idents work for plain binding names and dotted member paths — match err.kind and match os both work (capy dotted_ident). The string form match "${err.kind}" still works too.
Environment¶
with_env "FOO=bar" # scoped — auto-restored when the block exits
printenv FOO # the subprocess sees FOO in its environment
end
export "TOKEN" "abc" # process-lifetime (alias: set_env)
unset "TOKEN" # remove (alias: unset_env)
Control flow¶
if os == "darwin"
print "mac"
end
for_each items it
print it
end
match status # bare ident OK for plain names
case ok
print "good"
else
print "?"
end
Calling other commands / templates¶
build "-target=linux" # invoke another command; args are quoted, CLI-style
ensure_dir "./out" # expand a template with positional args
The four tools you'll actually use¶
| Command | What it does |
|---|---|
perch --check |
Static validation: arg types, unknown ops, unresolved ${…}, undeclared requires usage. Exit non-zero on error. Wire into CI. |
perch --scan FILE |
Static security audit (no execution): capabilities needed, env vars touched, risk score, recommended hardened invocation. Run before adopting a file you didn't write. |
perch simulate |
Walk the program against a hypothetical host; per-op WILL_RUN / WILL_FAIL / MIGHT_FAIL verdicts. |
perch test |
Run commands marked test in a sandbox, with assert_* ops. |
Sandboxing a run (operator side, shipped)¶
# Let an agent call your ops with no shell and no network, only KUBECONFIG visible:
perch-mcp --no-network --no-shell --env KUBECONFIG -f ops.perch
# Pin which bins and hosts a script may touch:
perch --allow-bin git,docker --allow-host api.github.com -f deploy.perch deploy
# Treat all input as hostile (strictest preset):
perch --untrusted -f thirdparty.perch
These compose AND-wise with any requires/sandbox declarations in the file — neither side can grant more than the other allows.
Gotchas in the current build¶
- Comments work.
# …(leading or trailing) parses and is ignored. try / rescue / finallyworks. Built on capyblock_sections; afinally-onlytryre-raises after cleanup (only a non-emptyrescueswallows).matchtakes bare idents and dotted paths.match osandmatch err.kindboth work; the string formmatch "${err.kind}"still works too.- No version checking in
requires. Removed deliberately (it required executing the binary). Usehashpins, or check versions inside a command. - Indentation is significant — 4 spaces or 1 tab per level.
What's roadmap, NOT shipped¶
So you don't mistake design docs for current features. The following are proposals only:
- Zero ambient authority / default-deny — today a file without
requireshas full access. (sandboxed-by-design.md) exec BIN argsverb,shell { bin … }two-level capability, capability handles (as NAME),|/pipe/glob,with_exec— none exist yet. Useshell "…"+requirestoday. (Filesystemread/writepath scopes inrequiresare shipped — see above.)- Removing the
shellop —shellis the current way to run subprocesses and isn't going anywhere soon. - In-module WASM manifests + signing +
wasm diff—wasm_runworks today with capabilities declared in the.perchfile; the in-module manifest is roadmap. (trust-by-manifest.md)
When in doubt: if it's in a doc titled with "(roadmap)" or "design", it's not shipped. This page only describes what runs.
See also¶
- The complete guide — zero-to-production walkthroughs
- op-reference.md — the ~140 built-in ops
- language.md — full grammar reference
- requires.md — the manifest in depth
- sandbox.md — the capability flags