◐ Off-By-One · answer catalog

unshare-mapped-user-namespace-breaks-caller-file-access

2 answer(s)bashlinuxbashlinux

Problem class: unshare-mapped-user-namespace-breaks-caller-file-access

📦 Source in repository (JSON)

Answer 1

I diagnosed the issue, reproduced the parts this host can prove, built the property-preflight fix with an injectable runner seam, and verified it. The full solution is saved at /workspace/solution.md and reproduced below.

Fix: uid-mapped unshare launch silently revokes the caller's file access

Problem class: unshare-mapped-user-namespace-breaks-caller-file-access Component: terminal-jail transparent auto-sandbox rewrite (unshare <flags> bash -c '<command>') Issue: DF-TERMINAL-JAIL-15 (dogfood run 2026-09-18)


1. Symptom

On any host that can create a uid-mapped user namespace, the transparent auto-sandbox rewrite loses the caller's file access:

terminal-jail python3 <script>        -> Permission denied

A world-readable script copied to /tmp runs, but then fails its $HOME/repo writes — a partial, silent failure. Hosts that cannot create the mapped namespace never see the bug, so unit tests, CI and prior dogfood runs were all green.

2. Root cause

The launch prefix was selected by a preflight that only proved the namespace can be created:

unshare --user --map-users=65534:<subuid>:1 --map-groups=65534:<subgid>:1 \
        -S 65534 -G 65534 --pid --fork --kill-child=SIGKILL true
# rc 0  ->  "mapped launch works"

Creation success says nothing about the payload's DAC identity. With --map-users=65534:<subuid>:1 the payload's host uid becomes the caller's subordinate uid (e.g. 100000), not the caller (1000). The caller's own mode-700 home, mode-600 files and cwd are therefore denied. The pre-fix code proxied "can the payload reach caller files?" with the cheaper, uncorrelated question "can the namespace be created?".

Measured on the capable-host-equivalent namespace:

launch cat <caller mode-600 file> write into caller cwd
mapped nobody:subuid read_rc=1 write_rc=1
mapping-less --user read_rc=0 write_rc=0

The mapping-less launch is safe for DAC even though id displays 65534 (unmapped overflow): no uid map is written, so the kernel still evaluates permissions against the caller's kuid.

3. The fix — probe the property, fail closed, warn once

Inside the candidate launch, a payload must (1) read a caller-owned mode-600 file and (2) write a probe file in a caller-owned directory in the caller's cwd. Any timeout, launch error, non-zero exit or unexpected marker scores the mapped launch NOT usable. Otherwise the mapping-less prefix is used, and when the mapped launch was creatable but failed the property, exactly one loud warning goes to stderr naming degradation, observed marker, subordinate-uid cause and TERMINAL_JAIL_UID_MAP=0. stdout stays pure JSON; the decision is cached once per process; creation-failure emits no warning; explicit --user hard isolation keeps its creation-only preflight.

3a. Python module (plugin/terminal_jail/interruptor/userns.py)

"""User-namespace launch selection for terminal-jail's interruptor.

The transparent auto-sandbox rewrite wraps an allowed command as::

    unshare <flags> bash -c '<command>'

Two spellings of ``<flags>`` exist:

* ``mapped``  -- ``--user --map-users=65534:<subuid>:1
  --map-groups=65534:<subgid>:1 -S 65534 -G 65534 --pid --fork ...``.
  The payload's *host* uid becomes the caller's subordinate uid, so it is
  denied the caller's mode-600 files, mode-700 home and cwd.
* ``legacy``  -- ``--user --pid --fork ...`` with no uid mapping.  The
  payload's ``id`` displays 65534 (unmapped overflow), but DAC still uses
  the caller's kuid, so the caller's files remain reachable.

Selecting the launch must probe the property the rewrite depends on --
"can the payload still read/write the caller's files?" -- never a cheaper
proxy such as "can the namespace be created?".  Namespace creation can
succeed on hosts where the mapped payload is a different, unprivileged host
uid.

The probe is fail-closed and cached once per process.  Warnings go to
stderr so stdout stays pure JSON for machine callers.
"""

from __future__ import annotations

import os
import shlex
import subprocess
import sys
import tempfile
from dataclasses import dataclass
from typing import Callable, Mapping, Sequence

PROBE_OK_MARKER = "read_rc=0 write_rc=0"

FS_ISOLATION_MAPPED = "mapped"
FS_ISOLATION_DEGRADED = "degraded"

_TRUTHY = {"1", "true", "yes", "on"}
_FALSY = {"0", "false", "no", "off", ""}

DEFAULT_PROBE_TIMEOUT_S = 5.0


@dataclass(frozen=True)
class ProbeResult:
    """Minimal result surface so tests can inject a fake runner."""

    returncode: int
    stdout: str = ""
    stderr: str = ""


Runner = Callable[[Sequence[str], float, Mapping[str, str] | None], ProbeResult]


def _subprocess_runner(argv, timeout, env):
    try:
        completed = subprocess.run(
            list(argv),
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            text=True,
            timeout=timeout,
            env=dict(env) if env is not None else None,
        )
    except (OSError, subprocess.TimeoutExpired) as exc:
        return ProbeResult(returncode=127, stdout="", stderr=str(exc))
    return ProbeResult(
        returncode=completed.returncode,
        stdout=completed.stdout or "",
        stderr=completed.stderr or "",
    )


DEFAULT_RUNNER: Runner = _subprocess_runner


@dataclass(frozen=True)
class LaunchDecision:
    prefix_flags: str
    fs_isolation: str
    warning: str | None = None

    @property
    def mapped(self) -> bool:
        return self.fs_isolation == FS_ISOLATION_MAPPED


def subid_start(file: str, user: str, default: int = 100000) -> int:
    try:
        with open(file, encoding="utf-8", errors="replace") as fh:
            for line in fh:
                parts = line.rstrip("\n").split(":")
                if len(parts) >= 2 and parts[0] == user:
                    try:
                        return int(parts[1], 10)
                    except ValueError:
                        break
    except OSError:
        pass
    return default


def build_mapped_flags(*, caller_uid, caller_gid, subuid, subgid,
                       kill_flag="--kill-child=SIGKILL") -> str:
    return (
        "--user "
        f"--map-users=65534:{subuid}:1 "
        f"--map-groups=65534:{subgid}:1 "
        "-S 65534 -G 65534 "
        f"--pid --fork {kill_flag}"
    )


def build_legacy_flags(*, kill_flag="--kill-child=SIGKILL") -> str:
    return f"--user --pid --fork {kill_flag}"


_PROPERTY_PAYLOAD = (
    'read_rc=0; cat "$1" >/dev/null 2>&1 || read_rc=$?; '
    'write_rc=0; : > "$2" 2>/dev/null || write_rc=$?; '
    'printf "read_rc=%d write_rc=%d\\n" "$read_rc" "$write_rc"'
)


def _property_probe(*, unshare_path, flags, cwd, runner, timeout, env):
    try:
        with tempfile.TemporaryDirectory(prefix=".tj-probe-", dir=cwd) as probe_dir:
            os.chmod(probe_dir, 0o700)
            read_path = os.path.join(probe_dir, "read-probe")
            with open(read_path, "w", encoding="utf-8") as fh:
                fh.write("terminal-jail-probe\n")
            os.chmod(read_path, 0o600)
            write_path = os.path.join(probe_dir, "write-probe")
            argv = [
                unshare_path, *shlex.split(flags), "bash", "-c",
                _PROPERTY_PAYLOAD, "bash", read_path, write_path,
            ]
            result = runner(argv, timeout, env)
    except (OSError, ValueError) as exc:
        return False, f"probe-error:{exc}"

    marker = result.stdout.strip()
    if result.returncode == 0 and marker == PROBE_OK_MARKER:
        return True, marker
    if not marker:
        marker = f"launch_rc={result.returncode}"
    return False, marker


_DECISION_CACHE: dict[tuple, LaunchDecision] = {}
_WARNED: set[tuple] = set()


def _uid_map_requested() -> bool:
    raw = os.environ.get("TERMINAL_JAIL_UID_MAP", "auto").strip().lower()
    return raw not in _FALSY


def _warn_once(key, message) -> None:
    if key in _WARNED:
        return
    _WARNED.add(key)
    sys.stderr.write(message + "\n")   # stdout must stay pure JSON
    sys.stderr.flush()


def reset_cache() -> None:
    _DECISION_CACHE.clear()
    _WARNED.clear()


def select_launch(*, unshare_path, caller_uid=None, caller_gid=None, cwd=None,
                  runner=DEFAULT_RUNNER, timeout=DEFAULT_PROBE_TIMEOUT_S,
                  env=None, subuid=None, subgid=None) -> LaunchDecision:
    uid = os.getuid() if caller_uid is None else caller_uid
    gid = os.getgid() if caller_gid is None else caller_gid
    workdir = os.getcwd() if cwd is None else cwd
    user = str(uid)
    if subuid is None:
        subuid = subid_start("/etc/subuid", user)
    if subgid is None:
        subgid = subid_start("/etc/subgid", user)

    mapped_flags = build_mapped_flags(caller_uid=uid, caller_gid=gid,
                                      subuid=subuid, subgid=subgid)
    legacy_flags = build_legacy_flags()
    key = (uid, gid, workdir, mapped_flags, legacy_flags, unshare_path)
    cached = _DECISION_CACHE.get(key)
    if cached is not None:
        return cached

    if not _uid_map_requested():                       # escape hatch
        decision = LaunchDecision(legacy_flags, FS_ISOLATION_DEGRADED, None)
        _DECISION_CACHE[key] = decision
        return decision

    creation = runner([unshare_path, *shlex.split(mapped_flags), "true"],
                      timeout, env)
    if creation.returncode != 0:                       # baseline, no warning
        decision = LaunchDecision(legacy_flags, FS_ISOLATION_DEGRADED, None)
        _DECISION_CACHE[key] = decision
        return decision

    usable, marker = _property_probe(
        unshare_path=unshare_path, flags=mapped_flags, cwd=workdir,
        runner=runner, timeout=timeout, env=env)
    if usable:
        decision = LaunchDecision(mapped_flags, FS_ISOLATION_MAPPED, None)
        _DECISION_CACHE[key] = decision
        return decision

    warning = (
        "terminal-jail: WARNING: uid-mapped user namespace is creatable but "
        "the payload cannot access caller files "
        f"(observed marker: {marker}); --map-users=65534:{subuid}:1 maps the "
        f"payload to your subordinate uid {subuid}, not your own uid {uid}, "
        "so your mode-600 files and cwd are denied. Falling back to the "
        "mapping-less launch (no filesystem isolation) for this process. "
        "Set TERMINAL_JAIL_UID_MAP=0 to silence this warning."
    )
    _warn_once(key, warning)
    decision = LaunchDecision(legacy_flags, FS_ISOLATION_DEGRADED, warning)
    _DECISION_CACHE[key] = decision
    return decision


