docs← Back to article

Markdown for LLMs

Runtime filesystem isolation

The source Markdown for this article. Copy it into your assistant or download it as a text file.

Download this articlePlain text ↗
# 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.