Skip to content

ARCHITECTURE · 16

Jails and host proxying.

Every file access a payload makes already flows through the TFS layer, so that one choke point is where host access is decided. The policy follows docker -v semantics: a default, a mount list, and named errors instead of surprises. Enforcement exists because coverage is total by construction.

Note

The policy model, the TFS choke, manifest∩user composition, the press/run/shim surfaces, the platform floor, jail-deny journaling, and record mode are shipped.

The policy model

jail:
  default: open | deny          # open = cwd + writes pass through
  mounts:
  - host: $HOME/sources
    mount: /work
    access: ro | rw
  argument_files: auto-allowed  # the input file you hand the command
                                # is allowed even under deny
PROFILE SPEC MEANING

full access

default: open

The host cwd and writes pass through; this is the historic behavior.

directory-scoped

cwd mounted at /work, rw

The working tree is visible, and nothing else is.

file-scoped tight jail

default: deny + argument files

An untrusted payload runs against one input, and nothing else is visible.

Writes to an ro mount fail EROFS, and paths outside every mount fail EPERM. Payload images themselves are always read-only. Symlinks resolve inside the granted view, with realpath checked at bind time and re-validated on each open; a symlink swapped in after install resolves to its target, and escapes fail EPERM. Precedence is locked: the package’s manifest requests access (capabilities.host), and the user can always tighten (tebako run --jail, --mount src:/work:rw, --no-host). User policy always wins, and signed payloads may get wider latitude by policy choice, never by bypass. Deny defaults win, and each side’s grants are capped by the other side’s allowance at the same prefix, so ro is sticky.

The platform floor

Under the deny default, every bound policy gains a fixed per-platform set of read-only grants covering the surface that a spawned interpreter physically cannot boot without. The floor exists because the failure mode for a missing platform grant was never a policy verdict; it was a crash in someone else’s library. Denying the platform surface never produced a working process, so the floor grants it always.

PLATFORM FLOOR ENTRIES (ro) EVIDENCE

macOS

/usr · /System · /Library

The JVM NULL-derefs in getMacOSXLocale when /usr reads are denied: a SIGSEGV in someone else’s library, observed 2026-08-13.

windows

%SystemRoot%\System32 · %SystemRoot%\SysWOW64 · %SystemRoot%\Fonts

The floor covers the loader’s DLL root, the 32-bit view for 32-bit children, and the GDI font tree that AWT enumerates.

other unix

none today

An entry joins only with a proven platform-process consumer, cited by run; the floor is amended by evidence, never anticipation.

The rules are the following. An authored mount covering a floor path supersedes it: the floor never narrows what the author allowed, and widening a floor path to rw is one authored grant away, while there is no way to drop a floor path short of not denying. Every bound grant implies that its strict ancestors are traversable, as exact-path reads, never a prefix and never a write, so the platform’s canonicalization walks pass by construction (the JVM reads its cwd and every ancestor at VM init). Floor mounts are never serialized into TEBAKO_JAIL: the env grammar’s right-split cannot carry a windows drive-qualified spelling, and it never needs to, because every consumer re-derives the floor at its own bind, so a spawned child’s inherited spec plus its bind yields a policy identical to the parent’s. The boundary is honest: the floor is system surface only, and the workload’s own tool tree and the user’s home stay authored grants. The floor’s promise is the end of the segfault class: every missing grant surfaces as the workload’s own named error, pinned in the audit journal.

The choke point

tebako_fs_host_policy installs the policy as process state; an unmount never resets it (fail-closed), and a later call replaces it. A host path means any path the mounts do not hold, outside every mount, or covered by a mount but absent from its image. The app payload mounts at /, and the host stays reachable exactly through this rule. A mount’s access bit applies even under open, longest host prefix wins on path-component boundaries, and argument files are an exact read-only grant, even under deny.

GATED DECISION ANSWER

open (read and write modes)

The answer is EPERM denied, and EROFS on an ro mount.

stat · opendir

The answer is EPERM denied.

dlmap2file

The answer is EPERM denied; native-library load paths are policy surface.

extract_all destination

It is a host write, gated as one.

the mount family’s own image read

It is policy-gated once a policy is active; install the policy AFTER the mounts.

Because bootstrap, runtime driver, tebako run, and tfs exec all enforce in the same Rust layer, every consumer behaves identically with no per-app work. The bootstrap composes package jail: ∩ TEBAKO_JAIL at handoff and exports the effective policy, plus TEBAKO_JAIL_SOURCE = manifest|user|manifest+user; a malformed policy fails closed with exit 73. Every denial appends event=jail-deny path=… op=read|write source=… to the audit journal, best-effort, because journaling never fails the operation it audits.

Record mode: discovering undeclared needs

A slice author usually does not know the host surface that the executable touches. policy: record (env form TEBAKO_JAIL=record) is a third policy default beside open/deny: every host passthrough check is allowed and journaled as event=jail-allow. Nothing is denied, so the workload cannot crash from policy during discovery, and spawned children inherit the spec and append to the same journal.

$ TEBAKO_JAIL=record tebako run pkg -- input.docx
$ tfs needs --from-journal ~/.tebako/journal.log
needs:
host:
- path: $HOME/.cache/vendor-tool
  access: rw                    # strongest observed op wins
  optional: true                # absent at generation time
  why: "TODO — observed: 14 read, 3 write"   # you replace this

The generator folds jail-deny lines in too, because a denial is an unmet need. It aggregates on the atom-substituted form, excludes the automatic surface (floor, store, and exec-cache paths, which are never declared), re-substitutes symbolic atoms with longest prefix winning, omits relative paths (cwd-relative probes are not declarable, so $CWD is declared explicitly), collapses ro ancestors that the bind’s traverse set already covers, and marks absent-at-generation paths optional: true. Then comes the human gate: the record shows the observed minimum, and the author flips ro↔rw where production differs, deletes noise, fills why, and merges into D1 or D2. The generator never edits a manifest. Record is a development mode: shipping a composition with it in force is a named lint warning, and the store’s audit journal marks record-mode runs.

The scope of the jail

Jails are filesystem-only. capabilities.network is advisory metadata, not an enforcement promise, and OS-level network confinement (seatbelt/seccomp/WFP) is a possible later layer that is never claimed today. Coverage is every IO route through TFS: all interpreted payloads, plus native binaries under the preload interposition shim. The shim’s coverage is symbol-level (tebako#439): a jail holds only for the libc entry points that the shim interposes, and the named list includes the LFS64/fortify/versioned twins (open64, openat_2, the xstat family), because glibc hands _FILE_OFFSET_BITS=64 callers distinct symbols that never touch the plain names. Known-uncovered at this layer are the remaining fortify open family and raw syscall(2) IO, and statically linked binaries have no interposition point at all. The trust note completes the scope: tebako is the executor of everything it runs, mediating a child’s syscalls is the execution contract rather than hijacking, and jails bind the child downward while claiming nothing about the executor or the kernel upward.