Skip to content

Commit 33f3f8e

Browse files
committed
refactor(code-review-agent): run sandbox scripts via workspace runtime
1 parent d829835 commit 33f3f8e

11 files changed

Lines changed: 165 additions & 89 deletions

File tree

examples/skills_code_review_agent/DESIGN_NOTE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
本方案将自动代码评审拆为“主流程编排 + 可复用 Skill + 受控执行 + 结构化落库”四层。主流程由 `agent/agent.py` 负责,统一接收 diff、repo path 或 fixture,完成输入归一化、diff 解析、规则执行、Filter 决策、skill 脚本调度、报告生成和 SQLite 持久化。`skills/code-review/` 则承载正式的 `code-review` Skill,包括 `SKILL.md`、规则文档、使用文档、脚本契约与三个确定性脚本,用于承接可复用的评审知识与脚本执行面。
44

5-
沙箱隔离策略采用“显式本地开发回退 + 容器/远端接口预留”的实现方式。当前示例只有 `local` 会实际执行脚本,`container``cube``e2b` 等 runtime 在未接入真实隔离执行器前,都会先被 Filter 标记为 `needs_human_review`,避免把宿主机执行误报成生产安全沙箱。统一脚本执行层仍然提供 timeout、输出截断、失败记录和 Filter 前置治理,确保高风险脚本、禁止路径、默认网络访问和超预算输入不能直接进入执行链路。对脚本失败或超时,系统不会整体崩溃,而是转换为可追踪的 `sandbox_runs` 记录和结构化 finding。
5+
沙箱隔离策略采用“统一 workspace runtime 执行 + 本地回退 + 容器优先扩展”的实现方式。当前示例中的 `local` `container` 都通过同一套 workspace manager / filesystem / program runner 链路执行脚本,不再把 `container` 名义上的运行时回退到宿主 `subprocess`。其中 `local` 仍用于开发调试,`container` 在具备 Docker 环境时提供真实隔离;`cube``e2b` 等远端 runtime 仍保留后续接入点。统一脚本执行层继续提供 timeout、输出截断、失败记录和 Filter 前置治理,确保高风险脚本、禁止路径、默认网络访问和超预算输入不能直接进入执行链路。对脚本失败或超时,系统不会整体崩溃,而是转换为可追踪的 `sandbox_runs` 记录和结构化 finding。
66

77
数据库 schema 采用最小可查询设计,包含 `review_tasks``review_inputs``filter_decisions``sandbox_runs``findings``review_reports` 六张表,支持按 `task_id` 查询完整审查链路。报告输出同时生成 JSON 与 Markdown,两者都包含 findings 摘要、人工复核项、Filter 摘要、sandbox 摘要和监控指标。监控字段聚合总耗时、severity/category 分布、拦截次数和 sandbox 次数,便于回放和评测。
88

examples/skills_code_review_agent/README.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -87,10 +87,10 @@ python examples/skills_code_review_agent/run_agent.py ^
8787
--fake-model
8888
```
8989

90-
`local` is the only runtime that currently executes scripts in this example,
91-
and it should be treated as a development fallback. Requests for `container`,
92-
`cube`, or `e2b` are recorded by Filter as `needs_human_review` until a real
93-
isolated executor is wired in.
90+
`local` and `container` now share the same workspace-runtime execution path.
91+
Use `local` for development fallback and `container` when Docker-backed
92+
isolation is available. Additional remote runtimes such as `cube` and `e2b`
93+
still require future wiring in this example.
9494

9595
## Outputs
9696

@@ -171,7 +171,7 @@ Phase 6 quality-gate evidence:
171171

172172
- full suite passes locally
173173
- all 8 public fixtures generate both report artifacts
174-
- a measured security fixture dry-run completes in about `9.87s`
174+
- a measured local dry-run remains within the acceptance time budget
175175

176176
## Related Docs
177177

examples/skills_code_review_agent/agent/agent.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,12 @@ def run_review_task(config: ReviewAgentConfig) -> tuple[ReviewTask, ReviewReport
9292
):
9393
task.add_filter_decision(decision)
9494
if decision.decision.value == "allow":
95-
sandbox_run = execute_skill_script(invocation, runtime=config.runtime)
95+
sandbox_run = execute_skill_script(
96+
invocation,
97+
runtime=config.runtime,
98+
diff_text=task.review_input.diff_text,
99+
project_root=Path(__file__).resolve().parents[3],
100+
)
96101
task.add_sandbox_run(sandbox_run)
97102
all_findings.extend(_sandbox_run_findings(task, sandbox_run))
98103
all_findings.extend(_sandbox_output_findings(task, sandbox_run))

examples/skills_code_review_agent/agent/tools.py

Lines changed: 85 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,21 @@
22

33
from __future__ import annotations
44

5+
import asyncio
56
import json
67
import os
78
import shlex
8-
import subprocess
99
import sys
1010
from pathlib import Path
1111
from time import perf_counter
1212
from typing import Any
13+
from uuid import uuid4
1314

1415
from trpc_agent_sdk.code_executors import BaseWorkspaceRuntime
1516
from trpc_agent_sdk.code_executors import DEFAULT_SKILLS_CONTAINER
17+
from trpc_agent_sdk.code_executors import WorkspacePutFileInfo
18+
from trpc_agent_sdk.code_executors import WorkspaceRunProgramSpec
19+
from trpc_agent_sdk.code_executors import WorkspaceStageOptions
1620
from trpc_agent_sdk.code_executors import create_container_workspace_runtime
1721
from trpc_agent_sdk.code_executors import create_local_workspace_runtime
1822
from trpc_agent_sdk.skills import BaseSkillRepository
@@ -26,7 +30,8 @@
2630
SKILL_NAME = "code-review"
2731
SCRIPT_TIMEOUT_SECONDS = 20
2832
OUTPUT_LIMIT_CHARS = 4000
29-
HOST_EXECUTION_RUNTIME = "local"
33+
WORKSPACE_SKILL_DIR = f"skills/{SKILL_NAME}"
34+
WORKSPACE_DIFF_PATH = "work/inputs/review.diff"
3035

3136

3237
def create_skill_tool_set(
@@ -58,37 +63,38 @@ def build_skill_script_plan(
5863
"""Build the list of planned skill-script executions for the review."""
5964

6065
scripts_dir = resolve_code_review_skill_dir(project_root=project_root) / "scripts"
66+
_ = diff_file
6167
return [
6268
SkillScriptInvocation(
6369
name="parse_diff",
6470
script_path=scripts_dir / "parse_diff.py",
6571
command=[
66-
sys.executable,
67-
str(scripts_dir / "parse_diff.py"),
72+
"python",
73+
f"{WORKSPACE_SKILL_DIR}/scripts/parse_diff.py",
6874
"--diff-file",
69-
str(diff_file),
75+
WORKSPACE_DIFF_PATH,
7076
],
7177
target="skill:code-review/scripts/parse_diff.py",
7278
),
7379
SkillScriptInvocation(
7480
name="run_linters",
7581
script_path=scripts_dir / "run_linters.py",
7682
command=[
77-
sys.executable,
78-
str(scripts_dir / "run_linters.py"),
83+
"python",
84+
f"{WORKSPACE_SKILL_DIR}/scripts/run_linters.py",
7985
"--diff-file",
80-
str(diff_file),
86+
WORKSPACE_DIFF_PATH,
8187
],
8288
target="skill:code-review/scripts/run_linters.py",
8389
),
8490
SkillScriptInvocation(
8591
name="run_tests",
8692
script_path=scripts_dir / "run_tests.py",
8793
command=[
88-
sys.executable,
89-
str(scripts_dir / "run_tests.py"),
94+
"python",
95+
f"{WORKSPACE_SKILL_DIR}/scripts/run_tests.py",
9096
"--diff-file",
91-
str(diff_file),
97+
WORKSPACE_DIFF_PATH,
9298
],
9399
target="skill:code-review/scripts/run_tests.py",
94100
),
@@ -119,25 +125,23 @@ def execute_skill_script(
119125
invocation: SkillScriptInvocation,
120126
*,
121127
runtime: str,
128+
diff_text: str,
129+
project_root: Path | None = None,
122130
timeout_seconds: int = SCRIPT_TIMEOUT_SECONDS,
123131
output_limit_chars: int = OUTPUT_LIMIT_CHARS,
124132
) -> SandboxRunRecord:
125-
"""Execute a skill script in a controlled subprocess."""
126-
127-
if runtime != HOST_EXECUTION_RUNTIME:
128-
raise RuntimeError(
129-
f"Runtime `{runtime}` is not backed by a real isolated executor in this example."
130-
)
133+
"""Execute a skill script through the configured workspace runtime."""
131134

132135
started = perf_counter()
133136
try:
134-
completed = subprocess.run(
135-
invocation.command,
136-
capture_output=True,
137-
check=False,
138-
text=True,
139-
encoding="utf-8",
140-
timeout=timeout_seconds,
137+
completed = asyncio.run(
138+
_run_skill_script_in_workspace(
139+
invocation,
140+
runtime=runtime,
141+
diff_text=diff_text,
142+
project_root=project_root,
143+
timeout_seconds=timeout_seconds,
144+
)
141145
)
142146
stdout, stdout_truncated = _truncate_output(
143147
_normalize_process_output(completed.stdout),
@@ -147,9 +151,9 @@ def execute_skill_script(
147151
_normalize_process_output(completed.stderr),
148152
output_limit_chars,
149153
)
150-
status = (
154+
status = SandboxRunStatus.TIMED_OUT if completed.timed_out else (
151155
SandboxRunStatus.SUCCEEDED
152-
if completed.returncode == 0
156+
if completed.exit_code == 0
153157
else SandboxRunStatus.FAILED
154158
)
155159
return SandboxRunRecord(
@@ -158,33 +162,26 @@ def execute_skill_script(
158162
status=status,
159163
runtime=runtime,
160164
duration_ms=int((perf_counter() - started) * 1000),
161-
exit_code=completed.returncode,
165+
exit_code=completed.exit_code,
162166
stdout=_sanitize_output_text(stdout),
163167
stderr=_sanitize_output_text(stderr),
164-
timed_out=False,
168+
timed_out=completed.timed_out,
165169
output_truncated=stdout_truncated or stderr_truncated,
166170
blocked_by_filter=False,
167171
)
168-
except subprocess.TimeoutExpired as exc:
169-
stdout, stdout_truncated = _truncate_output(
170-
_normalize_process_output(exc.stdout),
171-
output_limit_chars,
172-
)
173-
stderr, stderr_truncated = _truncate_output(
174-
_normalize_process_output(exc.stderr),
175-
output_limit_chars,
176-
)
172+
except Exception as exc: # pylint: disable=broad-except
173+
stderr, stderr_truncated = _truncate_output(str(exc), output_limit_chars)
177174
return SandboxRunRecord(
178175
name=invocation.name,
179176
command=[_sanitize_display_value(part) for part in invocation.command],
180-
status=SandboxRunStatus.TIMED_OUT,
177+
status=SandboxRunStatus.FAILED,
181178
runtime=runtime,
182179
duration_ms=int((perf_counter() - started) * 1000),
183180
exit_code=None,
184-
stdout=_sanitize_output_text(stdout),
181+
stdout="",
185182
stderr=_sanitize_output_text(stderr),
186-
timed_out=True,
187-
output_truncated=stdout_truncated or stderr_truncated,
183+
timed_out=False,
184+
output_truncated=stderr_truncated,
188185
blocked_by_filter=False,
189186
)
190187

@@ -232,6 +229,53 @@ def _normalize_process_output(text: object) -> str:
232229
return str(text)
233230

234231

232+
async def _run_skill_script_in_workspace(
233+
invocation: SkillScriptInvocation,
234+
*,
235+
runtime: str,
236+
diff_text: str,
237+
project_root: Path | None,
238+
timeout_seconds: int,
239+
) -> Any:
240+
"""Stage skill inputs into a workspace and execute the script via runtime APIs."""
241+
242+
workspace_runtime = _create_workspace_runtime(workspace_runtime_type=runtime)
243+
manager = workspace_runtime.manager()
244+
fs = workspace_runtime.fs()
245+
runner = workspace_runtime.runner()
246+
exec_id = f"code_review_{invocation.name}_{uuid4().hex}"
247+
workspace = await manager.create_workspace(exec_id)
248+
249+
try:
250+
skill_dir = resolve_code_review_skill_dir(project_root=project_root)
251+
await fs.stage_directory(
252+
workspace,
253+
str(skill_dir),
254+
WORKSPACE_SKILL_DIR,
255+
WorkspaceStageOptions(read_only=True, allow_mount=True, mode="copy"),
256+
)
257+
await fs.put_files(
258+
workspace,
259+
[
260+
WorkspacePutFileInfo(
261+
path=WORKSPACE_DIFF_PATH,
262+
content=diff_text.encode("utf-8"),
263+
mode=0o644,
264+
)
265+
],
266+
)
267+
return await runner.run_program(
268+
workspace,
269+
WorkspaceRunProgramSpec(
270+
cmd=invocation.command[0],
271+
args=invocation.command[1:],
272+
timeout=timeout_seconds,
273+
),
274+
)
275+
finally:
276+
await manager.cleanup(exec_id)
277+
278+
235279
def _sanitize_output_text(text: str) -> str:
236280
"""Normalize sandbox output so reports stay portable across machines."""
237281

examples/skills_code_review_agent/sample_outputs/review_report.json

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"task_id": "9118ea7d-760d-4cf8-a383-7d14f298197e",
2+
"task_id": "f684c95b-c5a8-41cb-a55e-1eeec9233837",
33
"conclusion": "fail",
44
"findings": [
55
{
@@ -61,13 +61,13 @@
6161
"python",
6262
"skills/code-review/scripts/parse_diff.py",
6363
"--diff-file",
64-
"examples/skills_code_review_agent/sample_outputs/skill_inputs/9118ea7d-760d-4cf8-a383-7d14f298197e.diff"
64+
"work/inputs/review.diff"
6565
],
6666
"status": "succeeded",
6767
"runtime": "local",
68-
"duration_ms": 188,
68+
"duration_ms": 3404,
6969
"exit_code": 0,
70-
"stdout": "{\n \"diff_file\": \"examples/skills_code_review_agent/sample_outputs/skill_inputs/9118ea7d-760d-4cf8-a383-7d14f298197e.diff\",\n \"line_count\": 22,\n \"file_count\": 2,\n \"has_security_keywords\": true\n}\n",
70+
"stdout": "{\n \"diff_file\": \"review.diff\",\n \"line_count\": 22,\n \"file_count\": 2,\n \"has_security_keywords\": true\n}\n",
7171
"stderr": "",
7272
"timed_out": false,
7373
"output_truncated": false,
@@ -79,13 +79,13 @@
7979
"python",
8080
"skills/code-review/scripts/run_linters.py",
8181
"--diff-file",
82-
"examples/skills_code_review_agent/sample_outputs/skill_inputs/9118ea7d-760d-4cf8-a383-7d14f298197e.diff"
82+
"work/inputs/review.diff"
8383
],
8484
"status": "succeeded",
8585
"runtime": "local",
86-
"duration_ms": 182,
86+
"duration_ms": 3139,
8787
"exit_code": 0,
88-
"stdout": "{\n \"diff_file\": \"examples/skills_code_review_agent/sample_outputs/skill_inputs/9118ea7d-760d-4cf8-a383-7d14f298197e.diff\",\n \"warning_count\": 2,\n \"warnings\": [\n \"Security-sensitive call detected: eval\",\n \"Shell execution enabled in subprocess call\"\n ]\n}\n",
88+
"stdout": "{\n \"diff_file\": \"review.diff\",\n \"warning_count\": 2,\n \"warnings\": [\n \"Security-sensitive call detected: eval\",\n \"Shell execution enabled in subprocess call\"\n ]\n}\n",
8989
"stderr": "",
9090
"timed_out": false,
9191
"output_truncated": false,
@@ -97,13 +97,13 @@
9797
"python",
9898
"skills/code-review/scripts/run_tests.py",
9999
"--diff-file",
100-
"examples/skills_code_review_agent/sample_outputs/skill_inputs/9118ea7d-760d-4cf8-a383-7d14f298197e.diff"
100+
"work/inputs/review.diff"
101101
],
102102
"status": "succeeded",
103103
"runtime": "local",
104-
"duration_ms": 218,
104+
"duration_ms": 4462,
105105
"exit_code": 0,
106-
"stdout": "{\n \"diff_file\": \"examples/skills_code_review_agent/sample_outputs/skill_inputs/9118ea7d-760d-4cf8-a383-7d14f298197e.diff\",\n \"changed_test_files\": [\n \"tests/test_calculator.py\"\n ],\n \"test_update_present\": true\n}\n",
106+
"stdout": "{\n \"diff_file\": \"review.diff\",\n \"changed_test_files\": [\n \"tests/test_calculator.py\"\n ],\n \"test_update_present\": true\n}\n",
107107
"stderr": "",
108108
"timed_out": false,
109109
"output_truncated": false,
@@ -115,7 +115,7 @@
115115
"high": 2
116116
},
117117
"monitoring_summary": {
118-
"total_duration_ms": 604,
118+
"total_duration_ms": 11032,
119119
"changed_files_count": 2,
120120
"added_lines_count": 6,
121121
"deleted_lines_count": 1,

examples/skills_code_review_agent/sample_outputs/review_report.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Review Report
22

3-
- Task ID: `9118ea7d-760d-4cf8-a383-7d14f298197e`
3+
- Task ID: `f684c95b-c5a8-41cb-a55e-1eeec9233837`
44
- Final Verdict: `fail`
55

66
## Summary
@@ -36,9 +36,9 @@ Loaded review input, parsed diff, completed deterministic rule review, and proce
3636

3737
## Sandbox Summary
3838

39-
- `parse_diff` status=`succeeded` duration=188ms exit_code=0
40-
- `run_linters` status=`succeeded` duration=182ms exit_code=0
41-
- `run_tests` status=`succeeded` duration=218ms exit_code=0
39+
- `parse_diff` status=`succeeded` duration=3404ms exit_code=0
40+
- `run_linters` status=`succeeded` duration=3139ms exit_code=0
41+
- `run_tests` status=`succeeded` duration=4462ms exit_code=0
4242

4343
## Monitoring
4444

@@ -52,7 +52,7 @@ Loaded review input, parsed diff, completed deterministic rule review, and proce
5252
- `needs_human_review_count`: 0
5353
- `sandbox_run_count`: 3
5454
- `severity_distribution`: {'high': 2}
55-
- `total_duration_ms`: 604
55+
- `total_duration_ms`: 11032
5656
- `warning_count`: 0
5757

5858
## Actionable Recommendations

examples/skills_code_review_agent/src/filter_policy.py

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
_FORBIDDEN_PATH_PARTS = (".git/", ".env", "secrets/", "id_rsa", ".pem")
1111
_NETWORK_TOKENS = ("http://", "https://", "curl", "wget", "Invoke-WebRequest", "requests.get(")
1212
_DANGEROUS_TOKENS = ("rm -rf", "del /f", "format ", "shutdown ", "mkfs")
13-
_HOST_EXECUTION_RUNTIME = "local"
1413

1514

1615
@dataclass(slots=True, frozen=True)
@@ -76,19 +75,6 @@ def evaluate_invocations(
7675
reason="Diff size exceeds sandbox budget and requires manual approval.",
7776
requires_human_review=True,
7877
)
79-
elif runtime != _HOST_EXECUTION_RUNTIME:
80-
decision = FilterDecisionRecord(
81-
decision=FilterDecisionType.NEEDS_HUMAN_REVIEW,
82-
target=invocation.target,
83-
reason_code="runtime_not_isolated",
84-
reason=(
85-
f"Runtime `{runtime}` is requested, but this example does not yet "
86-
"back it with a real isolated executor. Block host execution until "
87-
"container or remote sandbox support is implemented."
88-
),
89-
requires_human_review=True,
90-
)
91-
9278
paired.append((invocation, decision))
9379
return paired
9480

0 commit comments

Comments
 (0)