Releases: bjorges/tjor
Release list
v0.21.3 — Worktree mounts: native layout only, explicit approval; git-check walks every root (v0.21.2 review)
Review follow-ups for v0.21.2 (external review; both Criticals confirmed
against the code — one by running the resolver against the forged layout —
before fixing).
Security
- A forged worktree structure can no longer mount an arbitrary host
directory (v0.21.2 review, Critical). The #79 resolver verified that
the private git directory linked back to the root, but the private
directory itself could live inside the writable workspace with a
commondirfile aimed at any repository on the host — three plaintext
files, all writable from the cage, "proving" a path the operator never
named. Two layers now: the private directory must be
<common>/worktrees/<name>inside the common directory git resolves, so
the back-link is a file inside the target that only someone who can
already write there could plant; and a common directory outside every
mount root is mounted only with--allow-worktree-mount(or named with
--dir), else the launch refuses and prints the exact path. tjor mounts
only paths the operator named, again. git-checkno longer skips a root on its shape (v0.21.2 review,
Critical).HEAD+objects/+refs/planted at the top of the
workspace made its whole subtree invisible to the baseline and every
check, silently. Every root is walked; a git-directory root simply yields
no repository of its own.- A symlinked
.git/configis refused regardless of the pin setting
(v0.21.2 review, Low): the refusal was gated onprotect_git_config
while documented as unconditional.
Changed
worktree_common_dirreports eachgit rev-parsefailure with its own
error text. README: the acknowledgement token binds to a state, not to
proof of review — keep--ackin human hands; two sessions sharing a
main repository coordinate only through git's own locks. Tests: the
forged layout, the unapproved common dir, explicit--dirapproval, the
planted git-dir shape, the config symlink with the pin off.
v0.21.2 — Worktree workspaces (#79); review follow-ups for v0.20.4–v0.21.1
Added
- A linked worktree works as the workspace or a
--dir/--dir-ro(#79,
from the #77 reproduction). Its.gitfile names a git directory under
the main repository, outside the worktree; git treats the missing target
as a hard error for every command, so the agent container died at the
entrypoint's firstgit config(exit 128) before the harness started.
The launcher now resolves the common directory through git and mounts it
alongside the worktree at its host path with the same writability —
validated by git's own linkage (a git directory whose worktree entry links
back to this checkout, or whosecore.worktreenames it), never by the
pointer alone: an unresolvable pointer, one that does not link back, a
symlinked.gitor a sensitive target refuses the launch with the fix
named (no--unsafe-diroverride for the last). The mounted common dir is
an ordinary root: git-trusted, kernel-granted, hooks masked (a bare-named
repo.gittoo), config pinned when opted in, judged by the nesting and
self-mount rules, baselined through the worktree without walking
objects/. Shared common dirs mount once, writable if any sharer is.
Tests: a daemon-freeworktreesuite (resolution, every refusal, list
wiring, the mask planner) and a live landlock section (status/log/commit
in-cage from a worktree, the main repository's hook masked).
Review follow-ups for v0.20.4 → v0.21.1 (external review of the batch; every
Critical and High below was confirmed against the code before fixing).
Security
tjor init/trust/policy/doctorstop on a core.worktree redirect
(v0.20.4 review, Critical).repo_root()ran the guarded resolver
inside command substitution, wheredie()exits only the subshell: the
redirect was printed and then ignored —init/trustwent on to say
"run inside a git repository",policyanddoctorfell through to the
user policy. Every$(repo_root)/$(policy_file)consumer now propagates
the refusal; the launcher test drives all four commands against a planted
redirect and asserts nothing was scaffolded.ext::/fd::transports onremote.*.url/pushurland
submodule.*.urlare findings (v0.21.1 review, Critical).
ext::<command>runs on every fetch, pull, push and clone; the value-based
rule mirrors the!-alias one, an https/ssh URL stays quiet. Added to the
documented set as well:sendemail.smtpServerCommand/sendmailCmd(and
per identity),sendemail.smtpServeras a program path,imap.tunnel,
protocol.allow/protocol.*.allow,trailer.*.command/cmd,
guitool.*.cmd,init.templateDir.- A truncated repository walk is never silent (v0.21.1 review,
Critical). The depth cap — now[landlock] git_check_depth, default
32, was a fixed 12 — and unreadable directories are recorded in every
snapshot: announced at launch, and a NEW spot after the baseline is a
finding. A repo planted below a spot already truncated at launch remains
the documented residual, named in the README. - An unparseable git config is a finding, never "no dangerous keys"
(v0.21.1 review, Critical).git config --listfailing records the file
as unreadable, reported at every check with git's error (fail closed, like
the hooks path). - A symlinked
.git,.git/hooksor.git/configrefuses the launch
(v0.21.0 review, High).mkdir -pand the bind mount both follow the
final component, so a link a previous session planted could have steered
the hooks mask (or the config pin) onto a chosen path under an
ordinary-looking launch line. The refusal names the link, its target, the
fix and the opt-out; the check also reports a hooks dir or.gitturned
symlink. --jsonsets the pending marker on findings (v0.21.1 review, High) —
the same rule as the human path; its output is escape-sanitized like the
terminal's, and credentials embedded in URL-shaped keys or values are
redacted in both.--ackis bound to the reviewed state (v0.21.1 review, High).
Findings print a token;tjor git-check --ack <token>accepts exactly
that state as the new baseline. A bare--ack, a wrong token or a stale
one (the state moved on) shows the findings and refuses.- Every value of a repeated key is compared (v0.21.1 review, Medium): a
value slipped between two legitimatesafe.directoryentries was
invisible to a last-value-wins dict. ssl_insecureis rejected synchronously (v0.20.4 review, Low): the
addon'sconfigureraisesOptionsErrorinstead of scheduling a
shutdown.
Changed
- Pending-marker writes are atomic (write, then rename) through one helper;
the repo-coverage, roots and re-baseline lookups moved from inline
heredocs intotjor_gitcheck.py(covers,roots,rebaseline);
discovery is one walk per snapshot instead of one per repository; the
hooks-maskfindprunes below.git.tjor git-check --jsonnow prints
an object (findings,token,incomplete) instead of a bare list. - One
gate_sensitive_pathfor the workspace and every--dir/--dir-ro
(three verbatim copies before);dir_is_sensitiveinitializes its own
roots (the ordering dependency is gone); onemask_coveredhelper for the
four mask blocks; the git masks live inplan_git_masks, driven directly
by the launcher test. GH_TOKENis unset whenever the broker does not cover the API host —
including a disabled broker — documented as intentional (README, ADR 0007)
and covered by the live broker test. New tests:doctor/init/trust/
policyredirect refusals; a worktree whose common dir lies outside every
root; every documented dangerous key has a unit case.
v0.21.1 — Git tamper detection: tjor git-check, launch baseline, crash-safe marker (#72)
Security
- Git tamper detection:
tjor git-check, a launch baseline, and a
crash-safe pending marker (#72). The v0.21.0 masks keep.git/hooks
empty and, opt-in, pin.git/config; what they cannot prevent —
core.hooksPathand every other key host git would execute, a re-pointed
worktree.gitfile orcommondir, a nested.git/planted in the working
tree — lands on the host the moment the cage writes it, and a crashed
session never reached any teardown report. Now: at launch, once the mount
set is final and before any container starts, the launcher records per
writable-root git dir the dangerous keys present (the documented set in
python/tjor_gitcheck.pyand the README: hooks, editors, pagers, ssh and
credential commands, filter/diff/merge drivers, remote helpers and proxies,
!-aliases, includes,url.*.insteadOf,safe.*,extensions.*), the
config symlink state, worktree pointers, hook hashes and existing nested
repos — under the session state dir, never mounted into the cage. The check
runs at attached-session exit, attjor down(which still completes) and
viatjor git-check [<repo> | --session <id>] [--ack] [--json], reporting
each finding escape-sanitized with repo, class, key and old → new value.
branch.*,remote.*.url/fetch/push,user.*and the rest are never
findings (apush -uis clean, tested). The pending marker is cleared only
by a clean check or--ack(which accepts the current state as the new
baseline) and set again by any check with findings;tjor lslists unchecked sessions, containers
or not.git-checkexits non-zero on findings so it can gate host-side git
in the operator's own shell — tjor ships no hook. Detection only: the
window is narrowed, not closed. Newgit-tamper-detectioncapability; a
daemon-freegitchecksuite (18 checks) and 57 module tests; six matrix
rows.
v0.21.0 — Git hooks masked in writable mounts; opt-in .git/config pin (#71)
Breaking by design (pre-1.0 minor bump): host-installed git hooks no longer
fire on in-cage commits unless [landlock] mask_git_hooks = false.
Security
- BREAKING (by design): git hooks directories are masked in every writable
mount (#71). A hook the cage writes into<repo>/.git/hooks/runs in the
operator's host git on the next ordinary command, outside every tjor
boundary — true for any writable mount, the workspace first. The launcher
now masks the hooks directory of every git dir it finds under a writable
root (workspace,--dirrepos, repos nested under a mounted parent, a
linked worktree's common dir when it lies under a writable root) with the
same read-only empty bindmask_dirsuses: in-cage it lists empty, nothing
can be created in it, it cannot be removed or replaced, and git runs no hook
from it. A missing hooks dir is created on the host first so the mask has a
mountpoint. Read-only mounts need none. What breaks: host-installed hook
frameworks (pre-commit, lefthook, husky) no longer fire on in-cage commits
—[landlock] mask_git_hooks = falserestores them. Residuals, stated:
core.hooksPathbypasses the hooks mask (the pin below is the preventive
answer); a repo created mid-session is not masked; siblings under one
writable parent are not isolated from each other. #72 tracks detection of
the rest. - Opt-in
[landlock] protect_git_configpins.git/configread-only
(#71). The real file is bound over itself:ro; git replaces config by
rename and a mountpoint cannot be renamed over, so every write fails while
reads work. This is the only preventive control here against
core.hooksPath/core.fsmonitor/ filter-driver redirection. Cost:
git configandgit remote addfail in-cage (verified live), and by the
same mechanism every other config-writing operation —push -u,branch --set-upstream-to,worktree add -bwith tracking,gh pr checkout;
commit,pushwithout-u,fetch,status,log,diffwork
(commit and reads verified live). Default off; #71's open question — default-on for untrusted-content
profiles — stays open until measured against a real profile. - Coverage: the
landlocksuite gains section A4 (hooks listing empty and
unwritable in the workspace, a nested repo and a worktree's common dir; a
hostpre-commitdoes not fire in-cage; the opt-out; the pin's writes,
reads and commits); six boundary-matrix rows. Spec:kernel-sandboxgains
two requirements. Closes #10's "targeted LSM denies (git hooks dir)"
candidate structurally.
v0.20.4 — Review follow-ups: one workspace resolver, bounded tripwire, GH_TOKEN unset, mint-after-refusal
Review follow-ups for v0.18.16 → v0.20.3 (external review of the batch; the
three Highs and every Medium below were confirmed against the code before
fixing).
Security
- One guarded workspace resolver for every host-side consumer (v0.20.2
review, High). v0.20.2's core.worktree check guardedresolve_session
only; the identicalgit rev-parse --show-toplevelsat unguarded in
tjor attach(short-name qualification — a planted redirect could attach
the terminal to another repo's running agent when short names collide),
inrepo_root(the trusted repo policy/config behindtrust,init,
policy— a redirect could show the wrong path's policy to the operator's
informed-consent review) and indoctor.workspace_toplevel()now does
the cross-check once and everything resolves through it; the redirect
section of the launcher test covers attach and repo_root. The message no
longer suggests unsettingcore.worktreewhen none is set (it names
GIT_DIR/GIT_WORK_TREEinstead);nearest_git_rootrefuses a relative
path. - The egress secret tripwire is bounded before decompression (v0.18.17
review, High). The scan readflow.request.content, which mitmproxy
transparently decompresses — a ~1 MB gzip request body (under
stream_large_bodies) toward a scanned host could inflate to a gigabyte in
the shared proxy sidecar beforescan_max_bytesapplied: a decompression
bomb against the session's only egress. The scan now reads the wire bytes,
caps them, and inflates gzip/deflate with zlib'smax_length(output
capped regardless of ratio); brotli/zstd bodies are forwarded unscanned,
like streamed bodies. Tests: a 50 MB bomb yields at most the cap; gzip
bodies are still scanned; the scan site keys on the SNI after the pin. GH_TOKENis explicitly unset when the broker does not cover the API
host (v0.20.1 review, High). The comment said "left unset"; now the code
enforces it, so an ambient token (a directdocker run -e GH_TOKEN=…, an
image-baked value) never rides into the cage un-brokered. Live test:
kube-only broker + ambient token → unset.- Credential material is minted after every refusal (v0.19.0 review,
Medium).cmd_runresolved the session and minted broker material
before the--dir/--dir-rogates and the self-mount guard ran, so a
refused launch could leave a real PAT / GitHub App key / live kube token on
disk, invisible totjor ls/gc. The mint now runs last; the launcher
test asserts a refused--dirleaves nobroker.json(with a control). ssl_insecurefails closed at proxy startup (v0.20.1 review, Medium).
SNI-keyed injection is safe only because upstream certificates are
verified; the addon'sconfigurehook now shuts mitmproxy down if
ssl_insecureis ever set.
Changed
- Dropped the unused
TJOR_ALLOW_SELF_MOUNTexport (v0.20.0 review: dead
code implying a cage-side re-check that does not exist).self-install
swaps thecurrentsymlink atomically (rename over, no unlinked window). - Tests:
kinds_presentcovered for all six secret shapes; empty-secret and
bytes-SNI paths; the launcher test's redirect section comment now describes
the shipped design and its sections are in order.
v0.20.3 — Conformance probe follows the client's auth scheme
Fixed
- The
broker: the agent's placeholder is overwrittenconformance probe
expected the old injection contract — it sent git's Basic placeholder and
demanded atoken …header upstream, which is exactly the scheme GitHub's
git endpoint rejects and which v0.20.1 stopped emitting for Basic clients.
The proxy was right; the probe was stale, and it turned the v0.20.1 and
v0.20.2ciruns red on that one probe (the other 17 passed; image
publishing was unaffected). The probe now expects Basic
x-access-token:<real token>and asserts the placeholder is absent raw and
decoded. A new probe covers gh'stokenscheme being kept with only the
secret swapped. 19/19 locally on colima; one new boundary-matrix row.
v0.20.2 — Refuse a core.worktree redirect of the workspace (#76)
Patch release: one refusal that no honest git layout hits, closing the route
by which a session could steer the next host-side launch to another directory.
Security
- A planted
core.worktreecan no longer redirect the next launch (#76,
reproduced and closed). tjor resolves the workspace on the host with
git rev-parse --show-toplevel. Withcore.worktree = <path>in a repo's
.git/config, git answers<path>from anywhere inside that repo — and a
session always has its workspace repo mounted writable, so it can plant
that line. On the operator's nexttjor runfrom the same repo, tjor
silently adopted<path>as the workspace whenever it was not in the
sensitive set (another project, or a parent holding every repo): mounted
writable, git-trusted as a tree, under a session id derived from it. A
redirect toward$HOMEwas caught by the v0.19.0 gate but reported as a
git climb, anddown/status/resetunder any redirect acted on the
wrong session. The launcher now finds the repository on its own — the
nearest ancestor of the launch directory holding a.gitentry — and
requires git's reported work tree to be that directory (both physical);
containment alone would not do, since a redirect to an ancestor still
contains the launch directory. Every honest resolution agrees: plain
repos, subdirectories, linked worktrees, submodules, nested repos,
symlinked spellings. Otherwise it refuses, on the launch path and on every
lifecycle path, naming the launch directory, the reported work tree, the
repository it found, thecore.worktreevalue and its config file, and
the remedies. No override:
a repo whose work tree is elsewhere (a detached-git-dir layout) is never a
tjor workspace — launch from the real work tree; the qualified--session
id keeps working from any other directory. The reproduction is a permanent
regression section of theworkspace-gatesuite (one boundary-matrix
row). Companion: #72 (detecting other cage-written git metadata that runs
on the host).
v0.20.1 — gh in broker sessions + four broker regressions fixed (#65)
Patch release: a small feature (gh authenticates through the broker) and
four fixes that restore brokered authentication in real sessions — read the
Security entry.
Fixed
ghworks in broker sessions (#65). With a GitHub-covering broker the
cage wired a placeholder git helper but gave theghCLI nothing, so
gh api/gh pr …failed with "not logged in" in exactly the sessions
where GitHub access is brokered — and the only workaround wasgh auth login, minting a real long-lived token into the cage (the ADR 0007
limitation). The entrypoint now decides, with the same shared matcher and
independently of the git decision, whether the broker covers
api.github.com:443, and if so exportsGH_TOKEN=tjor-broker-placeholder
into the agent environment;ghsendsAuthorization: token <placeholder>
and the proxy substitutes the real credential toward the covered host, as
it already did for git's Basic placeholder. Not covered (kube-only broker,
a host list naminggithub.comwithout a glob, no broker) leavesGH_TOKEN
unset. The default*.github.comcovers it.
Security
- Behavior change, called out:
ghprefersGH_TOKENover a stored
hosts.yml, so a token someone minted withgh auth logininside an
earlier session is no longer used in a broker session — the brokered
identity is the session's identity. Andgh auth loginrefuses to run
whileGH_TOKENis set, which removes the easy in-cage path to minting a
real token in such sessions. Honest scope: the agent can unset the variable,
so the ADR 0007 limitation is narrowed, not closed (ADR amended). - Caveat: a GitHub App installation token cannot read
/user, sogh auth statusmay report a failure while repo-scoped commands work (README). - Tests: the proxy unit test now covers gh's
tokenscheme; the live broker
integration test asserts theGH_TOKENplaceholder in a covered session
(secret scan unchanged) and the three coverage cases at direct invocation. - Four regressions found by the real-token end-to-end, fixed here.
(1) Proxy host-scoped decisions were keyed on the pinned IP. Since the
#41 resolve-and-pin (v0.17.4), mitmproxy reports the pinned upstream IP in
flow.request.hostfor every tunneled request, so the credential broker,
x-agent identity injection, the LLM-gateway key, the #62 secret scan and
the denial log compared an IP against hostnames and never matched a
DNS-resolved destination — git's and gh's placeholders were forwarded
unsubstituted and GitHub answered 401. The policy verdict used the
hostname, so requests were allowed and nothing looked denied; unit tests
model hostnames and the conformance broker probes run with the IP guard
off, so CI never saw it. Every such decision now keys on the client's SNI —
the hostname mitmproxy verifies the upstream certificate against, so a
forgedHostheader inside a tunnel to another server cannot attract a
credential (regression-tested both ways). (2) cplt sets
GIT_CONFIG_NOSYSTEM=1for the sandboxed harness, so under the
kernel-sandbox tier git ignored/etc/gitconfig— the placeholder helper,
the gh fallback, the SSH→HTTPS rewrites andsafe.directorytree trust,
all of it; the uid alignment masked the trust half. The child's environment
is now corrected inside the sandbox. (3) cplt's Landlock policy denies
reading/etc/gitconfig; the wrap grants exactly that file read-only.
(4) The injected scheme was wrong for git. The proxy injected
token <t>toward every covered host; GitHub's git smart-HTTP endpoint
accepts only Basic (x-access-token:<token>) and answers 401 totoken
andBearer, whileapi.github.comaccepts all three — so brokered git
auth never worked against real GitHub (ADR 0007's "GitHub accepts … as
token <t>" holds for the API only). The credential is now re-issued in
the scheme the client sent: git's Basic stays Basic, gh'stokenstays
token, Bearer stays Bearer. Verified end-to-end:gh api userand
git ls-remoteon a private repository both succeed from inside the
wrapped harness with only the placeholder in the cage.
The live broker test now asserts git's and gh's view from inside the
wrapped harness. Follow-up (not done): a conformance probe for injection
with the IP guard on needs a publicly resolvable echo target.
v0.20.0 — Launcher self-mount guard + tjor self-install (#67)
Breaking by design (pre-1.0 minor bump): the tree bin/tjor runs from is
refused as a writable mount unless --allow-self-mount; developing tjor
inside tjor now goes through tjor self-install.
Security
- BREAKING (by design): the running tjor tree is refused as a writable
mount (#67). The treebin/tjorruns from executes unsandboxed on the
host on every invocation and is the build context of every image — the
proxy (holding the session MITM CA key and brokered credentials) and the
root-running agent entrypoint included. Mounted writable into a cage (as the
workspace or a--dir, whether the mount is the tree, a parent, or a
directory inside it), one write persisted into every later session and onto
the host; the natural trigger was developing tjor inside a tjor session
launched by the checkout's ownbin/tjor. The launch is now refused after
every mount root is canonical and before any image is resolved or built,
naming both paths and the remedies.--allow-self-mountoverrides with a
loud warning; a read-only overlap (--dir-ro) is allowed with a notice.
Applies to every install kind. tjor self-install [--ref <commit-ish>](#67). Archives the committed
tree into~/.tjor/install/<sha>/(marked.tjor-source-sha,chmod -R a-w,currentsymlink), prints the launcher path and the commits since the
previous install, and is idempotent per sha. This is how to develop tjor
inside tjor: launch from the installed copy with the checkout as the
workspace.a-wguards against accidents only — the agent runs as the host
uid — the controls are the refusal above and the install root joining the
sensitive set:~/.tjor/install(orTJOR_INSTALL_ROOT) is refused as a
workspace or--dir/--dir-rounless--unsafe-dir.- A self-installed tree builds locally, like a checkout (ADR 0008
amended). The pull-vs-build decision keys on "source tree" (.gitor the
marker), so an archived tree never pulls a published image for its
VERSION. Only an installed release pulls. - Images record their source. Every local build (agent, proxy,
conformance) is labeledtjor.source-sha: the marker's sha, orHEAD
(-dirtywith uncommitted changes) for a checkout, orrelease-<VERSION>. tjor doctorreports launcher mutability. The root line says which kind
of tree is running (mutable git checkout / self-installed<sha>,
read-only / installed release), and from inside a checkout warns that a
launch from here would be refused, namingself-install. Scoping note: the
issue's "whether any configured profile mounts it writable" has no
equivalent — profiles declare no mounts — so the launch-from-here case is
what doctor checks.- Coverage:
tests/integration/self_mount_test.sh(daemon-free,unit
job; 45 checks) proves the refusals, the override, the read-only notice,
self-install's properties, the marker-driven local build, the label, the
install-root sensitivity and the doctor report; it is theself-mountsuite
of the boundary matrix with a newlauncher-integritycapability. Specs:
newlauncher-integrity;image-distributionandsession-launchamended.
v0.19.0 — Workspace sensitive-path gate (#64)
Breaking by design (pre-1.0 minor bump): a workspace that resolves to a
sensitive host path is now refused unless --unsafe-dir is given. Repository
workspaces are unaffected.
Security
- BREAKING (by design): the sensitive-path gate now covers the primary
workspace (#64).dir_is_sensitiverefused/, system directories,
$HOMEand its ancestors, and credential directories for every--dir/
--dir-ro— but never for the workspace itself. On a machine whose dotfiles
repository is rooted at$HOME,tjor runfrom~(or from any
non-repository directory under it) resolved the workspace to$HOMEvia
git rev-parse --show-topleveland mounted the entire home directory
writable and git-trusted as a tree, silently. The workspace is now checked
with the same rule, on the launch path only, before the session state
directory is created and before any credential is minted; a refusal leaves
nothing behind. When git discovery climbed above the launch directory to
reach a sensitive toplevel, the error says so (<cwd> is not a repository; git resolved the workspace to <toplevel> via <toplevel>/.git — launch from a repository (or pass --unsafe-dir)).--unsafe-dirremains the single
override and now prints a loud warning naming the exposed path whenever it
actually overrides a refusal (workspace or extra dir); it stays silent when
nothing needed overriding. Lifecycle commands (down,status,reset,
denials) never apply the gate, so a session launched under the override
remains manageable. Repository workspaces are unaffected. - tjor's own roots join the sensitive set (#64). The effective
session.root(every state dir under it holds a session's proxy CA key,
broker material and harness auth) and the effective user-config directory
($TJOR_USER_CONFIG's dir, else$XDG_CONFIG_HOME/tjor, else
~/.config/tjor) are refused when a path equals, contains, or lies under
them — for the workspace and for--dir/--dir-roalike (--dir ~/.tjor/sessionswas accepted before). Custom locations are honored. - The cage refuses a system-directory mount root at direct invocation
(#64). The entrypoint now aborts (exit 90) when any approved root in
TJOR_SAFE_DIRSis/etc,/usr,/var,/bin,/sbin,/boot,
/sys,/proc,/dev,/rootor a child of one;/was already refused.
The launcher's--unsafe-dirreaches the cage (TJOR_UNSAFE_DIR) and
downgrades this to a loud warning, so the one override holds end to end.
Honest scope: this is the only half of the gate the cage can judge —
the host home, its credential dirs, the session root and the config dir are
host facts the container cannot see, so those remain a launcher
guarantee, and the docs say so rather than claiming an in-cage re-check. - Coverage: a new daemon-free launcher test
(tests/integration/workspace_gate_test.sh,unitCI job) proves every
refusal, the override, the no-state-dir property and the lifecycle
exemption; it is the third suite source of the boundary matrix
(workspace-gate, launcher-side) with six renderedsession-launchrows.
Theagent-imageCI job gains the direct-invocation negative path.
session-launchspec amended.