Skip to content

Commit 82e4682

Browse files
committed
agent: add container sandbox runtime for code review
This change adds an optional Container sandbox runtime for the code review Agent example. The runtime reuses the existing ContainerClient to execute allowlisted sandbox scripts with diff input through stdin, network access disabled, timeout enforcement, output truncation, and structured sandbox run records. The fake sandbox remains the default deterministic runtime for local tests and CI, while the container runtime provides an isolated execution path for diff summary and static rule scripts. Updates #92 RELEASE NOTES: NONE
1 parent 0155e1c commit 82e4682

6 files changed

Lines changed: 270 additions & 17 deletions

File tree

examples/code_review_agent/README.md

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ The default mode intentionally does **not** require API keys, Docker, Cube/E2B,
1010
- Unified diff parsing for files, hunks, changed lines, and line anchors.
1111
- Deterministic rules for secrets, security risks, async issues, resource leaks, database lifecycle issues, and missing tests.
1212
- Pre-sandbox governance decisions for allowlisted scripts, forbidden paths, risky commands, network access, and output budgets.
13-
- Fake sandbox runs with failure/timeout/output-limit records.
13+
- Fake sandbox runs with failure/timeout/output-limit records, plus optional Docker Container runtime for allowlisted scripts.
1414
- Structured findings, warnings, filter decisions, sandbox summaries, audit events, and metrics.
1515
- Optional SQLite persistence for review task, sandbox run, filter decision, finding, warning, audit, and report records.
1616

@@ -22,7 +22,7 @@ user / CI request
2222
-> parse files, hunks, changed lines, and anchors
2323
-> build sandbox requests
2424
-> apply pre-sandbox Filter governance
25-
-> execute allowed requests in fake sandbox
25+
-> execute allowed requests in fake sandbox or Docker container
2626
-> run deterministic fake-model rules
2727
-> post-filter findings: redact, anchor, dedupe, route low confidence
2828
-> build review_report.json and review_report.md
@@ -122,6 +122,17 @@ python examples/code_review_agent/run_review.py \
122122
--json
123123
```
124124

125+
Review with Docker Container sandbox execution for allowlisted scripts:
126+
127+
```bash
128+
python examples/code_review_agent/run_review.py \
129+
--diff-file examples/code_review_agent/fixtures/hardcoded_secret.diff \
130+
--fake-model \
131+
--sandbox-runtime container \
132+
--container-image python:3-slim \
133+
--json
134+
```
135+
125136
Query a persisted task:
126137

127138
```bash
@@ -175,16 +186,16 @@ Rows store redacted summaries and report payloads. Raw secret-bearing diffs are
175186
## Security boundaries
176187

177188
- Fake sandbox is the default and never executes host commands.
178-
- Non-fake runtimes are not required for tests and should be treated as future adapters.
179-
- Local execution is a development fallback only, not the production default.
189+
- Container runtime is optional and uses the project's existing Docker `ContainerClient` to execute only allowlisted scripts with network disabled.
190+
- Cube/E2B can be integrated through the repository's existing `CubeWorkspaceRuntime`, but it is not required for the default deterministic path.
180191
- Pre-sandbox governance denies risky commands, forbidden paths, network access, and over-budget requests.
181192
- Denied or `needs_human_review` sandbox requests are recorded but not executed.
182193
- Reports and database rows redact API keys, tokens, passwords, private keys, cookies, authorization headers, and credential URLs.
183194
- Sandbox failures, timeouts, and truncated output are recorded as non-fatal audit data.
184195

185196
## Design note
186197

187-
The prototype keeps the Agent implementation deterministic so contributors can validate the full review lifecycle without external services. The Skill package defines the policy layer: scope, review categories, finding schema, and sandbox safety expectations. The Python pipeline then implements that policy in a dry-run form. Inputs are normalized from either a diff file or local git diff, then parsed into files, hunks, and changed-line anchors. Before any executable check, sandbox requests pass through a governance filter that allowlists known scripts and rejects risky commands, forbidden paths, network access, or excessive output. The fake sandbox records the same shape of result that a Container or Cube/E2B runtime would provide, including failures and timeouts, but never executes arbitrary host commands. Deterministic rules produce structured findings for secrets, security risks, async issues, resource leaks, database lifecycle problems, and missing tests. Post-filters redact sensitive values, dedupe by file/line/category, and route low-confidence or unanchored issues to human-review warnings. SQLite persistence stores task state, sandbox runs, filter decisions, findings, warnings, metrics, audit events, and the final report using only redacted data. Reports expose findings, severity/category statistics, human-review items, Filter decisions, sandbox summaries, monitoring fields, and actionable recommendations.
198+
The prototype keeps the Agent implementation deterministic so contributors can validate the full review lifecycle without external services. The Skill package defines the policy layer: scope, review categories, finding schema, and sandbox safety expectations. The Python pipeline then implements that policy in a dry-run form. Inputs are normalized from either a diff file or local git diff, then parsed into files, hunks, and changed-line anchors. Before any executable check, sandbox requests pass through a governance filter that allowlists known scripts and rejects risky commands, forbidden paths, network access, or excessive output. The fake sandbox records the same shape of result that an isolated runtime would provide, while the optional Container runtime uses the repository's existing Docker `ContainerClient` to execute allowlisted scripts with stdin, timeout, output caps, and network disabled. Deterministic rules produce structured findings for secrets, security risks, async issues, resource leaks, database lifecycle problems, and missing tests. Post-filters redact sensitive values, dedupe by file/line/category, and route low-confidence or unanchored issues to human-review warnings. SQLite persistence stores task state, sandbox runs, filter decisions, findings, warnings, metrics, audit events, and the final report using only redacted data. Reports expose findings, severity/category statistics, human-review items, Filter decisions, sandbox summaries, monitoring fields, and actionable recommendations.
188199

189200
## Verification
190201

examples/code_review_agent/agent/pipeline.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
from .inputs import build_review_input
2121
from .report import build_report
2222
from .rules import review_with_rules
23-
from .sandbox import FakeSandboxRunner
23+
from .sandbox import create_sandbox_runner
2424
from .schemas import AuditEvent
2525
from .schemas import ParsedDiff
2626
from .schemas import ReviewInput
@@ -39,6 +39,7 @@ class ReviewRunConfig:
3939
db_path: Path | None = None
4040
task_id: str | None = None
4141
include_missing_tests: bool = True
42+
container_image: str = "python:3-slim"
4243

4344

