Skip to content

Releases: bjorges/tjor

v0.21.3 — Worktree mounts: native layout only, explicit approval; git-check walks every root (v0.21.2 review)

Choose a tag to compare

@github-actions github-actions released this 28 Sep 05:34

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
    commondir file 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-check no 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/config is refused regardless of the pin setting
    (v0.21.2 review, Low):
    the refusal was gated on protect_git_config
    while documented as unconditional.

Changed

  • worktree_common_dir reports each git rev-parse failure with its own
    error text. README: the acknowledgement token binds to a state, not to
    proof of review — keep --ack in human hands; two sessions sharing a
    main repository coordinate only through git's own locks. Tests: the
    forged layout, the unapproved common dir, explicit --dir approval, 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

Choose a tag to compare

@github-actions github-actions released this 28 Sep 03:53

Added

  • A linked worktree works as the workspace or a --dir/--dir-ro (#79,
    from the #77 reproduction).
    Its .git file 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 first git 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 whose core.worktree names it), never by the
    pointer alone: an unresolvable pointer, one that does not link back, a
    symlinked .git or a sensitive target refuses the launch with the fix
    named (no --unsafe-dir override for the last). The mounted common dir is
    an ordinary root: git-trusted, kernel-granted, hooks masked (a bare-named
    repo.git too), 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-free worktree suite (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/doctor stop on a core.worktree redirect
    (v0.20.4 review, Critical).
    repo_root() ran the guarded resolver
    inside command substitution, where die() exits only the subshell: the
    redirect was printed and then ignored — init/trust went on to say
    "run inside a git repository", policy and doctor fell 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 on remote.*.url/pushurl and
    submodule.*.url are 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.smtpServer as 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 --list failing records the file
    as unreadable, reported at every check with git's error (fail closed, like
    the hooks path).
  • A symlinked .git, .git/hooks or .git/config refuses the launch
    (v0.21.0 review, High).
    mkdir -p and 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 .git turned
    symlink.
  • --json sets 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.
  • --ack is 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 legitimate safe.directory entries was
    invisible to a last-value-wins dict.
  • ssl_insecure is rejected synchronously (v0.20.4 review, Low): the
    addon's configure raises OptionsError instead 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 into tjor_gitcheck.py (covers, roots, rebaseline);
    discovery is one walk per snapshot instead of one per repository; the
    hooks-mask find prunes below .git. tjor git-check --json now prints
    an object (findings, token, incomplete) instead of a bare list.
  • One gate_sensitive_path for the workspace and every --dir/--dir-ro
    (three verbatim copies before); dir_is_sensitive initializes its own
    roots (the ordering dependency is gone); one mask_covered helper for the
    four mask blocks; the git masks live in plan_git_masks, driven directly
    by the launcher test.
  • GH_TOKEN is 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/
    policy redirect 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)

Choose a tag to compare

@github-actions github-actions released this 27 Sep 19:54

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.hooksPath and every other key host git would execute, a re-pointed
    worktree .git file or commondir, 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.py and 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, at tjor down (which still completes) and
    via tjor 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 (a push -u is 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 ls lists unchecked sessions, containers
    or not. git-check exits 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. New git-tamper-detection capability; a
    daemon-free gitcheck suite (18 checks) and 57 module tests; six matrix
    rows.

v0.21.0 — Git hooks masked in writable mounts; opt-in .git/config pin (#71)

Choose a tag to compare

@github-actions github-actions released this 27 Sep 19:36

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, --dir repos, 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 bind mask_dirs uses: 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 = false restores them. Residuals, stated:
    core.hooksPath bypasses 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_config pins .git/config read-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 config and git remote add fail in-cage (verified live), and by the
    same mechanism every other config-writing operation — push -u, branch --set-upstream-to, worktree add -b with tracking, gh pr checkout;
    commit, push without -u, fetch, status, log, diff work
    (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 landlock suite gains section A4 (hooks listing empty and
    unwritable in the workspace, a nested repo and a worktree's common dir; a
    host pre-commit does not fire in-cage; the opt-out; the pin's writes,
    reads and commits); six boundary-matrix rows. Spec: kernel-sandbox gains
    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

Choose a tag to compare

@github-actions github-actions released this 27 Sep 19:10

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 guarded resolve_session
    only; the identical git rev-parse --show-toplevel sat unguarded in
    tjor attach (short-name qualification — a planted redirect could attach
    the terminal to another repo's running agent when short names collide),
    in repo_root (the trusted repo policy/config behind trust, init,
    policy — a redirect could show the wrong path's policy to the operator's
    informed-consent review) and in doctor. 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 unsetting core.worktree when none is set (it names
    GIT_DIR/GIT_WORK_TREE instead); nearest_git_root refuses a relative
    path.
  • The egress secret tripwire is bounded before decompression (v0.18.17
    review, High).
    The scan read flow.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 before scan_max_bytes applied: a decompression
    bomb against the session's only egress. The scan now reads the wire bytes,
    caps them, and inflates gzip/deflate with zlib's max_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_TOKEN is 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 direct docker 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_run resolved the session and minted broker material
    before the --dir/--dir-ro gates and the self-mount guard ran, so a
    refused launch could leave a real PAT / GitHub App key / live kube token on
    disk, invisible to tjor ls/gc. The mint now runs last; the launcher
    test asserts a refused --dir leaves no broker.json (with a control).
  • ssl_insecure fails closed at proxy startup (v0.20.1 review, Medium).
    SNI-keyed injection is safe only because upstream certificates are
    verified; the addon's configure hook now shuts mitmproxy down if
    ssl_insecure is ever set.

Changed

  • Dropped the unused TJOR_ALLOW_SELF_MOUNT export (v0.20.0 review: dead
    code implying a cage-side re-check that does not exist). self-install
    swaps the current symlink atomically (rename over, no unlinked window).
  • Tests: kinds_present covered 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

Choose a tag to compare

@github-actions github-actions released this 27 Sep 15:17

Fixed

  • The broker: the agent's placeholder is overwritten conformance probe
    expected the old injection contract
    — it sent git's Basic placeholder and
    demanded a token … 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.2 ci runs 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's token scheme 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)

Choose a tag to compare

@github-actions github-actions released this 27 Sep 15:15

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.worktree can no longer redirect the next launch (#76,
    reproduced and closed).
    tjor resolves the workspace on the host with
    git rev-parse --show-toplevel. With core.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 next tjor run from 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 $HOME was caught by the v0.19.0 gate but reported as a
    git climb, and down/status/reset under 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 .git entry — 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, the core.worktree value 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 the workspace-gate suite (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)

Choose a tag to compare

@github-actions github-actions released this 27 Sep 14:58

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

  • gh works in broker sessions (#65). With a GitHub-covering broker the
    cage wired a placeholder git helper but gave the gh CLI nothing, so
    gh api / gh pr … failed with "not logged in" in exactly the sessions
    where GitHub access is brokered — and the only workaround was gh 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 exports GH_TOKEN=tjor-broker-placeholder
    into the agent environment; gh sends Authorization: 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 naming github.com without a glob, no broker) leaves GH_TOKEN
    unset. The default *.github.com covers it.

Security

  • Behavior change, called out: gh prefers GH_TOKEN over a stored
    hosts.yml, so a token someone minted with gh auth login inside an
    earlier session is no longer used in a broker session — the brokered
    identity is the session's identity. And gh auth login refuses to run
    while GH_TOKEN is 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, so gh auth status may report a failure while repo-scoped commands work (README).
  • Tests: the proxy unit test now covers gh's token scheme; the live broker
    integration test asserts the GH_TOKEN placeholder 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.host for 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
    forged Host header inside a tunnel to another server cannot attract a
    credential (regression-tested both ways). (2) cplt sets
    GIT_CONFIG_NOSYSTEM=1 for the sandboxed harness
    , so under the
    kernel-sandbox tier git ignored /etc/gitconfig — the placeholder helper,
    the gh fallback, the SSH→HTTPS rewrites and safe.directory tree 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 to token
    and Bearer, while api.github.com accepts 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's token stays
    token, Bearer stays Bearer. Verified end-to-end: gh api user and
    git ls-remote on 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)

Choose a tag to compare

@github-actions github-actions released this 27 Sep 10:42

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 tree bin/tjor runs 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 own bin/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-mount overrides 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, current symlink), 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-w guards 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 (or TJOR_INSTALL_ROOT) is refused as a
    workspace or --dir/--dir-ro unless --unsafe-dir.
  • A self-installed tree builds locally, like a checkout (ADR 0008
    amended).
    The pull-vs-build decision keys on "source tree" (.git or 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 labeled tjor.source-sha: the marker's sha, or HEAD
    (-dirty with uncommitted changes) for a checkout, or release-<VERSION>.
  • tjor doctor reports 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, naming self-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 the self-mount suite
    of the boundary matrix with a new launcher-integrity capability. Specs:
    new launcher-integrity; image-distribution and session-launch amended.

v0.19.0 — Workspace sensitive-path gate (#64)

Choose a tag to compare

@github-actions github-actions released this 27 Sep 10:19

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_sensitive refused /, system directories,
    $HOME and 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 run from ~ (or from any
    non-repository directory under it) resolved the workspace to $HOME via
    git rev-parse --show-toplevel and 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-dir remains 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-ro alike (--dir ~/.tjor/sessions was 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_DIRS is /etc, /usr, /var, /bin, /sbin, /boot,
    /sys, /proc, /dev, /root or a child of one; / was already refused.
    The launcher's --unsafe-dir reaches 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, unit CI 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 rendered session-launch rows.
    The agent-image CI job gains the direct-invocation negative path.
    session-launch spec amended.