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
3 changes: 2 additions & 1 deletion aicp/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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":
Expand Down
25 changes: 25 additions & 0 deletions aicp/container_runner.py
Original file line number Diff line number Diff line change
@@ -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,
]
10 changes: 7 additions & 3 deletions aicp/execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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):
Expand All @@ -41,16 +42,19 @@ 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""
except OSError as exc:
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),
Expand Down Expand Up @@ -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:
Expand Down
6 changes: 6 additions & 0 deletions docs/CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
35 changes: 35 additions & 0 deletions tests/test_container_runner.py
Original file line number Diff line number Diff line change
@@ -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()
13 changes: 13 additions & 0 deletions tests/test_execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading