# Architecture
```
agent.lock
│ policy.rs parse + validate (serde_yaml_ng)
▼
Policy { root, version, patterns }
│ matcher.rs patterns → globs
▼
Matcher
│ scan.rs walk the project, prune protected directories
▼
[ProtectedPath] ──────────────────► check / status print this and stop
│ enforce/mod.rs canonicalize, pick a backend, build a Plan
▼
Plan { backend, protected, pinned, carve }
│ enforce/linux.rs
▼
restrict this process ──► execve(command)
│
▼
the agent, and every process it will ever spawn
```
Nothing runs after `execve`. There is no daemon, no wrapper process, no fd
passed to the child. The restriction lives in the kernel, attached to the
process, inherited by everything it forks.
## Modules
| `main.rs` | dispatch, exit codes |
| `cli.rs` | clap definitions only |
| `commands.rs` | one function per subcommand, all printing |
| `policy.rs` | parse and validate `agent.lock`, find the project root |
| `matcher.rs` | patterns → `globset`, path matching |
| `scan.rs` | resolve patterns against the filesystem |
| `enforce/mod.rs` | backend selection, `Plan`, ancestor pinning |
| `enforce/carve.rs` | Landlock rule planning (pure, filesystem injected) |
| `enforce/linux.rs` | the syscalls: namespaces, mounts, Landlock |
`enforce/linux.rs` is the only `unsafe` code and the only file that cannot be
compiled off Linux. Everything else builds and is tested on Windows and macOS
too, which is what keeps `check` and `status` usable in mixed teams and in CI.
## Policy semantics
Patterns are relative to the directory holding `agent.lock`, which is also the
project root. Discovery walks up from the working directory, like `git`.
Each pattern becomes up to two globs: `config` matches `config` *and*
`config/**`, so a directory pattern protects the directory itself as well as its
contents — otherwise the directory could simply be renamed. `*` does not cross
`/` (`literal_separator`), `**` does. Matching is case-insensitive on Windows
and macOS, because on a case-insensitive filesystem a deny list that matched
fewer paths than the filesystem does would be wrong in the dangerous direction.
`agent.lock` is always pattern zero. A policy an agent can rewrite is not a
policy.
Rejected at parse time rather than reinterpreted: `..` anywhere, absolute paths,
`~`, `!` negation, unknown keys, unknown versions. A policy that does not mean
what it says is worse than no policy.
Scanning prunes: when a directory matches, it is recorded once and not descended
into. Protecting `node_modules` costs one entry, not two hundred thousand.
## The mount backend
The default. Sequence, all in the `ralon` process before `exec`:
1. `unshare(CLONE_NEWUSER | CLONE_NEWNS)` — a user namespace grants the
privileges needed to mount; a mount namespace keeps the mounts out of the
host. `uid_map`/`gid_map` map the current ids onto themselves, so the agent
keeps the identity it was started with instead of becoming `nobody`.
2. `mount(MS_REC | MS_PRIVATE, "/")` — nothing propagates back to the host.
3. **Pin the ancestors.** Every directory between the project root and a
protected path is bind-mounted onto itself, read-write. The access rights do
not change; the point is that it becomes a *mount point*, and the kernel
refuses to rename or remove one. Without this, `mv src src-moved` succeeds:
the read-only mount follows the directory, the file keeps its contents, and
the path the policy named is gone.
4. **Bind the protected paths read-only.** `mount_setattr(AT_RECURSIVE)` on
Linux 5.12+, so submounts are covered too; otherwise a classic
`MS_REMOUNT | MS_BIND | MS_RDONLY`, repeating the flags a user namespace
refuses to let a remount clear (`nosuid`, `nodev`, `noexec`, atime).
5. **Re-enter the working directory.** `cwd` is a `(mount, dentry)` pair
resolved before any of this, so relative lookups through it would walk
straight past every new mount. `chdir` to the same path resolves it against
the new tree. This was a real bug: pinning the project root silently disabled
all protection until the `chdir` was added.
6. `unshare(CLONE_NEWUSER | CLONE_NEWNS)` again. Mounts inherited from a more
privileged namespace are `MNT_LOCKED`: they cannot be unmounted, and
`copy_tree` refuses any bind mount that would reveal what is under them. This
is what makes step 4 permanent.
Order matters twice: parents before children (a bind mount of a directory does
not carry the mounts already inside it), and the locking `unshare` last.
## The landlock backend
Landlock rules are **additive**. A rule on a subdirectory can only grant *more*
access than its parents, never less; there is no way to write "everything is
writable except this file". This single fact determines the whole design.
So the carve-out: walk from `/` down to each protected path, and grant full
write access to everything that branches off the way. The ancestor chain itself
is granted nothing.
```
protect: src/index.tsx
/ no grant ← create-restricted
├── usr GRANT
├── tmp GRANT
└── home/dev/proj no grant ← create-restricted
├── package.json GRANT
├── tests/ GRANT (whole subtree, new files included)
└── src no grant ← create-restricted
├── App.tsx GRANT
└── index.tsx (nothing) ← protected
```
Only write rights are handled (`AccessFs::from_write`), so reads are never
checked and never denied. ABI v3 is requested with `CompatLevel::BestEffort`:
v3 is the last ABI whose write set means exactly "modify a file" — v5 adds
device ioctls, v9 unix socket connects — and best-effort silently drops what an
older kernel does not have.
The visible cost: **directories on the ancestor chain accept no new entries**,
because granting "create a file here" requires granting `WriteFile` on the
hierarchy, which would make the protected file writable. `run --dry-run
--backend landlock` lists exactly which directories are affected, and
`tests/enforcement.rs::only_landlock_blocks_new_files_beside_a_protected_one`
pins the behaviour so it cannot regress silently.
`carve::plan` takes the directory lister as an argument, so the algorithm is
unit-tested against a fake tree on every platform, with no filesystem involved.
## Backend selection
`auto` prefers `mount`: it protects exactly what the policy names and leaves
everything else alone. It falls back to `landlock`, which needs no namespaces
and therefore works in the container runtimes and hardened distros that disable
unprivileged user namespaces — which is, conveniently, where the mount backend
cannot run.
Availability is probed honestly. Landlock: `landlock_create_ruleset(NULL, 0,
LANDLOCK_CREATE_RULESET_VERSION)` returns the ABI version. Mount: `fork` a child
that attempts the `unshare` and report its errno, because nothing short of
trying it is trustworthy. Both are reported by `ralon status`.
## Exit codes
| 0 | fine |
| 1 | a path is protected (`check`), or the plan cannot be enforced (`--dry-run`) |
| 2 | error: no policy, bad policy, no usable backend, command failed to start |
Anything else comes from the command itself, unchanged, because `run` becomes
that command.
## Testing
`cargo test` — policy parsing, pattern matching, carve planning, and the CLI
end to end, on any platform.
`cargo test --test enforcement` — Linux only. Creates a real project, runs a
real shell inside a real sandbox, attempts the attack, and then inspects the
file *from outside the sandbox*. Every test runs against every backend the
kernel provides, so a backend cannot pass by being unavailable. Attacks live in
one table; adding a case is one line.
In a container: `--security-opt seccomp=unconfined` is usually what makes user
namespaces available, so both backends get exercised.
## Adding a backend
1. Add a variant to `Backend` and to `availability()`.
2. Add whatever planning it needs to `Plan::build` (keep it pure and testable,
like `carve.rs`).
3. Apply it in `enforce_and_exec` before the `exec`.
4. Add it to the list in `tests/enforcement.rs::usable_backends`. The existing
attack tables then run against it unchanged.