A cross-platform, userspace declarative command policy launcher. You post the standing orders once; the gatehouse enforces them the same way, everywhere.
Warning
This is a gatehouse, not a dungeon. Palisade is a userspace launcher, not a kernel sandbox. It decides whether and how a command is launched. It does not confine a process once running. Read §7 · The Threat Boundary first.
Not a feature brochure — a duty roster, written the way you would brief an operator taking a shift at the gate. Read in order the first time; return by number afterwards.
Posts: 1 Operator Briefing · 2 Muster · 3 The Two Wardens · 4 Standing Orders · 5 Admission Sequence · 6 Seal of Record · 7 Threat Boundary · 8 Field Transcripts · 9 Armoury · 10 Failure Diagnosis · 11 Cross-Platform · 12 Limitations & Roadmap
Ad-hoc command invocation is where accidents live. A wrapper script leaks the full
environment to a subprocess. A CI step quietly accepts an extra --force. A cron job with
no timeout hangs a runner. Each is an implicit assumption nobody reviewed.
Palisade turns those assumptions into a standing order — a small, auditable policy file
that describes exactly how one command may run: its executable (a bare name via PATH,
or an explicit path); its arguments (a fixed prefix plus an allow-list of what a caller
may add); its environment (started clean and populated, or inherited and pruned); an
optional path constraint on which binary launches; a declared network intent
(advisory — see §7); and a wall-clock timeout.
The launcher then resolves the executable, filters the environment, validates the arguments, spawns the process, enforces the timeout, and writes a machine-readable receipt of what actually happened. Lint the file, diff it in review, and run it identically on a laptop, a CI runner, and a cron host.
Use Palisade to make command execution explicit, consistent, and auditable — in CI wrappers, cron jobs, and operator runbooks. Do not use it as a barrier against hostile code. For real isolation, station the gatehouse inside a container, VM, or OS sandbox.
Two tools garrison the fortress, built from two toolchains — both standard-library only, no third-party dependencies. Prerequisites: Rust (1.74+) and Go (1.24+).
# The launcher (Rust) — the tool that executes.
cd launcher && cargo build --release # -> launcher/target/release/palisade
# The inspector (Go) — the tool that only reads.
cd inspector && go build ./cmd/palisade-lint # -> palisade-lint(.exe)Or muster both at once through the top-level Makefile:
make build (compile both), make test (cargo test + go test), make lint-examples,
make fmt, make vet.
One fortress format; two independently-written tools that must agree on it. The grammar is implemented twice — once in Rust, once in Go — and cross-checked by each tool's tests.
| Warden | Language | Standing duty | Executes? |
|---|---|---|---|
palisade |
Rust (stdlib) | Loads + validates a policy, resolves the program, runs it, writes a receipt. | Yes |
palisade-lint |
Go (stdlib) | Inspects and lints the same format. Reads files, reports diagnostics. | Never |
The division is deliberate. The inspector never launches a process — run it safely
against an untrusted policy to learn what it would do. The launcher is the only tool
with the keys to the gate. Both read the same normative grammar in
docs/POLICY.md.
A policy is a tiny INI-like text file. = sets a scalar (last assignment wins); +=
appends to a list (order preserved). There is no quoting, escaping, or line
continuation — deliberately, so the format is trivial to parse, diff, and audit. A #
anywhere on a line begins a comment.
Here is a complete standing order, annotated post by post:
name = git-status # human-readable label (default: "unnamed")
[exec]
program = git # REQUIRED. Bare name (PATH-resolved) or a path.
timeout_ms = 10000 # wall-clock ms. 0/absent = no timeout.
# workdir = /srv/repo # optional working directory
[args]
fixed = status # always prepended, in order
fixed += --short # (+= preserves order)
allow_extra = false # false (default): only allow-listed caller args
# allow = --porcelain # allow-list of extra caller args
[env]
mode = inherit # "clean" (default) or "inherit"
remove = AWS_SECRET_ACCESS_KEY # (inherit) prune from parent env
remove += GITHUB_TOKEN
# pass = HOME # (clean) copy from parent if present
# set = LANG=C.UTF-8 # KEY=VALUE applied LAST, overriding above
[path]
# root = /usr/bin # if set, resolved binary MUST live under a root
[net]
mode = none # "none" (default) or "allow". ADVISORY ONLY — see §7.Full per-key detail is in the normative spec docs/POLICY.md. In brief:
exec.program is the only required key; args splits into a fixed prefix and an
allow list gated by allow_extra; env is either clean (empty + pass + set) or
inherit (parent − remove + set); path.root optionally confines the resolved binary;
net.mode is none/allow and advisory only. Booleans accept true/yes/1/on and
false/no/0/off, case-insensitive.
When you run palisade run, a command approaches the gate and must clear five wardens in
order. Any refusal halts admission with a distinct exit code (see §10).
flowchart TD
A[policy file] --> P[parse + validate policy]
P --> AR[validate caller args against allow-list]
AR --> RS[resolve executable via PATH / PATHEXT]
RS --> RT{under a declared path.root?}
RT -->|no| X[REFUSE · exit 3]
RT -->|yes / none declared| EV[build filtered environment]
EV --> SP[spawn child · inherit stdio]
SP --> TO{exits before timeout?}
TO -->|yes| EC[record exit code]
TO -->|no| KL[best-effort kill · record timed_out]
EC --> RC[write JSON receipt]
KL --> RC
With allow_extra = false (default), every caller argument must appear verbatim in
args.allow, or the launcher refuses. The final argument vector is args.fixed followed by
the validated caller arguments. With allow_extra = true the allow-list is ignored and any
argument is accepted (the linter warns — see §10).
- If
programcontains a path separator (/anywhere,\on Windows), it is used as a path directly (canonicalized to absolute where possible). - Otherwise it is a bare name resolved against
PATH. On WindowsPATHEXTis honored, sogitfindsgit.exe; if unset, a.com/.exe/.bat/.cmdfallback applies. - If any
path.rootis declared, the resolved absolute path must sit within a root or the launch is refused (paths canonicalized for a robust prefix check).path.rootconstrains which binary is launched — never what the running process may later touch.
Built deterministically, then sorted by key before the child receives it (so execution and the receipt are reproducible):
clean(default) — empty environment; copy eachenv.passname present in the parent; then applyenv.set.inherit— full parent environment; delete eachenv.removename; then applyenv.set.
In both modes env.set (KEY=VALUE) is applied last and wins.
Enforcement is portable and stdlib-only: spawn the child, then poll its status until it exits
or the deadline passes; on timeout, issue a best-effort kill and reap it, recording
timed_out. The poll interval scales with the timeout, clamped to [5 ms, 100 ms].
timeout_ms = 0 (or absent) disables the timer. A timeout maps to exit code 124, matching
GNU timeout.
Every palisade run inscribes a receipt: a single-line JSON record of what was launched
and how it ended. Write it with --receipt <FILE>; omit the flag and it prints to stderr.
{"policy_name":"git-status","program":"git","resolved_path":"/usr/bin/git",
"args":["status","--short"],"env_mode":"inherit",
"effective_env_keys":["HOME","PATH", ...],"net_declared":"none",
"timeout_ms":10000,"duration_ms":42,"outcome":"exited","exit_code":0}What the seal records: policy_name, program, resolved_path (the exact binary chosen);
args (final vector = fixed + validated extras); env_mode and effective_env_keys (the
mode and the sorted list of variable names); net_declared (the declared intent,
a label not a control); timeout_ms and duration_ms; and outcome — exited (with an
exit_code, possibly null) or timed_out.
Important
The receipt records environment key names, never values. A secret's presence may be
visible; its contents are never written. net_declared records what the policy claimed,
not what the process did on the wire.
This is the most important post in the handbook. Read it before trusting the gate.
Palisade is a userspace launcher, not a sandbox. It provides no OS-kernel isolation and uses no seccomp, Linux namespaces, cgroups, ptrace, Windows job objects, or AppContainer. Concretely:
net.mode = nonedoes not block network access. It records a declaration of intent that both tools surface and that lands in the receipt; a launched program can still open sockets freely — the linter says so itself (P200).path.rootconstrains which executable is launched, not what the running process may later read, write, or connect to.- Argument and environment filtering reduce accidental exposure — a stray
--force, a leakedAWS_SECRET_ACCESS_KEY— but do not contain a hostile binary. Once running, a process runs with your privileges.
The gate decides who gets in and how they are dressed; it does not follow them inside. For real isolation against untrusted code, run Palisade inside a container, VM, or OS sandbox, and treat that outer layer as the actual security boundary.
Real output from the two wardens ($ is the shell prompt).
Lint every example policy:
$ palisade-lint lint examples/*.policy
examples/echo.policy
ok (no diagnostics)
examples/git-status.policy
warning P102 (line 20): 'env.mode = inherit' exposes the full parent environment ...
summary: 0 error(s), 1 warning(s), 0 info
examples/network-fetch.policy
warning P103 (line 21): 'args.allow_extra = true' lets callers pass arbitrary args ...
warning P102 (line 24): 'env.mode = inherit' exposes the full parent environment ...
info P200 (line 28): 'net.mode = allow' is advisory only ...
summary: 0 error(s), 2 warning(s), 1 infoInspect a single policy as structured JSON (the inspector never runs it):
$ palisade-lint inspect --json examples/git-status.policy
{ "file": "...", "name": "git-status", "program": "git", ... }Check a policy without executing — resolve the program and print the plan:
$ palisade check --policy examples/git-status.policy
policy 'git-status' is valid
program: git -> /usr/bin/git
timeout: 10000 ms
net: none (declared)
fixed args: ["status", "--short"]
extra args: allow-list []Run it and capture a receipt (the receipt's shape is detailed in §6):
$ palisade run --policy examples/git-status.policy --receipt receipt.json
$ head -c 60 receipt.json
{"policy_name":"git-status","program":"git","resolved_path...Add --strict to palisade-lint lint to make CI fail on warnings, not just errors.
Three ready standing orders ship in examples/. Adapt, don't invent.
echo.policy— the disciplined greeting. Clean environment, a fixed message, one allow-listed flag (-n), a 2-second timeout, no network. Copy this when the child should see only what you name.git-status.policy— inherit, but prune.gitneedsHOME,PATH, and credential helpers, so it inherits — butAWS_SECRET_ACCESS_KEYandGITHUB_TOKENare dropped and args are locked to the exact subcommand (allow_extra = false).network-fetch.policy— the honest warning. Acurlinvocation that intentionally trips three advisories (P103,P102,P200) so you can see a looser policy — and why the linter complains.
Lint all three: make lint-examples.
Every refusal has its own number, so a wrapper can react precisely.
| Code | Meaning |
|---|---|
0 |
child exited 0 / check succeeded |
1 |
usage error (bad flags, missing command) |
2 |
policy load / validation error |
3 |
executable not found, not a file, or outside path.root |
4 |
a caller argument was not in the allow-list |
5 |
spawn / wait error |
124 |
child timed out (matches GNU timeout) |
| N | otherwise the child's own non-zero exit code (clamped to 1–255) |
The linter mirrors the launcher's hard validation (errors — the policy would be rejected) and adds warnings and info advisories the launcher does not enforce. Exit codes:
| Code | Meaning |
|---|---|
0 |
success (no errors; and no warnings under --strict) |
1 |
usage error |
2 |
a policy had errors (or warnings under --strict) |
3 |
a file could not be read or parsed |
Diagnostic register (as emitted by the source):
- Errors (launcher would reject):
P000unparseable file ·P001missing/blankexec.program·P002non-integertimeout_ms·P003badenv.mode·P004malformedenv.setentry ·P005badnet.mode·P006non-booleanargs.allow_extra. - Warnings (valid but risky):
P100no timeout set ·P101timeout_ms = 0disables the timer ·P102env.mode = inheritexposes the full parent environment ·P103allow_extra = truepermits arbitrary args ·P104unknown section (typo like[exce]) ·P105unknown key (typo likeprogramm). - Info (advisory):
P200net.mode = allowcannot be enforced ·P201program is a path but nopath.rootis declared.
Common launch failures, decoded: "executable '…' not found on PATH" → exit 3 (check
PATH, or PATHEXT on Windows); "resolved executable '…' is not under any allowed
path.root" → exit 3 (real binary, outside your roots); "argument '…' is not in the
allow-list" → exit 4 (add it to args.allow, or set allow_extra = true).
The tools are cross-platform, but the world they run in is not.
PATHEXTon Windows. Bare-name resolution honorsPATHEXTsogitfindsgit.exe; if unset, it falls back to.com,.exe,.bat,.cmd, and an exact match.echois not portable.examples/echo.policytargetsecho, a real binary on Unix (/bin/echo) but a shell builtin on Windows (noecho.exe). On Windows prefer thegit-statusexample, or pointexec.programatcmdwithfixed = /Cthenfixed += echo. Linting works identically everywhere — the inspector never launches.- Path separators.
programis a path if it contains/anywhere, or\on Windows. - Signals vs. kill. On Unix a signal-terminated child may report no exit code; the receipt
records
outcome: "exited"withnull, mapped to exit1. Timeout kill is best-effort. - Make on Windows. The
MakefiledetectsWindows_NTand targetspalisade-lint.exeforlint-examples.
- No isolation. See §7. No sandboxing primitives are used.
net.modeis advisory. It cannot allow or block a single packet.- No quoting in the policy format. A
#anywhere begins a comment and values cannot contain a literal#; there is no escape sequence. This keeps the parser and audits trivial. - Cooperative timeout. Poll-based (5–100 ms granularity), not a hard real-time timer.
- stdio is inherited, not captured. The receipt records outcome and timing, not output.
A published CHANGELOG.md tracks releases. Anything not present in the
current source is not a feature — this handbook documents only what the two wardens do
today. Proposals belong in the issue tracker, not in this file.
palisade/
├── launcher/ Rust CLI + library — the executor
│ ├── src/ policy · model · resolve · exec · main · lib
│ └── tests/ end-to-end execution tests
├── inspector/ Go CLI + packages — the inspector (never executes)
│ ├── cmd/palisade-lint/
│ └── internal/ policy · lint (+ tests)
├── examples/ sample .policy files (see §9)
├── docs/POLICY.md normative format specification
├── docs/assets/ handbook illustrations (SVG)
├── Makefile build · test · lint entry points
└── .github/workflows/ CI for both languages
MIT. See LICENSE.
- v0.1 - policy format draft, basic launcher (2016)
- v0.2 - exec pipeline, program-identity warden (2019)
- v0.3 - strict policy parser, resolve phase (2021)
- v0.4 - env warden, deny-by-default (2022)
- v0.5 - path warden, arg templates (2024)
- v0.6 - time-window warden, receipts (2025)
- v1.0 - frozen receipt schema, five wardens, Go inspector (2026)
- v1.1 - policy includes (in progress)
All milestones through v1.0 are shipped and verified by cargo test plus the
inspector test suite. Open work lives under the [Unreleased] heading in the
CHANGELOG.
MIT - see LICENSE.