4445
def run_dry_review(diff_text: str, *, fake_model: bool = True) -> ReviewReport:
@@ -88,7 +89,8 @@ def run_review(
8889

8990
requests = build_default_sandbox_requests(review_input.changed_files)
9091
allowed_requests, pre_decisions = evaluate_sandbox_requests(requests, policy)
91-
sandbox_runs = FakeSandboxRunner(policy).run_requests(allowed_requests, parsed)
92+
sandbox_runner = create_sandbox_runner(policy, container_image=config.container_image)
93+
sandbox_runs = sandbox_runner.run_requests(allowed_requests, parsed, diff_text)
9294
for run in sandbox_runs:
9395
if run.error_type:
9496
audit_events.append(

examples/code_review_agent/agent/sandbox.py

Lines changed: 156 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,23 +7,35 @@
77

88
from __future__ import annotations
99

10+
import asyncio
11+
import contextlib
12+
import logging
13+
import sys
1014
import time
1115
from collections.abc import Sequence
16+
from pathlib import Path
1217

1318
from .filters import redact_text
1419
from .governance import SandboxRequest
1520
from .schemas import ParsedDiff
1621
from .schemas import SandboxPolicy
1722
from .schemas import SandboxRun
1823

24+
_SCRIPT_FILES = {
25+
"diff_summary": "diff_summary.py",
26+
"static_rules": "static_rules.py",
27+
}
28+
1929

2030
class FakeSandboxRunner:
2131
"""Deterministic sandbox runner that never executes host commands."""
2232

2333
def __init__(self, policy: SandboxPolicy) -> None:
2434
self._policy = policy
2535

26-
def run_requests(self, requests: Sequence[SandboxRequest], parsed_diff: ParsedDiff) -> list[SandboxRun]:
36+
def run_requests(
37+
self, requests: Sequence[SandboxRequest], parsed_diff: ParsedDiff, diff_text: str = ""
38+
) -> list[SandboxRun]:
2739
"""Run all allowed requests deterministically."""
2840
return [self.run_request(request, parsed_diff) for request in requests]
2941

@@ -72,6 +84,149 @@ def run_request(self, request: SandboxRequest, parsed_diff: ParsedDiff) -> Sandb
7284
)
7385

7486

87+
class ContainerSandboxRunner:
88+
"""Sandbox runner backed by the project's existing Docker ContainerClient."""
89+
90+
def __init__(self, policy: SandboxPolicy, *, scripts_dir: Path | None = None, image: str = "python:3-slim") -> None:
91+
self._policy = policy
92+
self._scripts_dir = scripts_dir or Path(__file__).resolve().parents[1] / "skills" / "code-review" / "scripts"
93+
self._image = image
94+
95+
def run_requests(
96+
self, requests: Sequence[SandboxRequest], parsed_diff: ParsedDiff, diff_text: str = ""
97+
) -> list[SandboxRun]:
98+
"""Run allowed requests in Docker containers."""
99+
return [self.run_request(request, diff_text) for request in requests]
100+
101+
def run_request(self, request: SandboxRequest, diff_text: str) -> SandboxRun:
102+
"""Run one allowlisted script in a Docker container."""
103+
started = time.monotonic()
104+
script_file = _SCRIPT_FILES.get(request.script_name)
105+
if script_file is None:
106+
return _build_run(
107+
request,
108+
runtime="container",
109+
started=started,
110+
exit_code=1,
111+
stderr=f"no container script is mapped for {request.script_name}",
112+
error_type="SandboxScriptUnavailable",
113+
policy=self._policy,
114+
)
115+
116+
try:
117+
return asyncio.run(self._run_request_async(request, script_file, diff_text, started))
118+
except Exception as exc: # pylint: disable=broad-except
119+
return _build_run(
120+
request,
121+
runtime="container",
122+
started=started,
123+
exit_code=-1,
124+
stderr=str(exc),
125+
error_type=exc.__class__.__name__,
126+
policy=self._policy,
127+
)
128+
129+
async def _run_request_async(
130+
self, request: SandboxRequest, script_file: str, diff_text: str, started: float
131+
) -> SandboxRun:
132+
CommandArgs, ContainerClient, ContainerConfig = _load_container_runtime()
133+
_redirect_trpc_agent_logs_to_stderr()
134+
with contextlib.redirect_stdout(sys.stderr):
135+
client = ContainerClient(
136+
ContainerConfig(
137+
image=self._image,
138+
host_config={
139+
"Binds": [f"{self._scripts_dir}:/workspace/scripts:ro"],
140+
"network_mode": "none",
141+
"working_dir": "/workspace",
142+
},
143+
)
144+
)
145+
try:
146+
with contextlib.redirect_stdout(sys.stderr):
147+
result = await client.exec_run(
148+
["python3", f"/workspace/scripts/{script_file}"],
149+
CommandArgs(timeout=self._policy.timeout_seconds, stdin=diff_text, environment={}),
150+
)
151+
finally:
152+
cleanup = getattr(client, "_cleanup_container", None)
153+
if callable(cleanup):
154+
cleanup()
155+
156+
error_type = None
157+
if result.is_timeout:
158+
error_type = "SandboxTimeout"
159+
elif result.exit_code:
160+
error_type = "SandboxCommandFailed"
161+
return _build_run(
162+
request,
163+
runtime="container",
164+
started=started,
165+
exit_code=result.exit_code,
166+
stdout=result.stdout,
167+
stderr=result.stderr,
168+
timed_out=result.is_timeout,
169+
error_type=error_type,
170+
policy=self._policy,
171+
)
172+
173+
174+
175+
def _redirect_trpc_agent_logs_to_stderr() -> None:
176+
logging.getLogger("trpc_agent_sdk").disabled = True
177+
178+
179+
def _load_container_runtime():
180+
try:
181+
from trpc_agent_sdk.code_executors.container import CommandArgs
182+
from trpc_agent_sdk.code_executors.container import ContainerClient
183+
from trpc_agent_sdk.code_executors.container import ContainerConfig
184+
except ModuleNotFoundError:
185+
repo_root = Path(__file__).resolve().parents[3]
186+
sys.path.insert(0, str(repo_root))
187+
from trpc_agent_sdk.code_executors.container import CommandArgs
188+
from trpc_agent_sdk.code_executors.container import ContainerClient
189+
from trpc_agent_sdk.code_executors.container import ContainerConfig
190+
return CommandArgs, ContainerClient, ContainerConfig
191+
192+
193+
def create_sandbox_runner(policy: SandboxPolicy, *, container_image: str = "python:3-slim") -> FakeSandboxRunner | ContainerSandboxRunner:
194+
"""Create a sandbox runner for the configured runtime."""
195+
if policy.runtime == "container":
196+
return ContainerSandboxRunner(policy, image=container_image)
197+
return FakeSandboxRunner(policy)
198+
199+
200+
def _build_run(
201+
request: SandboxRequest,
202+
*,
203+
runtime: str,
204+
started: float,
205+
exit_code: int | None,
206+
stdout: str = "",
207+
stderr: str = "",
208+
timed_out: bool = False,
209+
error_type: str | None = None,
210+
policy: SandboxPolicy,
211+
) -> SandboxRun:
212+
stdout_excerpt, stdout_truncated = _cap_output(redact_text(stdout), policy.max_output_bytes)
213+
stderr_excerpt, stderr_truncated = _cap_output(redact_text(stderr), policy.max_output_bytes)
214+
duration_ms = max(0, int((time.monotonic() - started) * 1000))
215+
return SandboxRun(
216+
id=f"sandbox-{request.script_name}",
217+
script_name=request.script_name,
218+
runtime=runtime,
219+
decision="allow",
220+
exit_code=exit_code,
221+
timed_out=timed_out,
222+
duration_ms=duration_ms,
223+
stdout_excerpt=stdout_excerpt,
224+
stderr_excerpt=stderr_excerpt,
225+
output_truncated=stdout_truncated or stderr_truncated,
226+
error_type=error_type,
227+
)
228+
229+
75230
def _cap_output(text: str, max_bytes: int) -> tuple[str, bool]:
76231
raw = text.encode("utf-8")
77232
if len(raw) <= max_bytes:

examples/code_review_agent/run_review.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@ def build_parser() -> argparse.ArgumentParser:
2929
parser.add_argument("--repo-path", type=Path, help="Path to a local git repository whose diff should be reviewed.")
3030
parser.add_argument("--base-ref", help="Optional base ref for repo-path mode, e.g. origin/main.")
3131
parser.add_argument("--fake-model", action="store_true", default=True, help="Use deterministic fake-model mode.")
32-
parser.add_argument("--sandbox-runtime", default="fake", choices=("fake", "container", "local-dev"), help="Sandbox runtime.")
32+
parser.add_argument("--sandbox-runtime", default="fake", choices=("fake", "container"), help="Sandbox runtime.")
33+
parser.add_argument("--container-image", default="python:3-slim", help="Docker image for --sandbox-runtime container.")
3334
parser.add_argument("--sandbox-timeout-seconds", type=int, default=10, help="Sandbox timeout budget.")
3435
parser.add_argument("--max-output-bytes", type=int, default=4096, help="Maximum sandbox output bytes to retain.")
3536
parser.add_argument("--db-path", type=Path, help="SQLite database path for persisted review results.")
@@ -64,10 +65,6 @@ def main(argv: list[str] | None = None) -> int:
6465
print("provide exactly one of --diff-file or --repo-path", file=sys.stderr)
6566
return 2
6667

67-
if args.sandbox_runtime != "fake":
68-
print("only --sandbox-runtime fake is implemented in this deterministic example", file=sys.stderr)
69-
return 2
70-
7168
try:
7269
bundle = load_review_input(diff_file=args.diff_file, repo_path=args.repo_path, base_ref=args.base_ref)
7370
except (FileNotFoundError, RuntimeError, ValueError) as exc:
@@ -84,7 +81,12 @@ def main(argv: list[str] | None = None) -> int:
8481
bundle.diff_text,
8582
parsed_diff=bundle.parsed_diff,
8683
review_input=bundle.review_input,
87-
config=ReviewRunConfig(fake_model=args.fake_model, sandbox_policy=policy, db_path=args.db_path),
84+
config=ReviewRunConfig(
85+
fake_model=args.fake_model,
86+
sandbox_policy=policy,
87+
db_path=args.db_path,
88+
container_image=args.container_image,
89+
),
8890
)
8991

9092
printed = False

examples/code_review_agent/skills/code-review/references/sandbox_policy.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,11 @@ The code-review Agent must treat diff content, generated scripts, and command ou
44

55
## Default runtime
66

7-
The deterministic example uses `fake` sandbox runtime by default. It records sandbox-shaped results without executing arbitrary host commands.
7+
The deterministic example uses `fake` sandbox runtime by default. It records sandbox-shaped results without executing arbitrary host commands. It also supports optional `container` runtime through the project's Docker `ContainerClient` for allowlisted scripts.
88

99
## Production runtime
1010

11-
Production implementations should use Container or Cube/E2B workspace runtimes. Local execution is only a development fallback and must require explicit opt-in.
11+
Production implementations should use Container or Cube/E2B workspace runtimes. This example implements the Container path for allowlisted scripts and documents Cube/E2B as a compatible extension point. Local execution is only a development fallback and must require explicit opt-in.
1212

1313
## Pre-execution governance
1414

0 commit comments

Comments
 (0)