Skip to content

Confine coders and Tester commands on Linux - #3

Open
Jerhaad wants to merge 8 commits into
mrinalwadhwa:mainfrom
Jerhaad:linux-support
Open

Confine coders and Tester commands on Linux#3
Jerhaad wants to merge 8 commits into
mrinalwadhwa:mainfrom
Jerhaad:linux-support

Conversation

@Jerhaad

@Jerhaad Jerhaad commented Aug 3, 2026

Copy link
Copy Markdown

Fluent's confinement is Seatbelt-only: both coder launch sites and the Tester hardcode sandbox-exec, so on Linux there is nothing to fall back to. This adds a Landlock backend so a Linux host runs coders confined rather than unconfined.

Why Landlock

It needs no external binary and no user namespace, and its path-hierarchy rules line up with SBPL's subpath grants. Both launch sites now ask the platform for a launcher and its arguments; that is where the two diverge, because Landlock has no launcher binary. Fluent re-executes itself (fluent sandbox-run, hidden) to apply the ruleset before the execve it confines — restrictions survive execve, and applying them in a pre_exec hook would have to allocate between fork and exec.

The constraint that shapes the renderer

Landlock unions every matching rule, so a nested rule can only widen an ancestor's grant. There is no deny. The nested denial that works under Seatbelt's first-match-wins ordering would silently leave the broader grant standing, which fails open.

Every carve-out is therefore enumerated into sibling grants at render time. Three consequences, each found by running it rather than reading it:

  • The exclusion set applies to system hierarchies too. A home under a granted tree (a scratch checkout in /tmp) otherwise hands back every secret the home rules withhold.
  • An enumerated directory keeps list access, or carving ~/.ssh out of $HOME also stops anything listing $HOME.
  • /dev/stdin, /dev/stdout, /dev/stderr, and /dev/fd are symlinks into /proc/self/fd; the kernel refuses them as rule targets with EBADFD, and a rule buys nothing since Landlock mediates opening a path, not writing through an open descriptor.

.fluent/expertise/learnings/landlock-rules-union-never-subtract.md records this for future agents.

Fails closed, loudly

Landlock is commonly compiled in but left out of the boot lsm= list. There the syscall returns EOPNOTSUPP while a best-effort ruleset reports success and enforces nothing, so the availability probe demands ABI 1 outright and an unenforced ruleset fails the launch rather than running a coder unconfined.

tests/linux_sandbox.rs drives the real launcher against the kernel and names which outcome each assertion expects — a forbidden action that failed because the launcher never started proves nothing about confinement. Verified on a kernel that does enforce (Landlock ABI 4): reads and writes inside the root work, a withheld ~/.ssh key is unreadable, a write outside every writable root is denied, and a handoff-only policy closes the shared temp trees.

Known gaps

  • Network is not at parity. Landlock ABI 4 filters TCP by port only and cannot express common.sb's "inbound localhost only", so this restricts no network rather than fake it. Outbound matches Seatbelt, which already allows all.
  • keep-awake stays macOS-only. It inhibits laptop idle sleep; its message now says so instead of just "macOS-only".

Two other changes

Credentials. security find-generic-password returns nothing on Linux, so every injected secret was silently absent. Lookups go through one helper with a per-platform implementation — Keychain on macOS, secret-tool then a named environment variable on Linux, since a headless host often runs no keyring daemon. The environment fallback is refused during refresh: the variable it would read is the token the refresh exists to replace.

Change-scoped Tester commands. A command may declare the paths it exercises and then runs only for a candidate that touches them:

  - command: ./scripts/explain-new-indexes
    test_harness: shell-harness
    when_changed: ["migrations/**", "**/*.sql"]

This lets a project attach a gate to a file class its main suite cannot judge; without it such a change lands green on the strength of checks that never looked at it. Existing configs are unaffected — a command with no when_changed always runs, and an undeterminable diff runs everything. Skipped commands are recorded in tester-results.json so a Reviewer can tell a gate that passed from one that never ran.

Testing

FLUENT_SANDBOX_BACKEND selects a backend explicitly. The integration tests stand a mock sandbox-exec on PATH to observe how Fluent drives a sandboxed launch, and a kernel facility has no binary to replace, so they pin Seatbelt; the Landlock backend is covered separately against a real kernel. It cannot disable confinement, only choose which backend must succeed.

Assertions about a rendered profile now name the root they care about rather than the syntax granting it, since Landlock expresses a denial as the absence of a grant.

One unrelated fix was needed to get there: the Fargate tests set HOME to a tempdir and never restored it, so a leaked HOME pointing at a deleted directory changed what every later test in the process rendered.

