Skip to content

GUIDES · 10

Set a jail policy.

A jail is a declarative answer to one question: what of the host filesystem can this run see? The policy is enforced in the virtual filesystem layer and installed after the mounts, before your code runs.

Note

This guide is part 2 of the Advanced payloads tutorial. The blog chapter derives a jail from a failing run.

What a jail is not.

  • Not a sandbox for hostile code. A jail shapes the filesystem view; it is not a security boundary against an adversarial payload.

  • Filesystem-only. No network, CPU, or memory metering.

  • Not for static binaries. Jails cover interpreted payloads and native binaries running under the preload shim. A statically linked binary sees only the host filesystem — for it there is no VFS, and so no jail.

The three profiles.

open

The host filesystem is fully visible. The permissive profile — the tool behaves like any installed program.

deny

The host is invisible; the process sees only its mounted images, plus whatever you explicitly grant with mounts.

deny:arg

The host is invisible except the paths you pass as arguments — those stay visible. The everyday profile for tools that edit the files you name.

The platform floor: what deny still grants.

A deny policy is not a blank refusal. Every policy bound under the deny default gains a fixed per-platform set of read-only grants — the floor — covering the surface a spawned interpreter physically cannot boot without. The floor exists because a missing platform grant never failed as a policy verdict: it crashed the child in someone else’s library (a JVM under a scratch-only jail segfaulted in its locale init when reads under /usr were denied). Denying the platform surface never produced a working process, so the floor grants it always:

  • macOS/usr, /System, /Library.

  • windows%SystemRoot%\System32, %SystemRoot%\SysWOW64, %SystemRoot%\Fonts.

  • other unix — none today; an entry joins the list only with a proven platform-process consumer, cited by run. The lists are amended by evidence, never by anticipation.

Two rules keep the floor honest. An authored mount covering a floor path supersedes the floor entry — widening one to read-write is one authored grant away, and the floor never narrows what you allowed. And every bound grant, authored or floor, implies its strict ancestors are traversable by exact-path reads, so the platform’s canonicalization walks pass by construction. The floor is system surface only: your home directory and your own tool trees stay authored grants — a deny never read-exposes them for you.

Tighten any run at invocation.

Tighten a single run at invocation with --jail:

$ tebako run ./myapp --jail deny -- --help
$ metanorma --jail deny:arg compile site.adoc

With tebako run the flag goes before the -- that separates payload arguments. At a shim, leading --jail / --mount / --no-host tokens are consumed by the dispatcher — everything else reaches the payload untouched.

Grant exactly what a run needs.

The two invocation-time grants are --mount and --no-host:

$ tebako run ./myapp --jail deny --mount "$HOME/sources:/work:ro" -- build /work
$ metanorma --no-host compile site.adoc

--mount <host:mount:ro|rw> adds a host directory into the process’s filesystem view at the given path, read-only or read-write; repeat it per grant. --no-host goes further than tightening: it drops every host grant the package requested.

Policy files, baked or invoked.

A jail policy is YAML — a default profile plus explicit mounts:

jail:
  default: deny        # or open
  mounts:
    - host: $HOME/sources
      mount: /work
      access: ro       # or rw

Publishers bake a policy into the package at press time; users apply one per run by passing the file path as the spec:

$ tebako press -r ./myapp -e bin/myapp --jail ./jail.yaml   # baked in
$ tebako run ./myapp --jail ./jail.yaml -- build              # per run

The same grammar works from the environment: TEBAKO_JAIL takes directives separated by semicolons — open, deny, <host>:<mount>:ro|rw, or @<path> to read directives from a file:

$ TEBAKO_JAIL='deny' metanorma compile site.adoc
$ TEBAKO_JAIL="$HOME/sources:/work:ro" ./myapp

Discover the surface: record mode.

An author rarely knows the host surface their payload touches — the JVM’s home probe was invisible until the journal named it. Discovery is a first-class policy mode, not a guess: TEBAKO_JAIL=record allows every host access and journals it — event=jail-allow path=… op=read|write — so the workload cannot crash from policy while you observe it. Spawned children inherit the spec and append to the same journal.

Record a run, then feed the journal to the generator:

$ TEBAKO_JAIL=record tebako run ./myapp -- build
$ tfs needs --from-journal ~/.tebako/journal.log

The generator reads the record journal — folding in jail-deny lines too, since a denial is an unmet need — and emits a draft needs: block: one entry per observed path, symbolic atoms re-substituted (/Users/alice/…$HOME/…), access set to the strongest observed op, floor and store paths excluded (they are automatic), each entry carrying a why: "TODO" for you to replace with the real reason.

The generator never edits a manifest. The record shows the observed minimum; you review the draft — flipping ro↔rw where production differs from the observation, deleting noise, filling in why — and merge it yourself. And record is a development mode: it journals at full volume and installs no enforcement, so shipping a composition with record still in force draws a named lint warning.

Declare it: the needs: block.

A slice declares its host surface in its manifest — needs: is the only spelling, one key with one grammar:

needs:
  host:
    - path: $HOME/.fontist      # absolute, or a symbolic atom
      access: rw                # ro | rw
      mount: /cache/fontist     # OPTIONAL: present the host path at this
                                # VFS point; absent = enforcement-only
      when: [macos]             # OPTIONAL platform filter
      optional: true            # OPTIONAL: absent at bind = silently skipped
      why: "fontist cache"      # MANDATORY

The symbolic atoms resolve at bind, per invocation, per user — never baked: $HOME, $TMPDIR, $CWD (the invoking directory), $TEBAKO_HOME. A need absent at bind without the optional marker is a named error, never a silent skip.

Stated plainly: record mode and the journal generator ship today at the image layer — TEBAKO_JAIL=record, tfs needs --from-journal, and needs: inside tfs exec --compose documents. The wiring of needs: into slice manifests, shims, and press is Phase-R — specified, not yet shipped. When it lands, a declared need is a contract: an effective policy that does not cover it fails the run before exec, naming the slice, the path, and the why — a need never surfaces as a mid-run EPERM.

Precedence: the user always wins by tightening.

The package manifest requests a policy; the user can only narrow it. The effective policy is the intersection — manifest ∩ user — and the user’s policy wins every conflict. A package may ask for read-write $HOME; --jail deny:arg at invocation still cuts it down, and --no-host drops the request entirely. Nothing at run time can widen what the publisher denied.

The audit journal, and failures.

Every denial appends one line to the journal — $TEBAKO_HOME/journal.log by default. When a tool cannot see something you expected it to, that file is the first place to read. Under record mode (above) the allows append too — the same file is what tfs needs --from-journal reads.

A malformed policy never runs half-applied: the run fails closed with exit 73 and nothing mounts.