Skip to main content
The admission gate that Permissions describes declaratively is one client of a general mechanism: the policy engine, an ordered stack of Policy objects the workspace consults at every state-changing surface. A Policy is one concern’s answers to the workspace lifecycle. It overrides only the hooks it cares about, answers an action to state an opinion or nothing to stay silent, and a hook that raises fails closed: the command or op is refused, naming the policy.
More arrive later through ws.policies.add(...). Hooks left un-overridden are detected at the seam and never called, so an empty policy costs nothing.

The five hooks

Each hook fires on one plane, and each accepts a fixed set of answers — returning a kind the hook cannot carry is a loud PolicyError, never a silent allow.
  • pre_command — once per classified command, before it runs. The CommandContext carries the name, the raw argv, every path the line names (paths, with operands alone beside it for rules that read a slot by position), the admission tokens with their program head (a CLI’s verb path canonicalized), the cwd, and the session and agent ids. May answer Deny or Ask.
  • pre_ops — once per VFS op at the op doors (the dispatcher and the ws.fs facade, which is also how FUSE, the runtime guests, find -delete and the warm cache arrive) and on the command tier’s backend I/O, before any backend or cache read. The op is named per door, so a portable policy keys on write and path. May answer Deny, which the doors raise as EACCES. This is the hot path — thousands of ops under one recursive command — so keep it cheap; expensive decisions belong at pre_command or precomputed into policy state.
  • pre_session — once per session-state write, before it lands. export, a plain assignment, ${X:=default}, $((X=5)) and printf -v all clear the same gate, because every writer goes through SessionView.set. The context carries a key, never a pseudo-path, so a path-scoped rule cannot match one by accident. May answer Deny.
  • post_ops — one completed op at the doors. A Deny suppresses the result; a Limit caps a byte-producing one.
  • post_execute — one finished execute() line. A Limit returned here merges with every other opining policy’s and caps the line’s output at the workspace boundary.

The answers

  • Silence (None/null): no opinion, the next policy speaks.
  • Deny(reason, scope): with the default COMMAND scope the line reads <command>: Permission denied at exit 126, bash’s own wording, and the reason travels as the result’s refusal record (kind: deny, the reason, the policy’s class name, the scope); the OPERAND scope keeps the GNU operand voice instead — <command>: <reason> at the command’s own operand exit code, the reason naming the operand (cat: /vault/aws.token: credentials are never read by hand, exit 1). Exit codes derive from the plane and the scope, never from a number a policy returns. At the op and session doors a Deny is EACCES.
  • Ask(reason) (pre_command only): the line does not run, the agent reads <command>: Permission denied at exit 126 with a pending refusal record naming the ask id, and the question lands in ws.decisions for a host to answer — the same ledger, on_ask handler, CLI and REST doors as a profile’s ask rules; see Asks.
  • Limit(max_bytes, max_lines, timeout_seconds, on_exceed): a bound on a result. Every opining policy’s limits merge to the tightest value per field.

The stack and its order

Policies are consulted in registration order, and the order is the tie-breaker, not the security model: a policy can only tighten the workspace, never loosen it. On a pre hook the first Deny wins. An Ask is remembered while the loop keeps looking for a Deny, so a later policy’s refusal is not re-opened as a question; the first Ask is returned only when nothing refused. The registry seeds two built-ins ahead of everything: MountRootPolicy (mount-root semantics are mount semantics: the POSIX EBUSY rules for rm/rmdir/mv/mkdir/touch/ln on a mount root, and the refusal to read one whole into an archive or copy) and OutputCapPolicy (the per-command output caps, fed the per-mount overrides). The workspace follows with the profile pair — PermissionsPolicy, reading each session’s compiled profile, and ScriptPolicy for profile policies, the policy: file a profile names — then policies= in list order, then anything added later through ws.policies.add(). A policy that raises does not fail open: the loop answers a Deny naming the policy and the error is logged.

One line, two judgments

The policy engine admits; the route policy places. A typed line clears them in a fixed order:
  1. Syntax. An unparsable line exits 2 and no policy of either kind is consulted.
  2. Route. The route policy sees the whole parsed line and picks the runtime that serves it — or refuses it. A route deny short-circuits: the line dies whole, and the admission hooks below are never consulted, so one line produces at most one refusal.
  3. Admission, per command. Every command in the line is judged before any of it runs, so a rule-refused line never runs halfway. This holds on both lanes: a whole line handed to a runtime clears the same per-command gate before the runtime sees a byte of it.
  4. Execution. Each VFS op clears pre_ops — including ops a routed runtime performs against the mounts — every env write clears pre_session, and the finished line is bounded by post_ops and post_execute.
Both layers refuse in the same voice (Permission denied, exit 126, the reason in the refusal record) on purpose, so an agent reads one grammar; a host tells them apart by what they judge. The route policy speaks once per line and can only place or deny; the policy engine speaks per command, per op and per write, and only it can ask instead of denying. Nested lines ($(), eval, source, xargs) inherit the outer line’s route decision and never re-route, but every nested command clears admission itself. ws.explain(line, session_id) runs the admission gate without running the line, and answers with the exact refusal the agent would see; see Dry runs.