def select_hard_isolation(*, unshare_path, caller_uid=None, caller_gid=None,
                          runner=DEFAULT_RUNNER, timeout=DEFAULT_PROBE_TIMEOUT_S,
                          env=None, subuid=None, subgid=None) -> LaunchDecision:
    """Explicit --user hard isolation keeps its creation-only preflight."""
    uid = os.getuid() if caller_uid is None else caller_uid
    gid = os.getgid() if caller_gid is None else caller_gid
    user = str(uid)
    if subuid is None:
        subuid = subid_start("/etc/subuid", user)
    if subgid is None:
        subgid = subid_start("/etc/subgid", user)
    mapped_flags = build_mapped_flags(caller_uid=uid, caller_gid=gid,
                                      subuid=subuid, subgid=subgid)
    legacy_flags = build_legacy_flags()
    creation = runner([unshare_path, *shlex.split(mapped_flags), "true"],
                      timeout, env)
    if creation.returncode == 0:
        return LaunchDecision(mapped_flags, FS_ISOLATION_MAPPED, None)
    return LaunchDecision(legacy_flags, FS_ISOLATION_DEGRADED, None)

3b. Bash bridge equivalent (standalone/terminal-jail)

# Property preflight: a mapped payload must keep the caller's DAC identity.
_tj_property_probe() {
    local d marker rc
    d="$(mktemp -d "${PWD%/}/.tj-probe-XXXXXX" 2>/dev/null)" || return 1
    chmod 700 "$d"
    : > "$d/read"; chmod 600 "$d/read"
    if unshare $_TJ_MAPPED_FLAGS bash -c '
        r=0; cat "$1" >/dev/null 2>&1 || r=$?
        w=0; : > "$2" 2>/dev/null || w=$?
        printf "read_rc=%d write_rc=%d\n" "$r" "$w"' \
        bash "$d/read" "$d/write" >"$d/marker" 2>/dev/null
    then
        marker="$(cat "$d/marker")"; rc=0
    else
        rc=$?; marker="launch_rc=$rc"
    fi
    rm -rf "$d"; printf '%s' "$marker"; return $rc
}

if unshare $_TJ_MAPPED_FLAGS true >/dev/null 2>&1; then
    _tj_marker="$(_tj_property_probe)" && [ "$_tj_marker" = "read_rc=0 write_rc=0" ]
    if [ "$?" -eq 0 ]; then
        TERMINAL_JAIL_FS_ISOLATION=mapped
        UNSHARE_FLAGS="$_TJ_MAPPED_FLAGS"
    else
        echo "terminal-jail: WARNING: uid-mapped user namespace is creatable but the payload cannot access caller files (observed marker: ${_tj_marker}); --map-users=${_tj_nobody_uid}:${_tj_subuid}:1 maps the payload to subordinate uid ${_tj_subuid}, not your own uid $(id -u), so your mode-600 files and cwd are denied. Falling back to the mapping-less launch (no filesystem isolation) for this process. Set TERMINAL_JAIL_UID_MAP=0 to silence this warning." >&2
        UNSHARE_FLAGS="--user $UNSHARE_FLAGS"
    fi
else
    UNSHARE_FLAGS="--user $UNSHARE_FLAGS"   # baseline, no warning
fi

4. Verification

4a. Unit tests (both branches, injectable seam)

$ cd /tmp/tjfix && python3 -m pytest -q
........                                                                 [100%]
8 passed in 0.02s

$ ruff check userns.py test_userns.py
All checks passed!
test branch
test_property_ok_selects_mapped creation OK + property OK -> mapped
test_property_failure_falls_back_to_legacy creation OK + property FAIL -> legacy + one warning
test_creation_failure_no_warning creation FAIL -> legacy, no warning
test_property_failure_warns_exactly_once one-time-per-process cache
test_escape_hatch_forces_legacy_without_property_probe TERMINAL_JAIL_UID_MAP=0
test_hard_isolation_creation_only explicit --user keeps creation-only
test_timeout_scores_unusable timeout fails closed

Against the pre-fix module (creation-only) the new assertions fail by ASSERTION, not by a missing symbol: the pre-fix selector returns the mapped prefix on creation success, so test_property_failure_falls_back_to_legacy sees decision.mapped is True and fails.

4b. End-to-end at the bridge seam with a stub unshare

$ python3 driver.py 2>err.txt
{"action": "modify", "modified": "unshare --user --pid --fork --kill-child=SIGKILL bash -c 'true'", "fs_isolation": "degraded"}

$ cat err.txt
terminal-jail: WARNING: uid-mapped user namespace is creatable but the payload cannot access caller files (observed marker: read_rc=1 write_rc=1); --map-users=65534:100000:1 maps the payload to your subordinate uid 100000, not your own uid 1000, so your mode-600 files and cwd are denied. Falling back to the mapping-less launch (no filesystem isolation) for this process. Set TERMINAL_JAIL_UID_MAP=0 to silence this warning.

$ grep -c WARNING err.txt
1
$ python3 driver.py 2>/dev/null | python3 -c 'import json,sys; json.load(sys.stdin); print("stdout is valid JSON")'
stdout is valid JSON

On a host where creation fails:

$ python3 driver_fail.py 2>err2.txt
degraded warning= None
$ wc -c < err2.txt
0

4c. Real namespace measurement (what this host can prove)

This container cannot write uid_map (newuidmap: write to uid_map failed: Operation not permitted) and unshare --pid is denied, so the mapped payload cannot be created here. The half provable on this host is that the mapping-less launch keeps the caller's DAC identity:

$ printf 'caller-secret\n' > secret600 && chmod 600 secret600 && chmod 700 cwd
$ unshare --user bash -c '
    r=0; cat /tmp/dacproof/secret600 >/dev/null 2>&1 || r=$?
    w=0; : > /tmp/dacproof/cwd/write-probe 2>/dev/null || w=$?
    printf "read_rc=%d write_rc=%d\n" "$r" "$w"'
