Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
222 changes: 222 additions & 0 deletions tests/test_docker_sandbox.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
"""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"


@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"
3 changes: 3 additions & 0 deletions tiny_harness/_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion tiny_harness/_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
Expand Down
88 changes: 88 additions & 0 deletions tiny_harness/_docker_sandbox.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
"""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)
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")
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,
]
7 changes: 5 additions & 2 deletions tiny_harness/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions tiny_harness/skills/shell.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Loading
Loading