ralon 0.1.0

Filesystem policy for AI coding agents: kernel-enforced write protection driven by an agent.lock file
# 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

| File | Responsibility |
| --- | --- |
| `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

| Code | Meaning |
| --- | --- |
| 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.