Skip to content

Latest commit

 

History

1,783 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

The Palisade gatehouse: a command must clear five wardens — program, args, env, path, time — before the copper portcullis rises and admits it

Palisade — Fortress Operations Handbook

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.


How to read this handbook

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


1 · Operator Briefing

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.


2 · Muster — build and install

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.


3 · The two wardens

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.


4 · Reading standing orders — policy anatomy

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.

The section register

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.


5 · The admission sequence

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
Loading

5.1 Argument validation

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).

5.2 Executable resolution

  • If program contains 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 Windows PATHEXT is honored, so git finds git.exe; if unset, a .com/.exe/.bat/.cmd fallback applies.
  • If any path.root is declared, the resolved absolute path must sit within a root or the launch is refused (paths canonicalized for a robust prefix check). path.root constrains which binary is launched — never what the running process may later touch.

5.3 Environment resolution

Built deterministically, then sorted by key before the child receives it (so execution and the receipt are reproducible):

  • clean (default) — empty environment; copy each env.pass name present in the parent; then apply env.set.
  • inherit — full parent environment; delete each env.remove name; then apply env.set.

In both modes env.set (KEY=VALUE) is applied last and wins.

5.4 Timeout behaviour

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.


6 · The seal of record — receipts

A parchment terminal inscribing a JSON receipt line by line — policy name, resolved path, args, env mode, effective env key names, declared network mode, timeout, duration, outcome — then pressing a wax seal

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 outcomeexited (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.


7 · The threat boundary

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 = none does 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.root constrains 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 leaked AWS_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.


8 · Field transcripts

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 info

Inspect 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.


9 · Recipes from the armoury

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. git needs HOME, PATH, and credential helpers, so it inherits — but AWS_SECRET_ACCESS_KEY and GITHUB_TOKEN are dropped and args are locked to the exact subcommand (allow_extra = false).
  • network-fetch.policy — the honest warning. A curl invocation 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.


10 · Failure diagnosis

Launcher exit codes (palisade)

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)

Inspector diagnostics (palisade-lint)

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): P000 unparseable file · P001 missing/blank exec.program · P002 non-integer timeout_ms · P003 bad env.mode · P004 malformed env.set entry · P005 bad net.mode · P006 non-boolean args.allow_extra.
  • Warnings (valid but risky): P100 no timeout set · P101 timeout_ms = 0 disables the timer · P102 env.mode = inherit exposes the full parent environment · P103 allow_extra = true permits arbitrary args · P104 unknown section (typo like [exce]) · P105 unknown key (typo like programm).
  • Info (advisory): P200 net.mode = allow cannot be enforced · P201 program is a path but no path.root is 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).


11 · Cross-platform notes

The tools are cross-platform, but the world they run in is not.

  • PATHEXT on Windows. Bare-name resolution honors PATHEXT so git finds git.exe; if unset, it falls back to .com, .exe, .bat, .cmd, and an exact match.
  • echo is not portable. examples/echo.policy targets echo, a real binary on Unix (/bin/echo) but a shell builtin on Windows (no echo.exe). On Windows prefer the git-status example, or point exec.program at cmd with fixed = /C then fixed += echo. Linting works identically everywhere — the inspector never launches.
  • Path separators. program is 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" with null, mapped to exit 1. Timeout kill is best-effort.
  • Make on Windows. The Makefile detects Windows_NT and targets palisade-lint.exe for lint-examples.

12 · Limitations & roadmap

Limitations (by design)

  • No isolation. See §7. No sandboxing primitives are used.
  • net.mode is 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.

Roadmap

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.


Fortress plan

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

License

MIT. See LICENSE.


Palisade is a gatehouse, not a dungeon — a userspace policy launcher, never a kernel sandbox. The gate decides who is admitted and how; it does not follow them inside.

Milestones

  • 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.


License

MIT - see LICENSE.

About

A cross-platform, userspace declarative command policy launcher - post the standing orders once; the gatehouse (program, args, env, path, time) enforces them the same way, everywhere.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

18 stars

Watchers

2 watching

Forks

Releases

Packages

Contributors

Languages