Ralon makes a narrow promise and tries to make it exactly. This document says what the promise is, what it is not, and which of the claims have been tested rather than reasoned about.
Defends against: a process that runs with your privileges and tries to
modify a path the policy protects. That covers the ordinary case — an agent
editing a file it should not have touched — and the adversarial one: a
prompt-injected agent that deliberately goes after .env, an agent that shells
out to sed, python or git checkout, and any process it spawns, including
ones that outlive it.
Which processes are covered depends on the platform, and it is worth being exact:
Linux (run) |
macOS (run) |
Windows (run) |
Windows (guard) |
|
|---|---|---|---|---|
| the agent and everything it spawns | yes | yes | yes | yes |
| an agent started any other way | no | no | no | yes |
| survives being killed | nothing to kill | nothing to kill | job object kills the command too | no — the locks go with it |
Does not defend against:
- Root. Anything that can become root outside the namespace can undo all of it. This is a guardrail for a tool you invited in, not a defence against an attacker who already has your password.
- Processes you did not start this way — on Linux. The policy binds the
process tree under
ralon run. An agent launched directly is unrestricted, and so is a daemon that was already running — a language server, a file-watcher, an editor with a remote API. If a sandboxed process can ask one of those to write a file, the write happens outside the sandbox. Do not run an IPC-reachable writer alongside an agent you do not trust. On Windows this is whatralon guardexists for: it refuses every process, so the daemon is covered too. - Killing or blocking a guard — but not silently. On Windows a guard is a
process, and a process running as you can terminate it, suspend it (its handles
survive suspension, so the locks hold until it actually dies), or squat its
claim so a fresh one cannot start. That is the same boundary that lets an agent
run
ralon scope remove: there is no password by design. Two things it is not. It is not a way to modify a protected path while a guard is up — the locks refuse that regardless. And it is not silent:statusreports whether the files are actually locked, not whether a claim exists, so a killed guard reads as gone and the supervisor starts another. See Lifetime and The record is not evidence for why that distinction is load-bearing and how it was almost lost. - Reading. Protected files stay readable, deliberately:
agent.lockdeclares what must not change. A secret an agent must not read does not belong in the project directory. - Exfiltration. Nothing here touches the network.
- The kernel, the crates, the CPU. A Landlock or namespace vulnerability, a compromised dependency, or hardware is out of scope.
Inside ralon run, for every protected path, in that process and every
descendant:
| Attempt | Result |
|---|---|
write, append, truncate, cp over it |
denied |
| delete, rename away | denied |
| replace by renaming another file over it | denied |
| delete then recreate | denied |
| hard link or symlink over it | denied |
| create anything inside a protected directory | denied |
| rename or remove a directory on the way to it | denied |
chmod then write |
denied |
| reach the inode through a hard link made inside the sandbox | denied |
escape by umount, mount --bind, or a nested namespace |
denied |
reach it through another process's /proc/<pid>/root |
denied |
| read it | allowed |
| everything else in the project | unaffected |
Each row is a test in tests/enforcement.rs. They run a real shell inside a
real sandbox and then check the file from outside it, against every backend the
kernel offers.
- A Landlock domain is one-way. There is no syscall to leave one, and it
survives
forkandexecve. - The mount namespace is locked before your command starts. Entering a second
user namespace marks every inherited mount
MNT_LOCKED, soumountfails andcopy_treerefuses any bind mount that would expose what is underneath. no_new_privsis set, so a setuid binary cannot be used to climb out.- Nothing supervises the sandbox, so there is nothing to kill.
ralonbecomes the command.
Two things fall out of the design rather than being enforced by a check:
Hard links cannot reach a protected file. Under the mount backend the
protected path is itself a mount point, and link() requires source and target
to be on the same mount — every attempt returns EXDEV. Under the Landlock
backend the same attempt is denied for a different reason: cross-directory links
need REFER, which the ancestor chain is never granted, and same-directory
links need MakeReg on a directory that is never granted either.
/proc/<pid>/root is not a way out. Following another process's root
requires PTRACE_MODE_READ, and a process in a nested user namespace does not
have it over processes in the parent one, even at the same uid. Verified: the
write returns EPERM.
A hard link made before the sandbox starts bypasses both backends. A
protected file with a second name is reachable through that name: it is an
ordinary file, not bind-mounted and not carved out of the Landlock grant, and
writing it changes the protected file's contents. Verified — a write through
the second name changed .env from inside the sandbox. status and run warn
when a protected file has more than one link, and so does the supervisor, into
its log, at the moment it starts enforcing — which is the only notice anyone
gets, since nothing about the enforcement can prevent it. (Links created
inside the sandbox are still refused: see "Why it cannot be undone".)
The warning applied to Linux and macOS only until recently: the Windows
implementation returned an empty list, so the one platform whose backend is
entirely about holding file handles was the one that never mentioned a second
handle-able name for the same bytes. NTFS has hard links and mklink /H needs
no privilege. It now asks GetFileInformationByHandle, which is the only way to
get a link count on Windows — std::fs::Metadata does not carry one — and is
tested against a real second name rather than a fixture.
A second path to the same directory bypasses both backends. This is tested and true: if the project is also visible at another mount point — a bind mount made before the sandbox started, a volume mounted twice into a container, a network share exported at two paths — writing through the other path is not restricted. Both backends are path-based, and neither can protect a path it was not told about. The sandboxed process cannot create such a mount (the mount backend locks its namespace; the Landlock backend runs where user namespaces are typically unavailable), so this requires the second path to already exist. If your setup has one, protect it too or do not use it.
Landlock alone can be defeated where user namespaces are available. Landlock
rules apply to paths, not inodes. A process that can create its own mount
namespace can bind the project somewhere the carve-out granted, and write
through the new path. auto therefore prefers the mount backend, which is
available in exactly the environments where this attack is; forcing
--backend landlock on a machine with unprivileged user namespaces gives up a
real guarantee.
A file another program is using cannot be locked (Windows). A live SQLite
database, a log a dev server appends to, a state file a daemon rewrites: the
handle Ralon needs is refused because that program already holds one. status
warns and run refuses to start, rather than reporting the path as locked
while it is not. This is a policy naming the wrong thing — protect the files a
program owns, not the ones it has open.
Only paths that exist can be protected. A bind mount needs something to
mount. status and run warn about patterns matching nothing. The Landlock
backend is stricter here by accident of its design: it forbids creating anything
in the ancestor directories, so a missing .env cannot be created at all.
The policy is read before the sandbox starts. Nothing races it — the scan
and the mounts happen in one single-threaded process before exec — but a path
created after that point is not protected for the lifetime of that run. Restart
the agent after adding files that need protecting.
Landlock's create-restriction is a functional cost, not a security one. See
architecture.md. It is why mount is the default.
run enforces through a Seatbelt profile: agent.lock is compiled to SBPL and
applied to this process with sandbox_init, which is inherited across exec
and by every descendant and cannot be left. That is the same property the Linux
backends have — run becomes the command, so there is no supervisor to kill.
Seatbelt is the only one of the three that can state the policy directly,
because SBPL has deny:
(version 1)
(allow default)
(deny file-write* (literal "/proj/.env") (subpath "/proj/config"))
Two consequences follow, and both are improvements on the platforms either side
of it. Nothing outside the named paths behaves differently, so unlike Landlock
there is no create-restriction to work around. And a protected directory
covers entries created inside it later, so unlike the Windows locks there is no
gap needing an ACL to reach. Directories on the way to a protected path are
denied as literal nodes rather than subpath trees: that stops the directory
being renamed or removed without making its contents read-only.
Specific to this backend:
sandbox_initis deprecated — since 10.8, with no public header. It is also what every sandboxed application on macOS uses, and the supported alternative is the App Sandbox, which is an entitlement on a signed.appbundle and not something a command-line tool can be. So this is a dependency on a deprecated API, named here rather than left implied. If it is ever removed,runfails loudly: the kernel's refusal is reported verbatim and the command is not started.- A rejected profile is an error, never a warning. Nothing is applied partially.
- Rules name paths, so a hard link or a second path to the same file is outside
them, exactly as on Linux.
audit.rsreports the hard-link case.
The attack tables in tests/enforcement.rs run against this backend on a real
macOS kernel in CI, with RALON_REQUIRE_BACKEND=1 so that "nothing was tested"
fails the job.
ralon guard and the supervisor cannot use Seatbelt. A profile restricts the
process it is applied to and its descendants, and both of those exist to protect
processes nobody started. They use chflags uchg — the user immutable flag —
instead, and this section is the whole basis on which that should be trusted.
Ralon previously declined to implement this, on the grounds that it is a
narrowing of the same kind as the Windows deny ACE and should not be presented as
protection. That decision is reversed. What changed is that ralon install needs
something a background process can impose, and on macOS this is the entire list:
Seatbelt is inherited and cannot be imposed, and Endpoint Security needs an
Apple-granted entitlement, root, and Full Disk Access — a privileged process an
agent could talk to, which is worse than the problem. So it is implemented, and
described as what it is.
What it refuses, for every process on the machine, tested in tests/immutable.rs:
overwrite, truncate, append, rm, mv away, mv over, cat >, sed -i, and
creating a new entry inside a protected directory. Every ordinary write.
What it does not do, each with a test that asserts the weakness so the claim here cannot drift:
-
An agent can undo it.
chflags nouchgneeds no privileges and is one command. This is a narrowing, not a sandbox, and it is not equivalent to process-level sandboxing.runis: a Seatbelt profile cannot be dropped, inspected, or lifted by the process it applies to. -
Unprotected ancestors are not pinned, and that permits substitution. This is the sharpest limitation of this backend, so it is stated as the attack it is. Given a policy protecting
src/deep/secret.txtwhere neithersrc/norsrc/deep/is protected:$ mv src/deep src/moved # allowed: src/ is not flagged $ mkdir -p src/deep $ echo whatever > src/deep/secret.txt
The original bytes remain immutable under their new name. They are also no longer the bytes anything reads: every build, test run and deploy that opens the declared path now gets the attacker's file. "The path no longer refers to it" is the mild way of saying the policy has been defeated.
Why it is not fixed. macOS gives one flag meaning both this directory may not be renamed and this directory may not accept new entries. Pinning
src/would stop the project ever gaining a file insrc/; pinning the project root — which every policy needs, becauseagent.locklives there — would stop it gaining a file at all. Every other backend separates the two: a mount point, a held directory handle, and a Seatbeltliteralnode each forbid the rename while leaving creation alone. This one cannot, so the gap is named rather than closed at a price nobody agreed to.What closes it. Protect the directory instead of the file inside it. A protected directory carries the flag itself and cannot be renamed, so the substitution has nowhere to start.
audit.rsdetects the exposed case and prints exactly that advice before the guard starts, andralon statusrepeats it.Every clause above has a test in
tests/immutable.rs: that the substitution succeeds, that protecting the directory stops it, and that the warning fires only when the exposure is real. The corresponding attacks are held on every other backend bytests/enforcement.rsandtests/supervisor.rs. -
It leaves state behind. A supervisor that is killed cannot clear the flags, so they stay set. That fails closed — the files remain protected — and is the opposite failure mode from Windows, where a killed guard loses protection.
ralon statusreports it andralon guard --stopclears it. -
A path that cannot be flagged is reported, never skipped in silence — another user's file, a read-only filesystem, a filesystem with no flags. The rest of the policy is still applied.
The honest summary: on macOS, ralon run is the guarantee and the supervisor is
a narrowing that covers the agents run cannot reach. Both are worth having;
they are not the same thing and this document will not call them the same thing.
run enforces on Windows through exclusive share-mode handles: Ralon holds
every protected file open allowing readers and refusing writers, so any attempt
to write, delete, rename, or replace one fails with a sharing violation. The
crucial property is that this binds processes, not agents — the blocked
process does not have to know what Ralon is, so it covers every agent equally,
including ones with no hook support at all.
ACLs were the obvious alternative and are the wrong tool: an agent runs as the same user, so any permission Ralon can set it can unset. A handle is not a permission and cannot be argued with.
Verified on Windows — overwrite, append, delete, rename away, replace by copy, replace by move, rename the parent directory, write inside a protected directory, remove the protected tree, rewrite the policy, and clear the read-only attribute then write. All refused; ordinary edits elsewhere unaffected.
Creating an entry inside a directory opens no existing object, so no share mode
is ever consulted and no handle can refuse it. Ralon adds a deny ACE for
Everyone over FILE_ADD_FILE and FILE_ADD_SUBDIRECTORY while it is
running, which refuses creating a file, creating a subdirectory, copying or
moving a file in, and renaming one inside — all tested.
This one is a narrowing, not a guarantee, and the difference is the point of
the rest of this page. The agent runs as the same user and owns the
directory, and an owner's WRITE_DAC is implicit: it cannot be denied. Tested
directly — with an explicit deny ACE on WRITE_DAC itself, the owner still
removed it and created the file. So an agent that decides to rewrite the ACL
gets its write. What this buys is that every ordinary create is refused and the
remaining route is one an agent has to take deliberately. The handle locks are
the part that cannot be argued with; this is not, and is labelled accordingly.
The ACE is removed when Ralon exits. If Ralon is killed instead, it stays —
which fails closed, on a directory the policy protects anyway. status
reports it and ralon guard --stop clears it. A directory whose ACL already
names Everyone is left alone and reported, rather than having permissions
Ralon did not write rebuilt around it.
ralon run protects the agent it starts. ralon guard protects the ones it
does not: it holds the same locks with no command to supervise, and Windows
refuses them to every process on the machine. An agent launched from an IDE, an
extension, another terminal, or installed next month is refused without knowing
Ralon exists — verified against unwrapped cmd.exe for overwrite, append,
delete, rename, writing a protected file, rewriting the policy, and creating a
new file in a protected directory.
That inverts the usual platform ranking, and only on this one point. Linux
enforcement is inherited: applied to a process before it runs, so there is
nothing left to kill, and correspondingly no way to reach out and restrict a
process you did not start. Windows enforcement is held: it covers every
process, and it lasts exactly as long as the process holding it. A guard can be
killed, and killing it releases the locks. run on Linux cannot be.
Protection lasts as long as Ralon does. There is no inheritable restriction
to hand over, so Ralon supervises rather than execing. An agent could kill its
supervisor, so a command started by run is placed in a job object that dies
with Ralon — killing Ralon kills the command with it, tested. A guard has no
child to put in a job, so killing a guard leaves the agent running with the
files writable; status says whether one is running, which is the only
notice there can be.
That last sentence is only worth anything if status cannot be lied to, and for
a while it could. "Is a guard running" was answered by whether the guard's claim
existed — a named pipe under \\.\pipe\ralon-guard-<hash>. The hash is of the
project path and is computed in open source, so any process running as you
could create a pipe of that name and hold nothing else: status would report a
running guard over a writable file, and — worse — the supervisor would record the
project enforced and never start a real guard, because the check its respawn
depends on was the one being fooled. A claim is a promise; a lock is a fact.
running now opens agent.lock for writing and asks whether the file refuses
it. A share-mode lock cannot be faked without holding the file, and a process
holding the file that way is protecting it — so the only way to make this report
a guard is to be one. The pipe still exists, as the rendezvous --stop connects
to; it just no longer stands in for the thing it was meant to prove.
tests/enforcement-style coverage lives beside the code, in
enforce/windows/guard.rs: a pipe held with no locks must read as not running,
and a genuinely locked file must read as running.
ralon install registers a per-user background process that starts enforcement
for any project containing an agent.lock, under directories the developer named
once. It introduces no new way to stop a write: it starts the same guard the
developer would have started by hand, so nothing about it is easier to bypass
than ralon guard is. What has to be examined is the discovery, not the
enforcement.
-
agent.lockgrants nothing by existing. Only paths inside a declared scope are considered, so a policy file arriving inside a downloaded archive or a dependency's source tree is inert. Being honoured is a permission the developer gives to a directory, by name, once. The check is applied to what the filesystem notification reports as well as to the sweep, so an event cannot introduce a workspace the configuration does not allow. -
A broad scope is a convenience question, not a security one. Scopes are arbitrary directories and may be as wide as a whole drive. What that permits is bounded by the policy format rather than by the scope: patterns are relative to the file that declares them and
.., absolute paths,~and!are rejected, so the most a hostileagent.lockcan achieve by being found is making its own directory read-only until someone removes it. It cannot name a path elsewhere on the machine, a backend, a command, or a privilege level. Adding a drive root is warned about because discovery gets slower, not because it opens a hole. -
The policy is data, not configuration of Ralon. It names patterns relative to itself and cannot select a backend, a command, a privilege level, or a path outside its own project —
.., absolute paths,~and!are rejected by the same parser the CLI uses. There is nothing in the format for a hostileagent.lockto escalate with. -
Nothing runs as root or administrator. The Windows registration is a Task Scheduler logon task with
LeastPrivilege; the macOS one is a LaunchAgent under the user's own~/Library. A tool that protects you from an agent should not be the reason a privileged process exists for an agent to talk to. -
The supervisor runs in session 0 on Windows, and its claim is machine-wide. The logon task uses the
S4Ulogon type, which is what keeps a console window from opening at every logon — session 0 has no desktop to open one on. That put the supervisor in a different session from everyralona person types, which matters because a guard's claim used to be aLocal\named event: scoped to one logon session, while the share-mode locks it stood for are refused to every process on the machine. The claim described something narrower than what it was claiming. The visible result wasstatusreportingguard not runningabout a running guard, andpausereporting a project released while its files stayed locked — a false negative about enforcement, which is the one direction this tool must never get wrong. The claim is now a named pipe under\\.\pipe\, which is one namespace for the whole machine and needs no privilege, so the claim's scope and the lock's scope are the same thing. Regression-tested inenforce/windows/guard.rs; verified end to end by pausing a session-0 guard from an ordinary terminal and confirming on disk that the file became writable and locked again onresume. -
The supervisor protects its own binary and its own scopes. Both live in a directory the user can write, because nothing here asks for administrator — so an agent could replace
bin/ralon.exeand own the supervisor at the next logon, or delete a line fromconfig.yamland unprotect every project under that scope, without touching a single protected file. While a supervisor is running it holds both: the binary with an exclusive handle (macOS:chflags uchg, with the same limitation as everywhere else —chflags nouchgundoes it), andconfig.yamlagainst writers but not readers. Verified by attacking a running install: rename, overwrite, delete and scope-wipe are all refused with the files unchanged afterwards.ralon scope addstill works — it asks the supervisor to stand down, writes, and starts it again; the guards keep holding their projects throughout, so nothing becomes writable in between.Two things this is not. It is not protection when no supervisor is running — these are held handles, and a machine with Ralon stopped has an ordinary writable state directory. And it does not stop an agent using Ralon's own interface: anything that can run
ralon scope removecan remove a scope, because there is no password and no approval step by design. That is the same boundary that lets an agent kill a guard. What is closed is the silent path — editing the files directly, with nothing to notice. Every change made through Ralon is appended to the supervisor log with what the scopes were before it.An earlier attempt at this kept a fingerprint of
config.yamland reported mismatches. It is recorded here because it looked convincing and did not work: the supervisor reconciles within a second of any write to that file, and reconciling means adopting, so a tampered configuration was re-fingerprinted almost immediately and then reported as intact. A check that goes green a second after the attack is worse than no check. -
The registered binary is a copy Ralon owns.
installcopies the executable into the state directory and registers that path, rather than registering wherever the binary happened to be — which for most installs is inside a package manager's directory. This is not a privilege boundary; the copy sits somewhere the user can write, like every other place the binary could live. It removes two failures: a running supervisor made its own package impossible to uninstall on Windows, where the image of a running process cannot be deleted; and removing the package left the registration pointing at a path that no longer existed, failing at every logon in silence.statusnow names a registration whose binary is missing. -
A malformed policy enforces nothing and says so. It is not partially applied and does not fall back to a previous policy. Failing closed here would mean freezing a repository on the strength of a file nobody could read; the project is left alone,
statusexits 2 and names the line, and the supervisor records the failure rather than retrying it in silence. The case that would matter most cannot arise: an enforced workspace has its ownagent.locklocked, so nothing can corrupt it while enforcement is in place. -
The record is not evidence. The supervisor asks the kernel whether each workspace is actually enforced rather than trusting
workspaces.json. This is load-bearing on Windows: enforcement lives in a process, so a reboot ends all of it while the file still saysenforced, and a supervisor that believed its own notes would come up, agree with itself, and protect nothing. The same check restores a guard that was killed — provided the check reads the lock and not a claim that a killed guard's replacement could be prevented from ever taking. It does; see Lifetime for the squat that made this precise. What a same-user process can still do is deny — kill the guard and squat its claim so the next one cannot start — and that now surfaces as a workspace the supervisor reports it cannot enforce, logged, rather than one it falsely believes it has. -
State is per-user and outside the repository.
agent.lockis never written to. Everything the supervisor learns lives in its own state directory.
Its limits are the limits of the thing it starts. On Windows a supervisor is a
process and killing it releases the locks — run has nothing to kill, which is
why it remains the stronger option for an agent you launch yourself. On macOS see
The immutable backend, and what it is not, above.
One consequence of session 0 is worth stating plainly because it is a gap rather
than a trade: a process there has no network credentials, so a scope on a mapped
drive or a UNC path is not reachable by the supervisor and projects under it are
not discovered. ralon scope add warns when given one. ralon guard and
ralon run work there normally — they run in your session — so the answer is to
use those rather than to expect automatic discovery on a network share.
Every platform Ralon ships for now has a backend for run. Linux has no guard
and therefore no supervisor: its restrictions are inherited by a process before
it starts, never imposed on one already running, so an agent launched any other
way is unrestricted there — which is the situation most people are actually in
until they start the agent through ralon run. ralon install fails on Linux
with that explanation rather than registering a systemd user unit, which would
start cleanly, report active (running), and enforce nothing.
ralon hook install writes a refusal into the agent's own configuration. Be
precise about what that buys:
- It covers the agent's file-editing tools, and refuses before the write.
Nine agents document a hook that can do this; the list and the exact refusal
each one reads are in
README.md. - It does not cover a shell command the agent runs. A hook cannot tell
which paths
sed -iwill touch, soBashis deliberately not matched rather than matched badly. - It lives in a file inside the project. An agent that can edit it can remove
it — unless
agent.lockprotects that file too, which on a platform with no enforcement is itself only a hook away from being edited. - It depends on the agent honouring its own documented contract. That is a different kind of claim from "the kernel refused the write", and the two are never listed as if they were the same thing.
So it is a courtesy, not a guarantee. The recommendation everywhere is to start
the agent with ralon run, where the kernel does the refusing — and on Windows,
ralon guard, which does not care how the agent was started.
$ cargo test --test enforcement # every attack, every available backend
$ ralon status # what this kernel can actually enforce
$ ralon run --dry-run -- claude # exactly what will be lockedDo not take the tests' word for it either — check by hand:
$ ralon run -- sh
$ echo x > .env # EROFS or EACCES
$ rm .env # denied
$ echo x > src/App.tsx # fineIf status reports no available backend, run refuses to start the command
rather than running it unprotected. A failure to enforce is never silent.
A bypass is anything that modifies a protected path from inside
ralon run without root, other than the limitations listed above. Please
report it privately — email the maintainers or open a GitHub security advisory —
with the policy, the command, and the kernel version (uname -r) and backend
(ralon status). A failing test case in the style of
tests/enforcement.rs is the most useful possible report.
- Warn when the project root is reachable through a second mount, by reading
/proc/self/mountinfo. - A seccomp filter denying
mount,umount2,unshareandsetnsin the sandboxed process, as defence in depth behind the locked namespace. - Applying both backends at once for callers who want the Landlock guarantees on top of the mount ones and can live with the create-restriction.