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.
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 loudPolicyError, never a
silent allow.
pre_command— once per classified command, before it runs. TheCommandContextcarries the name, the rawargv, every path the line names (paths, withoperandsalone beside it for rules that read a slot by position), the admissiontokenswith theirprogramhead (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 thews.fsfacade, which is also how FUSE, the runtime guests,find -deleteand 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 onwriteandpath. May answer Deny, which the doors raise asEACCES. This is the hot path — thousands of ops under one recursive command — so keep it cheap; expensive decisions belong atpre_commandor precomputed into policy state.pre_session— once per session-state write, before it lands.export, a plain assignment,${X:=default},$((X=5))andprintf -vall clear the same gate, because every writer goes throughSessionView.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 finishedexecute()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 defaultCOMMANDscope the line reads<command>: Permission deniedat exit 126, bash’s own wording, and the reason travels as the result’srefusalrecord (kind: deny, the reason, the policy’s class name, the scope); theOPERANDscope 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 isEACCES.Ask(reason)(pre_commandonly): the line does not run, the agent reads<command>: Permission deniedat exit 126 with apendingrefusal record naming the ask id, and the question lands inws.decisionsfor a host to answer — the same ledger,on_askhandler, CLI and REST doors as a profile’saskrules; 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:- Syntax. An unparsable line exits 2 and no policy of either kind is consulted.
- 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.
- 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.
- Execution. Each VFS op clears
pre_ops— including ops a routed runtime performs against the mounts — every env write clearspre_session, and the finished line is bounded bypost_opsandpost_execute.
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.