Markdown for LLMs
Runtime filesystem isolation
The source Markdown for this article. Copy it into your assistant or download it as a text file.
# Runtime filesystem isolation
## Goal
Run the server so it can read exactly the corpus and cases it serves,
write exactly the channels the operator opened, and reach exactly the
network peers the operator named — with each boundary tied to a real
mechanism or marked as operator policy.
## Scope
Component `law-mcp` (stdio and `--http`). Scenario: a deployment host
serving one profile. All users, paths, and URLs below are synthetic
examples.
## Applies to
| Branch | Coverage in this article |
|---|---|
| MCP (`law-mcp-server`, stdio + HTTP) | full recipe: user, mounts, network, temp |
| `law serve` | same OS mechanisms apply; serve paths (world, journal, token file) are in [Deploy a private HTTP service](/operate/private-http-service/) and [Configuration reference](/operate/configuration-reference/) |
## Prerequisites
- A checkout root containing the profiles directory and the
CLIR tree (real root markers), or an explicit `--root`
/ `LAW_MCP_ROOT` (real).
- A decision on the service user (no code default; the process runs
as whoever starts it) and on which optional legs are enabled
(journal socket, OTLP, Lens answers, neural appendix).
## Steps
1. Create a dedicated service user (operator-policy-example:
`useradd --system --no-create-home law-svc`). The server needs no
root powers: it binds a high port by default (`8722`, real) and
opens no privileged socket itself.
2. Mount the release read-only (operator-policy-example:
`mount -o remount,ro /opt/law-dsl`, or a read-only container
layer). This holds only while the profile denies the writing
facets: with `editing`/`drafting` allowed, the server writes case
packages and author-tree files under the root (see the
[effects matrix](/operate/tool-profiles-input-policy/#effects-the-authoritative-matrix)).
The explicit `--export-neural-input PATH` (real, `main.rs`) takes
an operator-chosen path outside the release.
3. Grant read on the root, write nowhere by default
(operator-policy-example modes: root `0755 root:root`, service
user in no write group). The server reads profiles, the CLIR
tree, and case directories (`law.toml`, `law.lock`, queries)
under the root (real behavior).
4. Keep case packages inside the root. Real containment
(`execution.rs`): the package path is canonicalized and must
start with the canonical root and contain `law.toml`; symlink
escapes are refused with `case_ask.outside_root`. Do not work
around this with symlinks into foreign trees.
5. Open only the write channels you use (all real):
journald socket (datagrams; absent socket falls back to stderr),
stderr capture (your supervisor's), OTLP receiver
(`LAW_MCP_OTLP_URL`, spans only, no bodies), the export path
if `--export-neural-input` is used, plus — when the writing
facets are allowed — the case/author trees the tools update.
Workbench runs additionally create temp dirs and a `request.json`
under the OS temp dir, so `TMPDIR` must be writable (no other
temp contract exists).
6. Name the allowed network explicitly. Real client-side rules
(`service_client.rs`, shared by Lens answers and neural
appendix): production requires HTTPS; plain HTTP only to
loopback (`127.0.0.1`, `localhost`, `[::1]`); zero redirects;
30 s timeout; response caps 4 MiB default, 32 MiB for Lens
answers. Credentials in the URL (`@`, `?`, `#`) are refused. The
process otherwise opens no outbound connections: no telemetry,
no update checks, no LLM calls.
7. Treat foreign drafts as untrusted input. Two touch points (real):
`--export-neural-input PATH` hands the search index input to a
separate neural service at index-build time only; the
`law_search` neural appendix posts query text + lexical candidate
ids to `{LAW_NEURAL_URL}/v1/appendix` and validates the reply
(must carry `neural` + `candidates`, at most 8, each re-checked
against the local index — `reference/search.rs`). Anything the
service returns outside that contract is discarded with a
`search.neural_bad.*` reason, and the lexical answer stands.
## Expected result
- Under a profile that denies the writing facets (`editing`,
`drafting`), the service user can start the server, serve calls,
and emit journal/OTLP records, but cannot modify the release or
any case input. With those facets allowed, case/author-tree
writes and workbench temp files are the tools' designed effect
(see the [effects matrix](/operate/tool-profiles-input-policy/#effects-the-authoritative-matrix)),
not an isolation failure.
- A `law_case_ask` call whose `package` climbs out of the root
with parent-directory segments (created-example) is refused
(`outside_root`/`not_found`, real codes); no file outside the
root is opened.
- With all `LAW_*_URL` variables unset, the process makes zero
outbound connections (real: each leg is disabled without its URL).
## Result check
- Start read-only and call `law_search`: `neural.active:false`
with the unset-URL reason (real) proves no network attempt.
- Set `LAW_ANSWERS_URL=http://created-example.invalid` (non-loopback
plain HTTP): publication tools fail with `SERVICE_CONFIG`
`https_required` (real), proving the guard runs before any byte
is sent.
- `strace -f -e trace=%network` (operator-policy-example) during a
local-only call shows `accept`/`socket` for journald at most, and
no `connect` to foreign hosts.
## Failures and diagnostics
- `no law-dsl root found above …; pass --root` (real): start from
inside the checkout or set `LAW_MCP_ROOT`.
- `bind on "0.0.0.0" without LAW_MCP_TOKEN is refused` (real):
set a token or confirm a deliberate open bind with
`LAW_MCP_PUBLIC=1` (real escape hatch).
- `401` on `POST /mcp` (real): missing/wrong `Authorization: Bearer`
against `LAW_MCP_TOKEN` / `--token`. `403` (real): request
`Origin` not in `--allowed-origin` / `LAW_MCP_ALLOWED_ORIGINS`.
- Read-only root surprises: any write the profile's facets allow
can fail for permission — case/author-tree writes (`editing`,
`drafting`), workbench temp files under `TMPDIR`, and
`--export-neural-input`, not just the export flag. Diagnose by
asking which tool attempted the write and checking its row in the
[effects matrix](/operate/tool-profiles-input-policy/#effects-the-authoritative-matrix):
a denied-by-filesystem write from an allowed facet means the
mounts (step 2/5) are narrower than the profile, not that the
tool misbehaved.
## Support boundaries
- "Isolated" in this article means only: root containment for reads,
the write channels step 5 enumerates (nothing wider),
allowlisted outbound legs, and no ambient network use. Process
sandboxing (namespaces, seccomp, containers) is the operator's
layer — the binary requests none.
- Immutable releases, the service user, directory modes, and firewall
rules are operator policy with reference-setting examples above;
the engine's part is that read-only operation is sufficient only
for profiles denying the writing facets — with `editing` /
`drafting` allowed, the writable trees are part of the contract.
- Case inputs are trusted once inside the root: filesystem ACLs, not
the engine, decide who may change a `law.toml`.
## Next step
- Article 08 ([Data flow, storage, and retention](/operate/data-flow-storage-retention/)) for where each copy of the data rests; article 11 ([Resource limits and cancellation](/operate/resource-limits-cancellation/)) for
the runtime limits that keep one tenant's call from starving the
others.