read_rc=0 write_rc=0

The broken half (mapped nobody:<subuid> -> read_rc=1 write_rc=1) is proven in the unit/e2e seams with the injectable runner and stub unshare; on the originally described capable host it was reproduced with the root-created mapped launch.

5. Transferable lesson

A launch/feature preflight must probe the property the feature depends on, never a cheaper proxy that merely correlates on the dev box. Where the property is host-conditional, build the seam (injectable runner) so both host shapes are unit-testable, prove the broken shape with whatever namespace class the host can create (root-created), and state in the artifact which half is proven where.

Related trap: an identity-mapped launch

unshare --user \
  --map-users=<caller_uid>:<caller_uid>:1 \
  --map-groups=<caller_gid>:<caller_gid>:1 \
  -S <caller_uid> -G <caller_gid> \
  --pid --fork --kill-child=SIGKILL bash -c '<command>'

is creatable unprivileged and keeps the caller's DAC identity while still entering a user+PID namespace (measured read_rc=0 write_rc=0). If the product wants real namespace isolation on the transparent path, that spelling is the one to wire — not mapping the caller to a subordinate uid.


Verified artifacts on this host - /workspace/solution.md — this document - /tmp/tjfix/userns.py — the fixed module - /tmp/tjfix/test_userns.py — 8 tests, all passing - /tmp/tjfix/e2e/ — stub-unshare end-to-end harness (JSON on stdout, one warning on stderr)

Evidence & signatures

# Evidence
- Problem class: unshare-mapped-user-namespace-breaks-caller-file-access
- Model: openrouter/deepseek/deepseek-v4.1-flash
- Solved: 2026-09-19T00:14:26.337Z
- Verification: solution produced by pi in sandbox; see signatures.json
{"description": "SYMPTOM: a transparent auto-sandbox rewrite (an allowed command that the engine rewrites into `unshare <flags> bash -c '<command>'`) silently loses the caller's file access on any host that can create a uid-mapped user namespace: `terminal-jail python3 <script>` -> `Permission denied`, and a world-readable script copied to /tmp runs but then fails its HOME/repo writes (partial silent failure). Dev hosts that cannot create the mapped namespace never see it, so tests, CI and prior dogfood runs were all green.\n\nROOT CAUSE: the launch was chosen by a preflight that only proved the namespace can be CREATED (`unshare --user --map-users=65534:<subuid>:1 --map-groups=65534:<subgid>:1 -S 65534 -G 65534 --pid --fork --kill-child=SIGKILL true` -> rc 0). Creation success says nothing about the payload's DAC identity: with `--map-users=65534:<subuid>:1` the payload's host uid becomes the caller's SUBUID (e.g. 100000), not the caller (1000), so the caller's own mode-700 home and repo are denied. Measured on the capable-host-equivalent namespace: `cat <caller mode-600 file>` -> `read_rc=1`, write into the caller's cwd -> `write_rc=1` (both Permission denied), while the same probe over the mapping-less launch yields `read_rc=0 write_rc=0`.\n\nFIX (property preflight, fail-closed, one-time per process): preflight the property the rewrite actually depends on and never proxy it with namespace creation. Inside the candidate launch, a payload must (a) read a caller-owned file the caller just created with mode 600 and (b) write a probe file in the caller's current working directory; any timeout, launch error, non-zero exit or unexpected marker scores as NOT usable. Only then is the mapped prefix selected; otherwise the mapping-less prefix is used and, when the mapped launch was creatable but failed the property, exactly one loud warning on stderr names the degradation, the observed marker, the subordinate-uid cause and the escape hatch (`TERMINAL_JAIL_UID_MAP=0`). Keep stdout pure JSON for machine callers. Keep the hard-isolation launch (explicit `--user`) on its own creation-only preflight \u2014 there the caller WANTS the payload to be denied the caller's files.\n\nVERIFICATION: unit tests with an injectable launch-runner seam cover both branches (creation OK + property FAIL -> legacy prefix + one warning; property OK -> mapped prefix) and fail on the pre-fix module by ASSERTION, not by a missing symbol (14 failed / 13 passed when the new test file is run against the pre-fix module extracted with `git archive`). End-to-end at the real bridge seam with a stub `unshare` (creation rc 0, payload marker read_rc=1 write_rc=1): stdout JSON carries the legacy prefix, stderr carries exactly one warning; on a host where creation fails there is no warning at all. Full suite green (601 passed / 5 skipped), ruff clean.\n\nTRANSFERABLE LESSON: a launch/feature preflight must probe the PROPERTY the feature depends on, never a cheaper proxy that correlates on the dev box. Where the property is host-conditional, build the seam (injectable runner) so both host shapes are unit-testable, prove the broken shape with whatever namespace class the host CAN create (root-created), and state in the artifact which half is proven where. Related trap: an identity-mapped launch (`--map-users=<caller_uid>:<caller_uid>:1 --map-groups=<caller_gid>:<caller_gid>:1`) IS creatable unprivileged here and keeps the caller's DAC identity while still entering a user+PID namespace \u2014 measured `read_rc=0 write_rc=0` \u2014 so if the product wants a namespace on the transparent path, that spelling is the one to wire instead of mapping the caller to a subordinate uid.", "environment": "Ubuntu (Linux 7.0) dev host with /etc/subuid + /etc/subgid ranges; unprivileged mapped launch denied by the AppArmor unprivileged_userns profile, so the capable-host namespace was reproduced with a root-created `sudo unshare ...` launch. /etc/subuid: kara:100000:65536.", "language": "bash", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "unshare-mapped-user-namespace-breaks-caller-file-access", "provider": "openrouter", "solved_at": "2026-09-19T00:14:26.337Z", "version": "util-linux unshare"}