Run on both platforms, each compared against this branch point on the same host.

macOS 26.5.1, arm64: library suite fully green (1227 passed, 0 failed). Integration suite 376 passed with 2 failures — post_land_retry_ignores_a_malformed_retained_candidate and pre_land_no_expertise_retry_runs_only_learner_and_preserves_mode — which main fails identically on the same machine, so they predate this branch.

Linux: 1228 passed with login_status_preflight_accepts_authenticated_worker_home failing, and 376 passed with concurrent_learner_retry_and_land_never_mutate_after_merge and update_replaces_binary_and_rematerializes_skills failing. All three fail on main here too. Notably login_status_preflight passes on macOS, so that one looks Linux-specific rather than flaky.

The Keychain item-class split is verified rather than assumed: in a throwaway keychain on macOS, an internet-class item is returned by find-internet-password and not by find-generic-password. That is the whole reason the class has to be part of the config.

Licensing

The repository publishes no LICENSE (#2), so there is no grant under which to offer this work. Everything here is mine to license and I am offering it under MIT; say the word if you would rather have it under whatever license you adopt.

Hermes Agent added 8 commits August 3, 2026 00:20
Seatbelt and Landlock cannot share a rendered profile: Seatbelt takes
ordered rules where a deny beats a broader allow, while Landlock unions
every matching rule so a nested one can only widen an ancestor's grant.
The Linux renderer therefore enumerates carve-outs into sibling grants
rather than layering denials over a broad allow, and keeps the
enumerated directory listable so tools can still scan it.

Landlock survives execve but has to be applied before it, so the
launcher re-executes the Fluent binary instead of shelling out to a
sandbox helper. An unenforced ruleset fails the launch rather than
running a coder unconfined.
`#[serial]` orders these against each other but not against the rest of
the suite, so a leaked HOME pointing at a deleted tempdir changed what
every later test in the process rendered.
Both launch sites hardcoded `sandbox-exec`, so Linux had no confinement
to fall back to. They now ask the platform for a launcher and its
arguments, which is where Seatbelt and Landlock diverge: Landlock has no
launcher binary, so Fluent re-executes itself to apply the ruleset
before the exec it confines.

`FLUENT_SANDBOX_BACKEND` selects a backend explicitly. Tests that stand
a mock launcher on PATH need it, because a kernel facility has no binary
to replace; it cannot turn confinement off, only pick which backend must
succeed.

Assertions about a rendered profile now name the root they care about
rather than the syntax that grants it, since the two backends spell the
same grant differently and Landlock expresses a denial as the absence of
a grant.
A suite that never looked at what changed is the expensive kind of
green: a chart template, a compose file, or a migration whose index its
own query cannot use passes on the strength of unrelated checks. A
command can now declare the paths it covers and runs only when the
candidate touches them, which is what lets a project attach a gate to a
file class its main suite cannot judge.

Skipped commands are recorded rather than dropped, so a Reviewer can
tell a gate that passed from one that never ran. An unknown change set
runs everything: the safe direction here is more gates, not fewer.
Treating the Linux backend as a transliteration of the Seatbelt one
fails open: Landlock has no deny rule, so the nested denial that works
on macOS silently leaves the broader grant standing.
Introduce a single read_secret() function with platform-specific
implementations behind cfg(target_os) gates. macOS keeps the existing
security find-generic-password calls. Linux queries secret-tool
(libsecret CLI) and falls back to environment variables when no
keyring daemon is running.

Add a CredentialConfig table mapping service names to environment
variable fallbacks, eliminating per-call cfg branches in
inject_oauth_token, inject_brave_search_key, and the Anthropic API
key lookup. The aws configure export-credentials path remains
unchanged as it is already cross-platform.

Add unit tests for the environment variable fallback and absent
secret-tool scenarios, neither of which requires a running keyring.
An internet password is a different Keychain class from a generic one,
so the Anthropic key stopped resolving on macOS when both went through
find-generic-password.

A refresh no longer falls back to the environment: the variable it
would read is the stale token it was called to replace, so falling back
reported success while changing nothing.
The platform temp directory sits inside a hierarchy the policy grants —
/tmp on Linux, /var/folders on macOS — so a fixture there was enumerated
as part of a system rule and the assertions read their own effect.

The environment fallback is a Linux-only path, so its test no longer
runs where the Keychain answers instead.
@mrinalwadhwa

Copy link
Copy Markdown
Owner

@Jerhaad thank you for sending this. I'll take some time to understand it over the next day or so and get back to you with thoughts and comments.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants