From f1818b91827a68a37ee55ef611c045804faba3df Mon Sep 17 00:00:00 2001 From: Ioannis Polyzos Date: Sat, 10 Oct 2026 16:17:36 +0100 Subject: [PATCH 1/6] feat: define constrained Docker agent invocation --- aicp/container_runner.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 aicp/container_runner.py diff --git a/aicp/container_runner.py b/aicp/container_runner.py new file mode 100644 index 0000000..d21b7d7 --- /dev/null +++ b/aicp/container_runner.py @@ -0,0 +1,25 @@ +"""Opt-in, constrained Docker runner for experimental agent commands. + +This is defense in depth, not a hardened multi-tenant security boundary. +""" +import re + + +def docker_agent_command(project, image, argv): + if not isinstance(image, str) or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._:/@-]{0,253}", image): + raise ValueError("Invalid container image reference") + if not argv or not all(isinstance(arg, str) and arg for arg in argv): + raise ValueError("Invalid agent argv") + if not project.is_dir() or project.is_symlink(): + raise ValueError("Project must be an existing non-symlink directory") + return [ + "docker", "run", "--rm", "--network", "none", + "--read-only", "--cap-drop", "ALL", + "--security-opt", "no-new-privileges", + "--pids-limit", "128", "--memory", "1g", "--cpus", "1", + "--user", "65534:65534", + "--tmpfs", "/tmp:rw,nosuid,nodev,size=64m", + "--mount", "type=bind,src=" + str(project.resolve()) + ",dst=/workspace", + "--workdir", "/workspace", + image, *argv, + ] From b11d89f5285e5910a555843387b5d0b086a69a7e Mon Sep 17 00:00:00 2001 From: Ioannis Polyzos Date: Sat, 10 Oct 2026 16:17:42 +0100 Subject: [PATCH 2/6] feat: add opt-in constrained Docker execution mode --- aicp/execution.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/aicp/execution.py b/aicp/execution.py index 2ee3daf..ff10a37 100644 --- a/aicp/execution.py +++ b/aicp/execution.py @@ -7,6 +7,7 @@ from .task_lifecycle import show_task from .scope_audit import git_snapshot, audit_scope +from .container_runner import docker_agent_command from .verification import run_checks, validate_checks @@ -28,7 +29,7 @@ def _load_trusted_inputs(project, name, checks_path): return task, task_path, task_bytes, checks_bytes, checks -def run_task(project, name, tool, argv, checks_path, output, timeout=300): +def run_task(project, name, tool, argv, checks_path, output, timeout=300, container_image=None): if tool not in ("codex", "claude", "gemini"): raise ValueError("Only explicitly supported local agent CLIs may run") if not argv or argv[0] != tool or not all(isinstance(a, str) and a for a in argv): @@ -41,9 +42,10 @@ def run_task(project, name, tool, argv, checks_path, output, timeout=300): baseline = git_snapshot(project) if baseline: raise ValueError('Scope audit requires a clean Git worktree before execution') + agent_command = docker_agent_command(project, container_image, argv) if container_image else argv started = time.monotonic() try: - proc = subprocess.run(argv, cwd=project, capture_output=True, timeout=timeout, check=False) + proc = subprocess.run(agent_command, cwd=project, capture_output=True, timeout=timeout, check=False) status, exit_code, stdout, stderr = "completed", proc.returncode, proc.stdout, proc.stderr except subprocess.TimeoutExpired as exc: status, exit_code, stdout, stderr = "timeout", None, exc.stdout or b"", exc.stderr or b"" @@ -51,6 +53,8 @@ def run_task(project, name, tool, argv, checks_path, output, timeout=300): status, exit_code, stdout, stderr = "launch_error", None, b"", str(exc).encode() agent = { "tool": tool, + "execution_mode": "constrained-docker" if container_image else "trusted-host", + "container_image": container_image, "status": status, "exit_code": exit_code, "duration_seconds": round(time.monotonic() - started, 3), @@ -85,7 +89,7 @@ def run_task(project, name, tool, argv, checks_path, output, timeout=300): "verification_source": "pre-agent-snapshot", }, "passed": bool(agent["passed"] and inputs_unchanged and scope["passed"] and checks and checks["passed"]), - "security_boundary": "trusted-host-execution-no-sandbox", + "security_boundary": "constrained-docker-not-hardened" if container_image else "trusted-host-execution-no-sandbox", "scope_enforced": False, } with output.open("x", encoding="utf-8") as handle: From a618b0f92b5d68034ab5acd0e668e689d5e63ee6 Mon Sep 17 00:00:00 2001 From: Ioannis Polyzos Date: Sat, 10 Oct 2026 16:17:49 +0100 Subject: [PATCH 3/6] feat: expose optional container image for agent runs --- aicp/__main__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/aicp/__main__.py b/aicp/__main__.py index 1ae7181..30d092f 100644 --- a/aicp/__main__.py +++ b/aicp/__main__.py @@ -123,6 +123,7 @@ def main(argv=None): run_parser.add_argument("--checks", type=Path, required=True) run_parser.add_argument("--output", type=Path, required=True) run_parser.add_argument("--timeout", type=int, default=300) + run_parser.add_argument("--container-image", help="Opt-in constrained Docker execution (no network)") run_parser.add_argument("--approve-host-execution", action="store_true", required=True) run_parser.add_argument("agent_argv", nargs=argparse.REMAINDER) verify_parser = commands.add_parser("verify", help="Run explicit project checks") @@ -167,7 +168,7 @@ def main(argv=None): elif args.command == "run": validate_config(args.path) argv = args.agent_argv[1:] if args.agent_argv and args.agent_argv[0] == "--" else args.agent_argv - report = run_task(args.path, args.name, args.tool, argv, args.checks, args.output, args.timeout) + report = run_task(args.path, args.name, args.tool, argv, args.checks, args.output, args.timeout, args.container_image) print(f"Execution {'passed' if report['passed'] else 'failed'}: {args.output}") return 0 if report["passed"] else 1 elif args.command == "task": From 73501423cf45daa18a7a8dc36e316b7d0356bd59 Mon Sep 17 00:00:00 2001 From: Ioannis Polyzos Date: Sat, 10 Oct 2026 16:17:56 +0100 Subject: [PATCH 4/6] test: verify constrained Docker command construction --- tests/test_container_runner.py | 35 ++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 tests/test_container_runner.py diff --git a/tests/test_container_runner.py b/tests/test_container_runner.py new file mode 100644 index 0000000..01ee67e --- /dev/null +++ b/tests/test_container_runner.py @@ -0,0 +1,35 @@ +"""Constrained Docker command tests (no Docker daemon required).""" +from pathlib import Path +import tempfile +import unittest + +from aicp.container_runner import docker_agent_command + + +class ContainerRunnerTests(unittest.TestCase): + def test_constrained_flags_and_command(self): + with tempfile.TemporaryDirectory() as tmp: + command = docker_agent_command(Path(tmp), "example/agent:local", ["codex", "exec", "task"]) + self.assertEqual(command[:3], ["docker", "run", "--rm"]) + self.assertIn("none", command) + self.assertIn("--cap-drop", command) + self.assertIn("no-new-privileges", command) + self.assertIn("--read-only", command) + self.assertEqual(command[-4:], ["example/agent:local", "codex", "exec", "task"]) + + def test_invalid_image_rejected(self): + with tempfile.TemporaryDirectory() as tmp: + with self.assertRaises(ValueError): + docker_agent_command(Path(tmp), "--privileged", ["codex"]) + + def test_symlink_project_rejected(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / "repo").mkdir() + (root / "link").symlink_to(root / "repo", target_is_directory=True) + with self.assertRaises(ValueError): + docker_agent_command(root / "link", "agent:latest", ["codex"]) + + +if __name__ == "__main__": + unittest.main() From 8b18d3f7950ff92fd189d4acda28c0d857a6e72b Mon Sep 17 00:00:00 2001 From: Ioannis Polyzos Date: Sat, 10 Oct 2026 16:18:02 +0100 Subject: [PATCH 5/6] test: verify container execution selection --- tests/test_execution.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/test_execution.py b/tests/test_execution.py index 55b0c69..bdc64d1 100644 --- a/tests/test_execution.py +++ b/tests/test_execution.py @@ -72,6 +72,19 @@ def test_invalid_checks_fail_before_agent_execution(self): run_task(self.project, "fix-retry", "codex", ["codex"], self.checks, self.output) agent.assert_not_called() + def test_container_mode_invokes_docker(self): + with patch("aicp.execution.subprocess.run") as agent: + agent.return_value.returncode = 1 + agent.return_value.stdout = b"" + agent.return_value.stderr = b"" + result = run_task( + self.project, "fix-retry", "codex", ["codex", "exec", "task"], + self.checks, self.output, container_image="example/agent:local", + ) + command = agent.call_args.args[0] + self.assertEqual(command[:2], ["docker", "run"]) + self.assertEqual(result["agent"]["execution_mode"], "constrained-docker") + def test_out_of_scope_changes_block_verification(self): with patch("aicp.execution.git_snapshot", side_effect=[set(), {"src/fix.py", "secrets.txt"}]): with patch("aicp.execution.subprocess.run") as agent: From 7d8fec11302d635a446a0a02757800868601b48b Mon Sep 17 00:00:00 2001 From: Ioannis Polyzos Date: Sat, 10 Oct 2026 16:18:11 +0100 Subject: [PATCH 6/6] docs: document constrained container execution and limitations --- docs/CLI.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/CLI.md b/docs/CLI.md index b9b682e..2ee8861 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -58,3 +58,9 @@ The report records agent exit status, output hashes, verification results, and w ### Git workspace scope audit Supervised `aicp run` now requires a **clean Git worktree** at startup and records newly changed tracked/untracked paths after the agent exits. If any reported path is outside the task's `allowed_paths`, verification is skipped and the run fails. The JSON report includes `scope_audit` with paths and status. This is a **best-effort audit, not permission enforcement**: Git status cannot detect ignored files, committed changes, writes that are reverted, or writes outside the repository. The agent retains full host permissions. Use a disposable isolated environment, and independently review diffs and tests. + +### Constrained Docker agent execution (experimental) + +To run the agent inside a disposable Docker container rather than directly on the host, pass `--container-image example/agent:local` to `aicp run`. The image must already contain the selected agent executable and any required offline dependencies. The runner disables container networking, uses a read-only root filesystem, drops Linux capabilities, disallows privilege escalation, restricts processes/CPU/memory, and mounts the project at `/workspace` for changes. It runs as UID/GID 65534, so workspace permissions must allow this user to write. Verification still runs **on the trusted host** after the agent finishes, using the pre-agent check snapshot. + +This mode is **not** a hardened isolation boundary or safe for hostile code. Do not mount credentials, Docker sockets, or sensitive workspaces. Docker daemon access is privileged; only use images you trust, and keep the Docker host and runtime patched. No vendor credentials are passed by this runner. Network-disabled mode may be unsuitable for cloud-backed agents; it is primarily a foundation for local/offline agents and synthetic tests. CI only tests command construction, not a live Docker daemon or vendor agent. Never claim the presence of `--container-image` alone proves sandbox security.