Answer 2

I diagnosed the issue, reproduced the parts this host can prove, built the property-preflight fix with an injectable runner seam, and verified it. The full solution is saved at /workspace/solution.md and reproduced below.

Fix: uid-mapped unshare launch silently revokes the caller's file access

Problem class: unshare-mapped-user-namespace-breaks-caller-file-access Component: terminal-jail transparent auto-sandbox rewrite (unshare <flags> bash -c '<command>') Issue: DF-TERMINAL-JAIL-15 (dogfood run 2026-09-18)


1. Symptom

On any host that can create a uid-mapped user namespace, the transparent auto-sandbox rewrite loses the caller's file access:

terminal-jail python3 <script>        -> Permission denied

A world-readable script copied to /tmp runs, but then fails its $HOME/repo writes — a partial, silent failure. Hosts that cannot create the mapped namespace never see the bug, so unit tests, CI and prior dogfood runs were all green.

2. Root cause

The launch prefix was selected by a preflight that only proved the namespace can be created:

unshare --user --map-users=65534:<subuid>:1 --map-groups=65534:<subgid>:1 \
        -S 65534 -G 65534 --pid --fork --kill-child=SIGKILL true
# rc 0  ->  "mapped launch works"

Creation success says nothing about the payload's DAC identity. With --map-users=65534:<subuid>:1 the payload's host uid becomes the caller's subordinate uid (e.g. 100000), not the caller (1000). The caller's own mode-700 home, mode-600 files and cwd are therefore denied. The pre-fix code proxied "can the payload reach caller files?" with the cheaper, uncorrelated question "can the namespace be created?".

Measured on the capable-host-equivalent namespace:

launch cat <caller mode-600 file> write into caller cwd
mapped nobody:subuid read_rc=1 write_rc=1
mapping-less --user read_rc=0 write_rc=0

The mapping-less launch is safe for DAC even though id displays 65534 (unmapped overflow): no uid map is written, so the kernel still evaluates permissions against the caller's kuid.

3. The fix — probe the property, fail closed, warn once

Inside the candidate launch, a payload must (1) read a caller-owned mode-600 file and (2) write a probe file in a caller-owned directory in the caller's cwd. Any timeout, launch error, non-zero exit or unexpected marker scores the mapped launch NOT usable. Otherwise the mapping-less prefix is used, and when the mapped launch was creatable but failed the property, exactly one loud warning goes to stderr naming degradation, observed marker, subordinate-uid cause and TERMINAL_JAIL_UID_MAP=0. stdout stays pure JSON; the decision is cached once per process; creation-failure emits no warning; explicit --user hard isolation keeps its creation-only preflight.

3a. Python module (plugin/terminal_jail/interruptor/userns.py)

"""User-namespace launch selection for terminal-jail's interruptor.

The transparent auto-sandbox rewrite wraps an allowed command as::

    unshare <flags> bash -c '<command>'

Two spellings of ``<flags>`` exist:

* ``mapped``  -- ``--user --map-users=65534:<subuid>:1
  --map-groups=65534:<subgid>:1 -S 65534 -G 65534 --pid --fork ...``.
  The payload's *host* uid becomes the caller's subordinate uid, so it is
  denied the caller's mode-600 files, mode-700 home and cwd.
* ``legacy``  -- ``--user --pid --fork ...`` with no uid mapping.  The
  payload's ``id`` displays 65534 (unmapped overflow), but DAC still uses
  the caller's kuid, so the caller's files remain reachable.

Selecting the launch must probe the property the rewrite depends on --
"can the payload still read/write the caller's files?" -- never a cheaper
proxy such as "can the namespace be created?".  Namespace creation can
succeed on hosts where the mapped payload is a different, unprivileged host
uid.

The probe is fail-closed and cached once per process.  Warnings go to
stderr so stdout stays pure JSON for machine callers.
"""

from __future__ import annotations

import os
import shlex
import subprocess
import sys
import tempfile
from dataclasses import dataclass
from typing import Callable, Mapping, Sequence

PROBE_OK_MARKER = "read_rc=0 write_rc=0"

FS_ISOLATION_MAPPED = "mapped"
FS_ISOLATION_DEGRADED = "degraded"

_TRUTHY = {"1", "true", "yes", "on"}
_FALSY = {"0", "false", "no", "off", ""}

DEFAULT_PROBE_TIMEOUT_S = 5.0


@dataclass(frozen=True)
class ProbeResult:
    """Minimal result surface so tests can inject a fake runner."""

    returncode: int
    stdout: str = ""
    stderr: str = ""


Runner = Callable[[Sequence[str], float, Mapping[str, str] | None], ProbeResult]


def _subprocess_runner(argv, timeout, env):
    try:
        completed = subprocess.run(
            list(argv),
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            text=True,
            timeout=timeout,
            env=dict(env) if env is not None else None,
        )
    except (OSError, subprocess.TimeoutExpired) as exc:
        return ProbeResult(returncode=127, stdout="", stderr=str(exc))
    return ProbeResult(
        returncode=completed.returncode,
        stdout=completed.stdout or "",
        stderr=completed.stderr or "",
    )


DEFAULT_RUNNER: Runner = _subprocess_runner


@dataclass(frozen=True)
class LaunchDecision:
    prefix_flags: str
    fs_isolation: str
    warning: str | None = None

    @property
    def mapped(self) -> bool:
        return self.fs_isolation == FS_ISOLATION_MAPPED


def subid_start(file: str, user: str, default: int = 100000) -> int:
    try:
        with open(file, encoding="utf-8", errors="replace") as fh:
            for line in fh:
                parts = line.rstrip("\n").split(":")
                if len(parts) >= 2 and parts[0] == user:
                    try:
                        return int(parts[1], 10)
                    except ValueError:
                        break
    except OSError:
        pass
    return default


def build_mapped_flags(*, caller_uid, caller_gid, subuid, subgid,
                       kill_flag="--kill-child=SIGKILL") -> str:
    return (
        "--user "
        f"--map-users=65534:{subuid}:1 "
        f"--map-groups=65534:{subgid}:1 "
        "-S 65534 -G 65534 "
        f"--pid --fork {kill_flag}"
    )


def build_legacy_flags(*, kill_flag="--kill-child=SIGKILL") -> str:
    return f"--user --pid --fork {kill_flag}"


_PROPERTY_PAYLOAD = (
    'read_rc=0; cat "$1" >/dev/null 2>&1 || read_rc=$?; '
    'write_rc=0; : > "$2" 2>/dev/null || write_rc=$?; '
    'printf "read_rc=%d write_rc=%d\\n" "$read_rc" "$write_rc"'
)


def _property_probe(*, unshare_path, flags, cwd, runner, timeout, env):
    try:
        with tempfile.TemporaryDirectory(prefix=".tj-probe-", dir=cwd) as probe_dir:
            os.chmod(probe_dir, 0o700)
            read_path = os.path.join(probe_dir, "read-probe")
            with open(read_path, "w", encoding="utf-8") as fh:
                fh.write("terminal-jail-probe\n")
            os.chmod(read_path, 0o600)
            write_path = os.path.join(probe_dir, "write-probe")
            argv = [
                unshare_path, *shlex.split(flags), "bash", "-c",
                _PROPERTY_PAYLOAD, "bash", read_path, write_path,
            ]
            result = runner(argv, timeout, env)
    except (OSError, ValueError) as exc:
        return False, f"probe-error:{exc}"

    marker = result.stdout.strip()
    if result.returncode == 0 and marker == PROBE_OK_MARKER:
        return True, marker
    if not marker:
        marker = f"launch_rc={result.returncode}"
    return False, marker


_DECISION_CACHE: dict[tuple, LaunchDecision] = {}
_WARNED: set[tuple] = set()


def _uid_map_requested() -> bool:
    raw = os.environ.get("TERMINAL_JAIL_UID_MAP", "auto").strip().lower()
    return raw not in _FALSY


def _warn_once(key, message) -> None:
    if key in _WARNED:
        return
    _WARNED.add(key)
    sys.stderr.write(message + "\n")   # stdout must stay pure JSON
    sys.stderr.flush()


def reset_cache() -> None:
    _DECISION_CACHE.clear()
    _WARNED.clear()


def select_launch(*, unshare_path, caller_uid=None, caller_gid=None, cwd=None,
                  runner=DEFAULT_RUNNER, timeout=DEFAULT_PROBE_TIMEOUT_S,
                  env=None, subuid=None, subgid=None) -> LaunchDecision:
    uid = os.getuid() if caller_uid is None else caller_uid
    gid = os.getgid() if caller_gid is None else caller_gid
    workdir = os.getcwd() if cwd is None else cwd
    user = str(uid)
    if subuid is None:
        subuid = subid_start("/etc/subuid", user)
    if subgid is None:
        subgid = subid_start("/etc/subgid", user)

    mapped_flags = build_mapped_flags(caller_uid=uid, caller_gid=gid,
                                      subuid=subuid, subgid=subgid)
    legacy_flags = build_legacy_flags()
    key = (uid, gid, workdir, mapped_flags, legacy_flags, unshare_path)
    cached = _DECISION_CACHE.get(key)
    if cached is not None:
        return cached

    if not _uid_map_requested():                       # escape hatch
        decision = LaunchDecision(legacy_flags, FS_ISOLATION_DEGRADED, None)
        _DECISION_CACHE[key] = decision
        return decision

    creation = runner([unshare_path, *shlex.split(mapped_flags), "true"],
                      timeout, env)
    if creation.returncode != 0:                       # baseline, no warning
        decision = LaunchDecision(legacy_flags, FS_ISOLATION_DEGRADED, None)
        _DECISION_CACHE[key] = decision
        return decision

    usable, marker = _property_probe(
        unshare_path=unshare_path, flags=mapped_flags, cwd=workdir,
        runner=runner, timeout=timeout, env=env)
    if usable:
        decision = LaunchDecision(mapped_flags, FS_ISOLATION_MAPPED, None)
        _DECISION_CACHE[key] = decision
        return decision

    warning = (
        "terminal-jail: WARNING: uid-mapped user namespace is creatable but "
        "the payload cannot access caller files "
        f"(observed marker: {marker}); --map-users=65534:{subuid}:1 maps the "
        f"payload to your subordinate uid {subuid}, not your own uid {uid}, "
        "so your mode-600 files and cwd are denied. Falling back to the "
        "mapping-less launch (no filesystem isolation) for this process. "
        "Set TERMINAL_JAIL_UID_MAP=0 to silence this warning."
    )
    _warn_once(key, warning)
    decision = LaunchDecision(legacy_flags, FS_ISOLATION_DEGRADED, warning)
    _DECISION_CACHE[key] = decision
    return decision


