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
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
| PROFILE | SPEC | MEANING |
|---|---|---|
full access |
|
The host cwd and writes pass through; this is the historic behavior. |
directory-scoped |
|
The working tree is visible, and nothing else is. |
file-scoped tight jail |
|
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 |
|
The JVM NULL-derefs in getMacOSXLocale when /usr reads are denied: a SIGSEGV in someone else’s library, observed 2026-08-13. |
windows |
|
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 |
|
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.
See also: Declarative composition · Native interposition · Trace and observability.