From c69eb96e5a41a718b4398037f03c3ed8646d1b49 Mon Sep 17 00:00:00 2001 From: XBetter <32125213+1998x-stack@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:47:44 +0800 Subject: [PATCH 01/10] feat(sandbox): define immutable-image OCI command with cgroup ceilings --- tiny_harness/_docker_sandbox.py | 86 +++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 tiny_harness/_docker_sandbox.py diff --git a/tiny_harness/_docker_sandbox.py b/tiny_harness/_docker_sandbox.py new file mode 100644 index 0000000..cb03d8b --- /dev/null +++ b/tiny_harness/_docker_sandbox.py @@ -0,0 +1,86 @@ +"""Opt-in OCI Docker execution with aggregate cgroup ceilings. + +The Docker daemon is a privileged trust boundary. This is designed for a +trusted local daemon only; it is NOT a remote multi-tenant sandbox service. +""" +from __future__ import annotations + +import os +import re +import shutil +import sys + +from tiny_harness._sandbox import SandboxUnavailable + + +_IMAGE_DIGEST = re.compile(r"[a-zA-Z0-9][a-zA-Z0-9._:/-]*@sha256:[0-9a-f]{64}\Z") +_CONTAINER_NAME = re.compile(r"tiny-harness-[0-9a-f]{24}\Z") + + +def build_docker_argv( + command: str, *, workspace: str, cwd: str | None, image: str, + name: str, config_dir: str, memory_mb: int = 1024, + cpus: float = 1.0, pids_limit: int = 64, +) -> list[str]: + """Construct an isolated, resource-limited Docker CLI invocation. + + Images must be pre-pulled and referenced by immutable digest. No shell + interpolation, automatic pull, remote Docker host, privileged mode, + socket mount, or broad filesystem bind. + """ + if sys.platform != "linux": + raise SandboxUnavailable("Docker sandbox currently requires Linux") + docker = shutil.which("docker", path="/usr/bin:/bin:/usr/local/bin") + if docker is None: + raise SandboxUnavailable("Docker executable unavailable; refusing unsandboxed fallback") + if not _IMAGE_DIGEST.fullmatch(image): + raise SandboxUnavailable("Docker sandbox image must be pinned using @sha256:<64 hex>") + if not _CONTAINER_NAME.fullmatch(name): + raise SandboxUnavailable("Invalid sandbox container name") + if not config_dir or not os.path.isdir(config_dir): + raise SandboxUnavailable("Private Docker CLI config directory is required") + if not isinstance(memory_mb, int) or isinstance(memory_mb, bool) or not (64 <= memory_mb <= 16384): + raise SandboxUnavailable("sandbox_memory_mb must be between 64 and 16384") + if isinstance(cpus, bool) or not isinstance(cpus, (int, float)) or not (0.1 <= cpus <= 8.0): + raise SandboxUnavailable("sandbox_cpus must be between 0.1 and 8.0") + if not isinstance(pids_limit, int) or isinstance(pids_limit, bool) or not (8 <= pids_limit <= 512): + raise SandboxUnavailable("sandbox_pids_limit must be between 8 and 512") + if not hasattr(os, "getuid") or os.getuid() == 0: + raise SandboxUnavailable("Docker sandbox requires a non-root host identity") + if not os.path.isdir(workspace): + raise SandboxUnavailable("Sandbox workspace must be an existing directory") + + root = os.path.realpath(workspace) + working_dir = os.path.realpath(cwd or root) + if os.path.commonpath((root, working_dir)) != root: + raise SandboxUnavailable("Sandbox cwd is outside workspace") + if not os.path.isdir(working_dir): + raise SandboxUnavailable("Sandbox cwd must be an existing directory") + # Docker --mount parses commas. Reject ambiguous input rather than allowing + # data from a workspace name to be parsed as extra mount options. + if "," in root or "\n" in root or "\r" in root: + raise SandboxUnavailable("Sandbox workspace contains unsupported mount delimiters") + relative = os.path.relpath(working_dir, root) + container_cwd = "/workspace" if relative == "." else "/workspace/" + relative.replace(os.sep, "/") + + return [ + docker, "--config", config_dir, "run", "--rm", + "--pull=never", + "--name", name, + "--network=none", + "--ipc=none", + "--read-only", + "--cap-drop=ALL", + "--security-opt=no-new-privileges:true", + "--pids-limit", str(pids_limit), + "--memory", f"{memory_mb}m", + "--memory-swap", f"{memory_mb}m", + "--cpus", str(cpus), + "--user", f"{os.getuid()}:{os.getgid()}", + "--tmpfs", "/tmp:rw,noexec,nosuid,nodev,size=64m", + "--mount", f"type=bind,source={root},target=/workspace,bind-propagation=rprivate", + "--workdir", container_cwd, + "--env", "HOME=/tmp", "--env", "PATH=/usr/bin:/bin", + "--entrypoint", "/bin/sh", + image, "-c", command, + ] From b65190f3b18d3f7927190fe9ca409335e2a0d29e Mon Sep 17 00:00:00 2001 From: XBetter <32125213+1998x-stack@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:48:17 +0800 Subject: [PATCH 02/10] feat(shell): execute OCI subprocess with daemon container teardown on timeout --- tiny_harness/tools/shell.py | 94 ++++++++++++++++++++++++++++++------- 1 file changed, 77 insertions(+), 17 deletions(-) diff --git a/tiny_harness/tools/shell.py b/tiny_harness/tools/shell.py index 607bdd0..9b5e09c 100644 --- a/tiny_harness/tools/shell.py +++ b/tiny_harness/tools/shell.py @@ -76,7 +76,7 @@ def run_command(args: dict) -> str: # The agent registers the async entrypoint below. Unlike the legacy sync helper # it can stop its whole process group when the executor cancels the coroutine. -async def run_command_async(args: dict, *, structured: bool = False, sandbox_mode: str = "off", workspace: str | None = None, sandbox_cpu_seconds: int = 30, sandbox_memory_mb: int = 1024, sandbox_file_mb: int = 32) -> str | ToolOutcome: +async def run_command_async(args: dict, *, structured: bool = False, sandbox_mode: str = "off", workspace: str | None = None, sandbox_cpu_seconds: int = 30, sandbox_memory_mb: int = 1024, sandbox_file_mb: int = 32, sandbox_image: str | None = None, sandbox_cpus: float = 1.0, sandbox_pids_limit: int = 64) -> str | ToolOutcome: import asyncio import os import signal @@ -94,8 +94,11 @@ async def run_command_async(args: dict, *, structured: bool = False, sandbox_mod if key in safe_keys or key.startswith("LC_") } - if sandbox_mode not in ("off", "bubblewrap"): + if sandbox_mode not in ("off", "bubblewrap", "docker"): raise ValueError(f"Unknown shell sandbox mode: {sandbox_mode!r}") + docker_config = None + docker_name = None + docker_binary = None if sandbox_mode == "bubblewrap": from tiny_harness._sandbox import build_bubblewrap_argv if workspace is None: @@ -107,6 +110,27 @@ async def run_command_async(args: dict, *, structured: bool = False, sandbox_mod file_mb=sandbox_file_mb, ), False cwd = workspace + elif sandbox_mode == "docker": + import secrets + import tempfile + from tiny_harness._docker_sandbox import build_docker_argv + if workspace is None or sandbox_image is None: + raise ValueError("Docker sandbox requires workspace and a digest-pinned sandbox_image") + docker_config = tempfile.TemporaryDirectory(prefix="tiny-harness-docker-") + docker_name = "tiny-harness-" + secrets.token_hex(12) + try: + cmd = build_docker_argv( + command, workspace=workspace, cwd=cwd, image=sandbox_image, + name=docker_name, config_dir=docker_config.name, + memory_mb=sandbox_memory_mb, cpus=sandbox_cpus, + pids_limit=sandbox_pids_limit, + ) + except Exception: + docker_config.cleanup() + raise + docker_binary = cmd[0] + use_shell = False + cwd = workspace else: cmd, use_shell = _build_args(command) @@ -119,10 +143,15 @@ async def run_command_async(args: dict, *, structured: bool = False, sandbox_mod } if os.name == "posix": options["start_new_session"] = True - if use_shell: - process = await asyncio.create_subprocess_shell(cmd, **options) - else: - process = await asyncio.create_subprocess_exec(*cmd, **options) + try: + if use_shell: + process = await asyncio.create_subprocess_shell(cmd, **options) + else: + process = await asyncio.create_subprocess_exec(*cmd, **options) + except BaseException: + if docker_config is not None: + docker_config.cleanup() + raise async def read_limited(stream): parts = [] @@ -149,18 +178,49 @@ def kill_tree(): elif process.returncode is None: process.kill() + async def remove_docker(): + # Killing the CLI alone can leave its daemon-managed container alive. + # The name is generated by us, never supplied by the model. + if docker_name is None: + return + try: + remover = await asyncio.create_subprocess_exec( + docker_binary, "--config", docker_config.name, "rm", "-f", + docker_name, + stdin=asyncio.subprocess.DEVNULL, + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.DEVNULL, + env=environment, + ) + await asyncio.wait_for(remover.wait(), timeout=5) + except (OSError, asyncio.TimeoutError): + # The daemon may already have auto-removed the container. + pass + try: - stdout, stderr, _ = await asyncio.wait_for( - asyncio.gather(stdout_task, stderr_task, process.wait()), - timeout=timeout, - ) - except (asyncio.TimeoutError, asyncio.CancelledError) as exc: - kill_tree() - await process.wait() - await asyncio.gather(stdout_task, stderr_task, return_exceptions=True) - if isinstance(exc, asyncio.CancelledError): - raise - return ToolOutcome.error(f"Error: Command timed out after {timeout}s") if structured else f"Error: Command timed out after {timeout}s" + try: + stdout, stderr, _ = await asyncio.wait_for( + asyncio.gather(stdout_task, stderr_task, process.wait()), + timeout=timeout, + ) + except (asyncio.TimeoutError, asyncio.CancelledError) as exc: + kill_tree() + await process.wait() + await asyncio.gather(stdout_task, stderr_task, return_exceptions=True) + if isinstance(exc, asyncio.CancelledError): + raise + return ToolOutcome.error(f"Error: Command timed out after {timeout}s") if structured else f"Error: Command timed out after {timeout}s" + finally: + if docker_config is not None: + # Shield cleanup against the tool executor's cancellation. + cleanup_task = asyncio.create_task(remove_docker()) + try: + await asyncio.shield(cleanup_task) + except asyncio.CancelledError: + await asyncio.shield(cleanup_task) + raise + finally: + docker_config.cleanup() output = stdout.decode("utf-8", errors="replace") if stderr: From b7108b8f12d0c97d29e49b4c19594b52f01955b2 Mon Sep 17 00:00:00 2001 From: XBetter <32125213+1998x-stack@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:48:30 +0800 Subject: [PATCH 03/10] feat(oci): wire Docker sandbox image and cgroup configuration into Agent --- tiny_harness/_config.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tiny_harness/_config.py b/tiny_harness/_config.py index 220820b..1f4fd73 100644 --- a/tiny_harness/_config.py +++ b/tiny_harness/_config.py @@ -24,6 +24,9 @@ class AgentConfig: sandbox_cpu_seconds: int = 30 sandbox_memory_mb: int = 1024 sandbox_file_mb: int = 32 + sandbox_image: str | None = None # Immutable OCI @sha256 digest + sandbox_cpus: float = 1.0 + sandbox_pids_limit: int = 64 class Prompt: From 1af9c591aedc6262076c4b15d19e3dae2008b224 Mon Sep 17 00:00:00 2001 From: XBetter <32125213+1998x-stack@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:48:33 +0800 Subject: [PATCH 04/10] feat(oci): wire Docker sandbox image and cgroup configuration into Agent --- tiny_harness/_core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tiny_harness/_core.py b/tiny_harness/_core.py index 881c21c..4acafb9 100644 --- a/tiny_harness/_core.py +++ b/tiny_harness/_core.py @@ -22,7 +22,7 @@ class Agent: def __init__(self, prompt: Prompt, config: AgentConfig, store: SessionStore | None = None): self._config = config self._prompt = prompt - if config.shell_sandbox not in ("off", "bubblewrap"): + if config.shell_sandbox not in ("off", "bubblewrap", "docker"): raise ValueError(f"Invalid shell_sandbox: {config.shell_sandbox!r}") if config.context_window <= config.max_tokens: raise ValueError("context_window must exceed max_tokens (reserved output budget)") From c5ff467347fef599460d5e0b73153f5b1403f0df Mon Sep 17 00:00:00 2001 From: XBetter <32125213+1998x-stack@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:48:36 +0800 Subject: [PATCH 05/10] feat(oci): wire Docker sandbox image and cgroup configuration into Agent --- tiny_harness/skills/shell.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tiny_harness/skills/shell.py b/tiny_harness/skills/shell.py index a1a2229..4b12024 100644 --- a/tiny_harness/skills/shell.py +++ b/tiny_harness/skills/shell.py @@ -99,6 +99,9 @@ def register(agent) -> None: sandbox_cpu_seconds=agent._config.sandbox_cpu_seconds, sandbox_memory_mb=agent._config.sandbox_memory_mb, sandbox_file_mb=agent._config.sandbox_file_mb, + sandbox_image=agent._config.sandbox_image, + sandbox_cpus=agent._config.sandbox_cpus, + sandbox_pids_limit=agent._config.sandbox_pids_limit, ), ) agent._prompt.append(SHELL_PROMPT) From 6bec0206a9c9daa96e7e9754c700efcfa2122207 Mon Sep 17 00:00:00 2001 From: XBetter <32125213+1998x-stack@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:48:39 +0800 Subject: [PATCH 06/10] feat(oci): wire Docker sandbox image and cgroup configuration into Agent --- tiny_harness/cli.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tiny_harness/cli.py b/tiny_harness/cli.py index 73cca09..bc11cb2 100644 --- a/tiny_harness/cli.py +++ b/tiny_harness/cli.py @@ -18,10 +18,13 @@ def parse_args() -> Namespace: parser.add_argument("--skills", default="", help="Comma-separated skill names (default: files)") parser.add_argument("--no-hitl", action="store_true", help="Disable human-in-the-loop approval prompts") parser.add_argument("--allow-unsafe-shell", action="store_true", help="Explicitly permit Shell without human approval (dangerous)") - parser.add_argument("--shell-sandbox", choices=("off", "bubblewrap"), default="off", help="Optional Linux bwrap Shell sandbox; fails closed if unavailable") + parser.add_argument("--shell-sandbox", choices=("off", "bubblewrap", "docker"), default="off", help="Optional Linux bwrap Shell sandbox; fails closed if unavailable") parser.add_argument("--sandbox-cpu-seconds", type=int, default=30, help="Bubblewrap process CPU limit (seconds)") parser.add_argument("--sandbox-memory-mb", type=int, default=1024, help="Bubblewrap per-process virtual memory limit (MiB)") parser.add_argument("--sandbox-file-mb", type=int, default=32, help="Bubblewrap file size limit (MiB)") + parser.add_argument("--sandbox-image", default=None, help="Pre-pulled OCI image pinned by @sha256 digest (Docker mode)") + parser.add_argument("--sandbox-cpus", type=float, default=1.0, help="Docker cgroup CPU quota") + parser.add_argument("--sandbox-pids-limit", type=int, default=64, help="Docker container-wide process limit") parser.add_argument("--tui", action="store_true", help="Use rich TUI mode (requires 'rich' package)") parser.add_argument("--api-key-env", default=None, help="Env var for API key (defaults to provider-specific variable)") return parser.parse_args() @@ -166,7 +169,7 @@ def main(): from tiny_harness import Agent, Prompt, Config prompt = Prompt("You are a helpful AI assistant.") - config = Config(model=args.model, api_key=api_key, workspace=args.workspace, provider=args.provider, api_base_url=args.api_base_url, max_iterations=args.max_iterations, no_hitl=args.no_hitl, allow_unsafe_shell=args.allow_unsafe_shell, shell_sandbox=args.shell_sandbox, sandbox_cpu_seconds=args.sandbox_cpu_seconds, sandbox_memory_mb=args.sandbox_memory_mb, sandbox_file_mb=args.sandbox_file_mb) + config = Config(model=args.model, api_key=api_key, workspace=args.workspace, provider=args.provider, api_base_url=args.api_base_url, max_iterations=args.max_iterations, no_hitl=args.no_hitl, allow_unsafe_shell=args.allow_unsafe_shell, shell_sandbox=args.shell_sandbox, sandbox_cpu_seconds=args.sandbox_cpu_seconds, sandbox_memory_mb=args.sandbox_memory_mb, sandbox_file_mb=args.sandbox_file_mb, sandbox_image=args.sandbox_image, sandbox_cpus=args.sandbox_cpus, sandbox_pids_limit=args.sandbox_pids_limit) agent = Agent(prompt=prompt, config=config) if not args.no_hitl: From 9cc9fe37662ee19238a14f7213c2275bcb05ba8a Mon Sep 17 00:00:00 2001 From: XBetter <32125213+1998x-stack@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:49:19 +0800 Subject: [PATCH 07/10] test(oci): validate security profile and real cgroup/network/filesystem isolation --- tests/test_docker_sandbox.py | 161 +++++++++++++++++++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 tests/test_docker_sandbox.py diff --git a/tests/test_docker_sandbox.py b/tests/test_docker_sandbox.py new file mode 100644 index 0000000..7d50cef --- /dev/null +++ b/tests/test_docker_sandbox.py @@ -0,0 +1,161 @@ +"""OCI sandbox argv, fail-closed errors, and opt-in Docker integration.""" +import os +import shlex +import shutil +import sys + +import pytest + +from tiny_harness import Agent, Config, Prompt +from tiny_harness._docker_sandbox import build_docker_argv +from tiny_harness._sandbox import SandboxUnavailable + + +IMAGE = "alpine@sha256:" + "a" * 64 +NAME = "tiny-harness-" + "b" * 24 + + +def _mock_docker(monkeypatch): + monkeypatch.setattr("tiny_harness._docker_sandbox.shutil.which", + lambda name, path=None: "/usr/bin/docker") + monkeypatch.setattr("tiny_harness._docker_sandbox.os.getuid", lambda: 1001) + monkeypatch.setattr("tiny_harness._docker_sandbox.os.getgid", lambda: 1002) + + +def test_immutable_image_and_cgroup_policy_argv(tmp_path, monkeypatch): + _mock_docker(monkeypatch) + workspace = tmp_path / "workspace" + workspace.mkdir() + sub = workspace / "nested" + sub.mkdir() + payload = "echo token; echo --network=host" + argv = build_docker_argv( + payload, workspace=str(workspace), cwd=str(sub), image=IMAGE, + name=NAME, config_dir=str(tmp_path), memory_mb=256, + cpus=0.5, pids_limit=16, + ) + assert argv[:4] == ["/usr/bin/docker", "--config", str(tmp_path), "run"] + for option in ("--rm", "--pull=never", "--network=none", "--ipc=none", + "--read-only", "--cap-drop=ALL", + "--security-opt=no-new-privileges:true"): + assert option in argv + assert "--memory" in argv and argv[argv.index("--memory") + 1] == "256m" + assert argv[argv.index("--memory-swap") + 1] == "256m" + assert argv[argv.index("--pids-limit") + 1] == "16" + assert argv[argv.index("--cpus") + 1] == "0.5" + assert argv[argv.index("--user") + 1] == "1001:1002" + assert argv[argv.index("--workdir") + 1] == "/workspace/nested" + assert argv[argv.index("--mount") + 1].startswith( + "type=bind,source=" + str(workspace) + ",target=/workspace" + ) + assert argv[-3:] == [IMAGE, "-c", payload] + assert "--privileged" not in argv + assert "--network=host" not in argv + assert "--pull=always" not in argv + assert not any("/var/run/docker.sock" in arg for arg in argv) + + +@pytest.mark.parametrize("image", [ + "alpine:3.20", "docker.io/library/alpine:latest", + "--privileged", "alpine@sha256:ABC", "", "alpine@sha256:123", +]) +def test_reject_unpinned_or_invalid_images(tmp_path, monkeypatch, image): + _mock_docker(monkeypatch) + with pytest.raises(SandboxUnavailable, match="pinned"): + build_docker_argv("true", workspace=str(tmp_path), cwd=None, + image=image, name=NAME, config_dir=str(tmp_path)) + + +@pytest.mark.parametrize("field,value", [ + ("memory_mb", 32), ("memory_mb", True), + ("cpus", 0), ("cpus", 20), ("cpus", True), + ("pids_limit", 0), ("pids_limit", 10000), +]) +def test_unsafe_resource_values_fail_closed(tmp_path, monkeypatch, field, value): + _mock_docker(monkeypatch) + kw = {"memory_mb": 512, "cpus": 1.0, "pids_limit": 64} + kw[field] = value + with pytest.raises(SandboxUnavailable): + build_docker_argv("true", workspace=str(tmp_path), cwd=None, + image=IMAGE, name=NAME, config_dir=str(tmp_path), **kw) + + +def test_unsafe_workspace_escape_or_mount_delimiter_fails(tmp_path, monkeypatch): + _mock_docker(monkeypatch) + w = tmp_path / "workspace" + w.mkdir() + for cwd in (str(tmp_path), str(w / "missing")): + with pytest.raises(SandboxUnavailable): + build_docker_argv("true", workspace=str(w), cwd=cwd, + image=IMAGE, name=NAME, config_dir=str(tmp_path)) + comma = tmp_path / "work,space" + comma.mkdir() + with pytest.raises(SandboxUnavailable, match="mount"): + build_docker_argv("true", workspace=str(comma), cwd=None, + image=IMAGE, name=NAME, config_dir=str(tmp_path)) + + +@pytest.mark.asyncio +async def test_docker_unavailable_never_runs_host_shell(tmp_path, monkeypatch): + monkeypatch.setattr("tiny_harness._docker_sandbox.shutil.which", + lambda name, path=None: None) + agent = Agent(Prompt("fixture"), Config( + model="t", api_key="unused", workspace=str(tmp_path), + no_hitl=True, allow_unsafe_shell=True, + shell_sandbox="docker", sandbox_image=IMAGE, + )) + agent.load_skill("shell") + result = await agent._tool_executor.execute( + "run_command", {"command": "echo canary"}, "call-1", + ) + assert not result.success + assert "Docker executable unavailable" in result.content + + +@pytest.mark.asyncio +async def test_missing_digest_never_starts_a_container(tmp_path): + agent = Agent(Prompt("fixture"), Config( + model="t", api_key="unused", workspace=str(tmp_path), + no_hitl=True, allow_unsafe_shell=True, shell_sandbox="docker", + )) + agent.load_skill("shell") + result = await agent._tool_executor.execute( + "run_command", {"command": "true"}, "call-2", + ) + assert not result.success + assert "digest-pinned" in result.content + + +@pytest.mark.asyncio +async def test_real_docker_cgroup_network_and_workspace_boundary(tmp_path): + """Actually execute the container when CI supplies a pulled immutable image.""" + image = os.environ.get("TINY_HARNESS_TEST_DOCKER_IMAGE") + if not image: + pytest.skip("Docker integration requires TINY_HARNESS_TEST_DOCKER_IMAGE") + workspace = tmp_path / "workspace" + workspace.mkdir() + secret = tmp_path / "host-private-marker.txt" + secret.write_text("hidden") + agent = Agent(Prompt("fixture"), Config( + model="t", api_key="unused", workspace=str(workspace), + no_hitl=True, allow_unsafe_shell=True, + shell_sandbox="docker", sandbox_image=image, + sandbox_memory_mb=256, sandbox_cpus=0.5, + sandbox_pids_limit=16, + )) + agent.load_skill("shell") + command = ( + "printf cgroup-ok > generated.txt; " + "test ! -e " + shlex.quote(str(secret)) + "; " + "test \"$(cat /sys/fs/cgroup/memory.max)\" = 268435456; " + "test \"$(cat /sys/fs/cgroup/pids.max)\" = 16; " + "test \"$(awk 'NR>1{n++}END{print n+0}' /proc/net/route)\" = 0; " + "pwd" + ) + result = await agent._tool_executor.execute( + "run_command", {"command": command, "timeout": 20}, "call-3", + ) + assert result.success, result.content + assert "/workspace" in result.content + assert (workspace / "generated.txt").read_text() == "cgroup-ok" + assert secret.read_text() == "hidden" From 136c449d95154e3ce75016755cfaa5b7e33a8349 Mon Sep 17 00:00:00 2001 From: XBetter <32125213+1998x-stack@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:49:38 +0800 Subject: [PATCH 08/10] fix(oci): reject system root directories as workspace mounts --- tiny_harness/_docker_sandbox.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tiny_harness/_docker_sandbox.py b/tiny_harness/_docker_sandbox.py index cb03d8b..9347d69 100644 --- a/tiny_harness/_docker_sandbox.py +++ b/tiny_harness/_docker_sandbox.py @@ -51,6 +51,8 @@ def build_docker_argv( raise SandboxUnavailable("Sandbox workspace must be an existing directory") root = os.path.realpath(workspace) + if root in ("/", "/etc", "/home", "/root", "/proc", "/sys", "/dev", "/run"): + raise SandboxUnavailable("Refusing to expose a system root as Docker workspace") working_dir = os.path.realpath(cwd or root) if os.path.commonpath((root, working_dir)) != root: raise SandboxUnavailable("Sandbox cwd is outside workspace") From c6d538c8fc5089942317a37802d8c0d108602ded Mon Sep 17 00:00:00 2001 From: XBetter <32125213+1998x-stack@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:49:47 +0800 Subject: [PATCH 09/10] ci(oci): add mandatory real Docker cgroup/network/filesystem isolation job --- .github/workflows/ci.yml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 22bde42..c4fac82 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,3 +42,25 @@ jobs: - name: Check actual kernel namespace support and run isolation tests run: | python -m pytest -q tests/test_shell_bwrap.py tests/test_sandbox_resource_limits.py -rs + + oci-sandbox-smoke: + name: Real OCI cgroup and network isolation + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install tests + run: python -m pip install -e ".[dev]" + - name: Confirm daemon and pull explicit integration image + run: | + docker info + docker pull alpine:3.20 + IMAGE="$(docker image inspect --format '{{index .RepoDigests 0}}' alpine:3.20)" + case "$IMAGE" in *@sha256:*) ;; *) echo 'Immutable image digest unavailable' >&2; exit 1 ;; esac + echo "TINY_HARNESS_TEST_DOCKER_IMAGE=$IMAGE" >> "$GITHUB_ENV" + - name: Execute real OCI boundary and cgroup tests (no skips) + run: | + python -m pytest -q tests/test_docker_sandbox.py -rs + python -m pytest -q tests/test_docker_sandbox.py::test_real_docker_cgroup_network_and_workspace_boundary -rs From 47573fd8e2efe5c20d707098f58888b1bc6bcae1 Mon Sep 17 00:00:00 2001 From: XBetter <32125213+1998x-stack@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:50:53 +0800 Subject: [PATCH 10/10] test(oci): verify actual timeout and cancellation tear down Docker containers --- tests/test_docker_sandbox.py | 61 ++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/tests/test_docker_sandbox.py b/tests/test_docker_sandbox.py index 7d50cef..6f8eb94 100644 --- a/tests/test_docker_sandbox.py +++ b/tests/test_docker_sandbox.py @@ -159,3 +159,64 @@ async def test_real_docker_cgroup_network_and_workspace_boundary(tmp_path): assert "/workspace" in result.content assert (workspace / "generated.txt").read_text() == "cgroup-ok" assert secret.read_text() == "hidden" + + +@pytest.mark.asyncio +async def test_real_docker_timeout_removes_daemon_container(tmp_path, monkeypatch): + """A timed-out Docker CLI must not orphan its daemon-owned container.""" + image = os.environ.get("TINY_HARNESS_TEST_DOCKER_IMAGE") + if not image: + pytest.skip("Docker integration requires TINY_HARNESS_TEST_DOCKER_IMAGE") + import secrets + import subprocess + + name = "tiny-harness-" + "d" * 24 + monkeypatch.setattr(secrets, "token_hex", lambda count: "d" * 24) + agent = Agent(Prompt("fixture"), Config( + model="t", api_key="unused", workspace=str(tmp_path), + no_hitl=True, allow_unsafe_shell=True, + shell_sandbox="docker", sandbox_image=image, + )) + agent.load_skill("shell") + result = await agent._tool_executor.execute( + "run_command", {"command": "sleep 30", "timeout": 1}, "timeout-1", + ) + assert not result.success + assert "timed out" in result.content.lower() + proc = subprocess.run( + ["docker", "ps", "-aq", "--filter", f"name=^{name}$"], + capture_output=True, text=True, check=True, timeout=5, + ) + assert proc.stdout.strip() == "", "Sandbox container was not removed after timeout" + + +@pytest.mark.asyncio +async def test_real_docker_cancel_removes_daemon_container(tmp_path, monkeypatch): + """Client-side task cancellation must also force container teardown.""" + image = os.environ.get("TINY_HARNESS_TEST_DOCKER_IMAGE") + if not image: + pytest.skip("Docker integration requires TINY_HARNESS_TEST_DOCKER_IMAGE") + import asyncio + import secrets + import subprocess + + name = "tiny-harness-" + "e" * 24 + monkeypatch.setattr(secrets, "token_hex", lambda count: "e" * 24) + agent = Agent(Prompt("fixture"), Config( + model="t", api_key="unused", workspace=str(tmp_path), + no_hitl=True, allow_unsafe_shell=True, + shell_sandbox="docker", sandbox_image=image, + )) + agent.load_skill("shell") + running = asyncio.create_task(agent._tool_executor.execute( + "run_command", {"command": "sleep 30", "timeout": 30}, "cancel-1", + )) + await asyncio.sleep(0.5) + running.cancel() + with pytest.raises(asyncio.CancelledError): + await running + proc = subprocess.run( + ["docker", "ps", "-aq", "--filter", f"name=^{name}$"], + capture_output=True, text=True, check=True, timeout=5, + ) + assert proc.stdout.strip() == "", "Sandbox container was not removed after cancellation"