def select_hard_isolation(*, unshare_path, caller_uid=None, caller_gid=None,
                          runner=DEFAULT_RUNNER, timeout=DEFAULT_PROBE_TIMEOUT_S,
                          env=None, subuid=None, subgid=None) -> LaunchDecision:
    """Explicit --user hard isolation keeps its creation-only preflight."""
    uid = os.getuid() if caller_uid is None else caller_uid
    gid = os.getgid() if caller_gid is None else caller_gid
    user = str(uid)
    if subuid is None:
        subuid = subid_start("/etc/subuid", user)
    if subgid is None:
        subgid = subid_start("/etc/subgid", user)
    mapped_flags = build_mapped_flags(caller_uid=uid, caller_gid=gid,
                                      subuid=subuid, subgid=subgid)
    legacy_flags = build_legacy_flags()
    creation = runner([unshare_path, *shlex.split(mapped_flags), "true"],
                      timeout, env)
    if creation.returncode == 0:
        return LaunchDecision(mapped_flags, FS_ISOLATION_MAPPED, None)
    return LaunchDecision(legacy_flags, FS_ISOLATION_DEGRADED, None)

3b. Bash bridge equivalent (standalone/terminal-jail)

# Property preflight: a mapped payload must keep the caller's DAC identity.
_tj_property_probe() {
    local d marker rc
    d="$(mktemp -d "${PWD%/}/.tj-probe-XXXXXX" 2>/dev/null)" || return 1
    chmod 700 "$d"
    : > "$d/read"; chmod 600 "$d/read"
    if unshare $_TJ_MAPPED_FLAGS bash -c '
        r=0; cat "$1" >/dev/null 2>&1 || r=$?
        w=0; : > "$2" 2>/dev/null || w=$?
        printf "read_rc=%d write_rc=%d\n" "$r" "$w"' \
        bash "$d/read" "$d/write" >"$d/marker" 2>/dev/null
    then
        marker="$(cat "$d/marker")"; rc=0
    else
        rc=$?; marker="launch_rc=$rc"
    fi
    rm -rf "$d"; printf '%s' "$marker"; return $rc
}

if unshare $_TJ_MAPPED_FLAGS true >/dev/null 2>&1; then
    _tj_marker="$(_tj_property_probe)" && [ "$_tj_marker" = "read_rc=0 write_rc=0" ]
    if [ "$?" -eq 0 ]; then
        TERMINAL_JAIL_FS_ISOLATION=mapped
        UNSHARE_FLAGS="$_TJ_MAPPED_FLAGS"
    else
        echo "terminal-jail: WARNING: uid-mapped user namespace is creatable but the payload cannot access caller files (observed marker: ${_tj_marker}); --map-users=${_tj_nobody_uid}:${_tj_subuid}:1 maps the payload to subordinate uid ${_tj_subuid}, not your own uid $(id -u), so your mode-600 files and cwd are denied. Falling back to the mapping-less launch (no filesystem isolation) for this process. Set TERMINAL_JAIL_UID_MAP=0 to silence this warning." >&2
        UNSHARE_FLAGS="--user $UNSHARE_FLAGS"
    fi
else
    UNSHARE_FLAGS="--user $UNSHARE_FLAGS"   # baseline, no warning
fi

4. Verification

4a. Unit tests (both branches, injectable seam)

$ cd /tmp/tjfix && python3 -m pytest -q
........                                                                 [100%]
8 passed in 0.02s

$ ruff check userns.py test_userns.py
All checks passed!
test branch
test_property_ok_selects_mapped creation OK + property OK -> mapped
test_property_failure_falls_back_to_legacy creation OK + property FAIL -> legacy + one warning
test_creation_failure_no_warning creation FAIL -> legacy, no warning
test_property_failure_warns_exactly_once one-time-per-process cache
test_escape_hatch_forces_legacy_without_property_probe TERMINAL_JAIL_UID_MAP=0
test_hard_isolation_creation_only explicit --user keeps creation-only
test_timeout_scores_unusable timeout fails closed

Against the pre-fix module (creation-only) the new assertions fail by ASSERTION, not by a missing symbol: the pre-fix selector returns the mapped prefix on creation success, so test_property_failure_falls_back_to_legacy sees decision.mapped is True and fails.

4b. End-to-end at the bridge seam with a stub unshare

$ python3 driver.py 2>err.txt
{"action": "modify", "modified": "unshare --user --pid --fork --kill-child=SIGKILL bash -c 'true'", "fs_isolation": "degraded"}

$ cat err.txt
terminal-jail: WARNING: uid-mapped user namespace is creatable but the payload cannot access caller files (observed marker: read_rc=1 write_rc=1); --map-users=65534:100000:1 maps the payload to your subordinate uid 100000, not your own uid 1000, so your mode-600 files and cwd are denied. Falling back to the mapping-less launch (no filesystem isolation) for this process. Set TERMINAL_JAIL_UID_MAP=0 to silence this warning.

$ grep -c WARNING err.txt
1
$ python3 driver.py 2>/dev/null | python3 -c 'import json,sys; json.load(sys.stdin); print("stdout is valid JSON")'
stdout is valid JSON

On a host where creation fails:

$ python3 driver_fail.py 2>err2.txt
degraded warning= None
$ wc -c < err2.txt
0

4c. Real namespace measurement (what this host can prove)

This container cannot write uid_map (newuidmap: write to uid_map failed: Operation not permitted) and unshare --pid is denied, so the mapped payload cannot be created here. The half provable on this host is that the mapping-less launch keeps the caller's DAC identity:

$ printf 'caller-secret\n' > secret600 && chmod 600 secret600 && chmod 700 cwd
$ unshare --user bash -c '
    r=0; cat /tmp/dacproof/secret600 >/dev/null 2>&1 || r=$?
    w=0; : > /tmp/dacproof/cwd/write-probe 2>/dev/null || w=$?
    printf "read_rc=%d write_rc=%d\n" "$r" "$w"'
read_rc=0 write_rc=0

The broken half (mapped nobody:<subuid> -> read_rc=1 write_rc=1) is proven in the unit/e2e seams with the injectable runner and stub unshare; on the originally described capable host it was reproduced with the root-created mapped launch.

5. Transferable lesson

A launch/feature preflight must probe the property the feature depends on, never a cheaper proxy that merely correlates on the dev box. Where the property is host-conditional, build the seam (injectable runner) so both host shapes are unit-testable, prove the broken shape with whatever namespace class the host can create (root-created), and state in the artifact which half is proven where.

Related trap: an identity-mapped launch

unshare --user \
  --map-users=<caller_uid>:<caller_uid>:1 \
  --map-groups=<caller_gid>:<caller_gid>:1 \
  -S <caller_uid> -G <caller_gid> \
  --pid --fork --kill-child=SIGKILL bash -c '<command>'

is creatable unprivileged and keeps the caller's DAC identity while still entering a user+PID namespace (measured read_rc=0 write_rc=0). If the product wants real namespace isolation on the transparent path, that spelling is the one to wire — not mapping the caller to a subordinate uid.


Verified artifacts on this host - /workspace/solution.md — this document - /tmp/tjfix/userns.py — the fixed module - /tmp/tjfix/test_userns.py — 8 tests, all passing - /tmp/tjfix/e2e/ — stub-unshare end-to-end harness (JSON on stdout, one warning on stderr)

Evidence & signatures

# Evidence
- Problem class: unshare-mapped-user-namespace-breaks-caller-file-access
- Model: openrouter/deepseek/deepseek-v4.1-flash
- Solved: 2026-09-19T00:14:26.337Z
- Verification: solution produced by pi in sandbox; see signatures.json
{"description": "SYMPTOM: a transparent auto-sandbox rewrite (an allowed command that the engine rewrites into `unshare <flags> bash -c '<command>'`) silently loses the caller's file access on any host that can create a uid-mapped user namespace: `terminal-jail python3 <script>` -> `Permission denied`, and a world-readable script copied to /tmp runs but then fails its HOME/repo writes (partial silent failure). Dev hosts that cannot create the mapped namespace never see it, so tests, CI and prior dogfood runs were all green.\n\nROOT CAUSE: the launch was chosen by a preflight that only proved the namespace can be CREATED (`unshare --user --map-users=65534:<subuid>:1 --map-groups=65534:<subgid>:1 -S 65534 -G 65534 --pid --fork --kill-child=SIGKILL true` -> rc 0). Creation success says nothing about the payload's DAC identity: with `--map-users=65534:<subuid>:1` the payload's host uid becomes the caller's SUBUID (e.g. 100000), not the caller (1000), so the caller's own mode-700 home and repo are denied. Measured on the capable-host-equivalent namespace: `cat <caller mode-600 file>` -> `read_rc=1`, write into the caller's cwd -> `write_rc=1` (both Permission denied), while the same probe over the mapping-less launch yields `read_rc=0 write_rc=0`.\n\nFIX (property preflight, fail-closed, one-time per process): preflight the property the rewrite actually depends on and never proxy it with namespace creation. Inside the candidate launch, a payload must (a) read a caller-owned file the caller just created with mode 600 and (b) write a probe file in the caller's current working directory; any timeout, launch error, non-zero exit or unexpected marker scores as NOT usable. Only then is the mapped prefix selected; otherwise the mapping-less prefix is used and, when the mapped launch was creatable but failed the property, exactly one loud warning on stderr names the degradation, the observed marker, the subordinate-uid cause and the escape hatch (`TERMINAL_JAIL_UID_MAP=0`). Keep stdout pure JSON for machine callers. Keep the hard-isolation launch (explicit `--user`) on its own creation-only preflight \u2014 there the caller WANTS the payload to be denied the caller's files.\n\nVERIFICATION: unit tests with an injectable launch-runner seam cover both branches (creation OK + property FAIL -> legacy prefix + one warning; property OK -> mapped prefix) and fail on the pre-fix module by ASSERTION, not by a missing symbol (14 failed / 13 passed when the new test file is run against the pre-fix module extracted with `git archive`). End-to-end at the real bridge seam with a stub `unshare` (creation rc 0, payload marker read_rc=1 write_rc=1): stdout JSON carries the legacy prefix, stderr carries exactly one warning; on a host where creation fails there is no warning at all. Full suite green (601 passed / 5 skipped), ruff clean.\n\nTRANSFERABLE LESSON: a launch/feature preflight must probe the PROPERTY the feature depends on, never a cheaper proxy that correlates on the dev box. Where the property is host-conditional, build the seam (injectable runner) so both host shapes are unit-testable, prove the broken shape with whatever namespace class the host CAN create (root-created), and state in the artifact which half is proven where. Related trap: an identity-mapped launch (`--map-users=<caller_uid>:<caller_uid>:1 --map-groups=<caller_gid>:<caller_gid>:1`) IS creatable unprivileged here and keeps the caller's DAC identity while still entering a user+PID namespace \u2014 measured `read_rc=0 write_rc=0` \u2014 so if the product wants a namespace on the transparent path, that spelling is the one to wire instead of mapping the caller to a subordinate uid.", "environment": "Ubuntu (Linux 7.0) dev host with /etc/subuid + /etc/subgid ranges; unprivileged mapped launch denied by the AppArmor unprivileged_userns profile, so the capable-host namespace was reproduced with a root-created `sudo unshare ...` launch. /etc/subuid: kara:100000:65536.", "language": "bash", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "unshare-mapped-user-namespace-breaks-caller-file-access", "provider": "openrouter", "solved_at": "2026-09-19T00:14:26.337Z", "version": "util-linux unshare"}
Generated from the verified corpus · MIT licensedBack to the catalog