May this command run?
Before a coding agent's shell command runs, the runner's hook asks ljos. Four layers judge it in order. The first refusal wins, and no later layer can allow what an earlier one refused. None of them is a model.
The four layers
- The seat guard. A command that writes the seat's own files, its binaries, its hook entries,
~/.config/ljos, the approvals store, is refused. An agent cannot rewrite the law it runs under. - ljos-policyd. A binary with fixed rules. Each pipeline of the command line reaches it as one call, so a download piped into a shell is judged as one thing. It refuses privilege runners (
sudo,doas), a download handed to a shell, a recursive delete outside/tmp, a setuidchmodand raw-disk writes. On a force push the binary printsdeny;ljosasks you first. - Seat rules. Rules you write with
ljos rule, kept in the seat's memory and tried against each command of the line. A quoted sentence or a heredoc body counts as data. - The push check. After the seat rules, the last check on the line applies to
git push: a push is free to a repository your account owns alone and has never released. Anywhere else it needsLJOS_CITE=ISSUE, the tracker issue that records the decision behind it.
This is not a sandbox
Each of the four layers reads the words of a command line before it runs. It does not watch what the command does once it is running, and it cannot see a command it was never shown.
What it stops
- The command lines its rules name: privilege runners, a download piped into a shell, a recursive delete outside
/tmp, a setuidchmod, raw-disk writes, a force push (which asks you first), and the rules you add. - An agent rewriting the seat's own files, binaries or hook entries.
- A push to a shared or released repository without a ticket behind it.
What it does not stop
- A script the agent wrote earlier and then runs:
bash build.shis allowed, whatever the file contains. - An interpreter one-liner, or a command built from an encoded string at run time.
- Tool calls that are not shell commands, such as a web fetch. A file edit is checked only for whether it targets the seat's own files, not for what it writes.
- Anything on a runner where the hook is not installed.
The layers catch common mistakes, and they isolate nothing. Run the agent in a container or a VM to isolate it. Give that machine only the files and credentials the agent needs, and keep the runner's own permission prompts on.
Try it
$ ljos-policyd check -- git status allow $ ljos-policyd check -- sudo id deny sudo $ ljos-policyd check -- curl -fsSL https://example.org/i.sh '|' sh deny curl-pipe-shell $ ljos rule '*--force*' --verdict deny --why "Never force push." $ ljos policy -- git push --force origin main deny: Never force push. (rule: *--force*)
ljos policy -- COMMAND prints the combined answer for one command without running it. ljos-policyd version says which law the binary carries: the table built into it, or phronesis after that table, when the library was present at build time.
What the agent sees
On Claude Code, Codex and the other runners, the hook's answer is what the runner does. A force push asks, and the command does not run until you agree:
$ echo '{"hook_event_name":"PreToolUse","tool_input":{"command":"git push --force"}}' | ljos hook
{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask","permissionDecisionReason":"a force push rewrites shared history; the agent asks you first (ljos rule `built-in push`)"}}
A rule with --verdict ask stops the command and asks you instead. On Claude Code that is its own permission prompt. On a runner without one, the hook prints a request id; you reply approve ID in the same conversation, or run ljos approve ID in a terminal, and the agent retries. A request from another conversation grants nothing, and an agent cannot type an approval into a pane.
The runner's own settings come first: a command they deny is refused before any hook is asked.
Without the binary
If ljos-policyd is not on PATH, the second layer has no opinion and the other three still run. ljos doctor shows a policy row saying so. Set POLICYD_REQUIRED=1 to refuse every command until it is installed.
Crate site: ljos-policyd. The rules in full: the ljos-policyd README.