From 37c54bb1424a6121181ea6fbd1915671b48ee655 Mon Sep 17 00:00:00 2001 From: FeatherCheung Date: Tue, 28 Jul 2026 20:44:08 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=E5=9F=BA=E4=BA=8E=20Skills=20+=20?= =?UTF-8?q?=E6=B2=99=E7=AE=B1=20+=20=E6=95=B0=E6=8D=AE=E5=BA=93=E5=AD=98?= =?UTF-8?q?=E5=82=A8=E6=9E=84=E5=BB=BA=E8=87=AA=E5=8A=A8=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E8=AF=84=E5=AE=A1=20Agent(#92)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现基于 Skill 的确定性代码评审规则 - 增加沙箱任务规划与隔离执行 - 接入 Filter 策略、敏感信息脱敏和资源限制 - 持久化 Finding、监控指标和执行审计记录 - 支持 Diff、Git 仓库、Fixture 和文件列表输入 - 补充结构化报告、测试样例和中文文档 --- examples/skills_code_review_agent/README.md | 186 +++++++ examples/skills_code_review_agent/__init__.py | 1 + .../agent/__init__.py | 6 + .../skills_code_review_agent/agent/agent.py | 31 ++ .../agent/input_parser.py | 217 ++++++++ .../skills_code_review_agent/agent/metrics.py | 39 ++ .../skills_code_review_agent/agent/models.py | 144 +++++ .../skills_code_review_agent/agent/policy.py | 49 ++ .../agent/reporting.py | 124 +++++ .../skills_code_review_agent/agent/review.py | 100 ++++ .../skills_code_review_agent/agent/sandbox.py | 101 ++++ .../agent/sanitizer.py | 101 ++++ .../skills_code_review_agent/agent/storage.py | 382 +++++++++++++ .../agent/task_planner.py | 62 +++ .../fixtures/async_leak.diff | 5 + .../fixtures/clean.diff | 6 + .../fixtures/database_leak.diff | 5 + .../fixtures/duplicate.diff | 5 + .../fixtures/missing_tests.diff | 6 + .../fixtures/sandbox_failure.diff | 5 + .../fixtures/security.diff | 5 + .../fixtures/sensitive.diff | 5 + .../fixtures/unsafe.diff | 7 + .../governance/__init__.py | 5 + .../governance/analyzer/__init__.py | 15 + .../governance/analyzer/command_analyzer.py | 49 ++ .../analyzer/environment_analyzer.py | 17 + .../governance/analyzer/network_analyzer.py | 17 + .../governance/analyzer/path_analyzer.py | 37 ++ .../governance/analyzer/resource_analyzer.py | 27 + .../governance/filter_engine.py | 73 +++ .../governance/models/__init__.py | 5 + .../governance/models/decision.py | 20 + .../governance/policy/__init__.py | 5 + .../governance/policy/default_policy.yaml | 27 + .../governance/policy/loader.py | 24 + .../skills_code_review_agent/run_agent.py | 116 ++++ .../example-security/filter_events.json | 11 + .../example-security/review_report.json | 99 ++++ .../example-security/review_report.md | 48 ++ .../sandbox/__init__.py | 5 + .../sandbox/executor/__init__.py | 6 + .../sandbox/executor/base.py | 17 + .../sandbox/executor/workspace_executor.py | 58 ++ .../sandbox/models/__init__.py | 5 + .../sandbox/models/execution_result.py | 39 ++ .../sandbox/policy/__init__.py | 5 + .../sandbox/policy/resource_limit.py | 13 + .../sandbox/runner.py | 38 ++ .../sandbox/runtime/__init__.py | 5 + .../sandbox/runtime/workspace.py | 69 +++ .../sandbox/tasks/__init__.py | 13 + .../sandbox/tasks/custom_rule.py | 22 + .../sandbox/tasks/diff_parser.py | 22 + .../sandbox/tasks/run_test.py | 16 + .../sandbox/tasks/static_check.py | 16 + examples/skills_code_review_agent/schema.sql | 57 ++ .../skills/code-review/README.md | 5 + .../skills/code-review/SKILL.md | 26 + .../skills/code-review/detectors/__init__.py | 6 + .../code-review/detectors/ast_detector.py | 68 +++ .../skills/code-review/detectors/base.py | 15 + .../code-review/detectors/regex_detector.py | 41 ++ .../detectors/semantic_detector.py | 15 + .../skills/code-review/models/__init__.py | 5 + .../skills/code-review/models/finding.py | 24 + .../skills/code-review/parser/__init__.py | 5 + .../skills/code-review/parser/diff_parser.py | 74 +++ .../skills/code-review/references/rules.md | 10 + .../skills/code-review/rules/async.yaml | 11 + .../skills/code-review/rules/database.yaml | 14 + .../skills/code-review/rules/security.yaml | 43 ++ .../skills/code-review/rules/testing.yaml | 15 + .../skills/code-review/runner.py | 86 +++ .../skills/code-review/scripts/parse_diff.py | 8 + .../skills/code-review/scripts/scan_rules.py | 10 + .../skills/code-review/validators/__init__.py | 4 + .../code-review/validators/resource_check.py | 6 + .../code-review/validators/run_tests.py | 6 + .../code-review/validators/security_probe.py | 6 + tests/code_review/test_review_agent.py | 503 ++++++++++++++++++ 81 files changed, 3599 insertions(+) create mode 100644 examples/skills_code_review_agent/README.md create mode 100644 examples/skills_code_review_agent/__init__.py create mode 100644 examples/skills_code_review_agent/agent/__init__.py create mode 100644 examples/skills_code_review_agent/agent/agent.py create mode 100644 examples/skills_code_review_agent/agent/input_parser.py create mode 100644 examples/skills_code_review_agent/agent/metrics.py create mode 100644 examples/skills_code_review_agent/agent/models.py create mode 100644 examples/skills_code_review_agent/agent/policy.py create mode 100644 examples/skills_code_review_agent/agent/reporting.py create mode 100644 examples/skills_code_review_agent/agent/review.py create mode 100644 examples/skills_code_review_agent/agent/sandbox.py create mode 100644 examples/skills_code_review_agent/agent/sanitizer.py create mode 100644 examples/skills_code_review_agent/agent/storage.py create mode 100644 examples/skills_code_review_agent/agent/task_planner.py create mode 100644 examples/skills_code_review_agent/fixtures/async_leak.diff create mode 100644 examples/skills_code_review_agent/fixtures/clean.diff create mode 100644 examples/skills_code_review_agent/fixtures/database_leak.diff create mode 100644 examples/skills_code_review_agent/fixtures/duplicate.diff create mode 100644 examples/skills_code_review_agent/fixtures/missing_tests.diff create mode 100644 examples/skills_code_review_agent/fixtures/sandbox_failure.diff create mode 100644 examples/skills_code_review_agent/fixtures/security.diff create mode 100644 examples/skills_code_review_agent/fixtures/sensitive.diff create mode 100644 examples/skills_code_review_agent/fixtures/unsafe.diff create mode 100644 examples/skills_code_review_agent/governance/__init__.py create mode 100644 examples/skills_code_review_agent/governance/analyzer/__init__.py create mode 100644 examples/skills_code_review_agent/governance/analyzer/command_analyzer.py create mode 100644 examples/skills_code_review_agent/governance/analyzer/environment_analyzer.py create mode 100644 examples/skills_code_review_agent/governance/analyzer/network_analyzer.py create mode 100644 examples/skills_code_review_agent/governance/analyzer/path_analyzer.py create mode 100644 examples/skills_code_review_agent/governance/analyzer/resource_analyzer.py create mode 100644 examples/skills_code_review_agent/governance/filter_engine.py create mode 100644 examples/skills_code_review_agent/governance/models/__init__.py create mode 100644 examples/skills_code_review_agent/governance/models/decision.py create mode 100644 examples/skills_code_review_agent/governance/policy/__init__.py create mode 100644 examples/skills_code_review_agent/governance/policy/default_policy.yaml create mode 100644 examples/skills_code_review_agent/governance/policy/loader.py create mode 100644 examples/skills_code_review_agent/run_agent.py create mode 100644 examples/skills_code_review_agent/sample_output/example-security/filter_events.json create mode 100644 examples/skills_code_review_agent/sample_output/example-security/review_report.json create mode 100644 examples/skills_code_review_agent/sample_output/example-security/review_report.md create mode 100644 examples/skills_code_review_agent/sandbox/__init__.py create mode 100644 examples/skills_code_review_agent/sandbox/executor/__init__.py create mode 100644 examples/skills_code_review_agent/sandbox/executor/base.py create mode 100644 examples/skills_code_review_agent/sandbox/executor/workspace_executor.py create mode 100644 examples/skills_code_review_agent/sandbox/models/__init__.py create mode 100644 examples/skills_code_review_agent/sandbox/models/execution_result.py create mode 100644 examples/skills_code_review_agent/sandbox/policy/__init__.py create mode 100644 examples/skills_code_review_agent/sandbox/policy/resource_limit.py create mode 100644 examples/skills_code_review_agent/sandbox/runner.py create mode 100644 examples/skills_code_review_agent/sandbox/runtime/__init__.py create mode 100644 examples/skills_code_review_agent/sandbox/runtime/workspace.py create mode 100644 examples/skills_code_review_agent/sandbox/tasks/__init__.py create mode 100644 examples/skills_code_review_agent/sandbox/tasks/custom_rule.py create mode 100644 examples/skills_code_review_agent/sandbox/tasks/diff_parser.py create mode 100644 examples/skills_code_review_agent/sandbox/tasks/run_test.py create mode 100644 examples/skills_code_review_agent/sandbox/tasks/static_check.py create mode 100644 examples/skills_code_review_agent/schema.sql create mode 100644 examples/skills_code_review_agent/skills/code-review/README.md create mode 100644 examples/skills_code_review_agent/skills/code-review/SKILL.md create mode 100644 examples/skills_code_review_agent/skills/code-review/detectors/__init__.py create mode 100644 examples/skills_code_review_agent/skills/code-review/detectors/ast_detector.py create mode 100644 examples/skills_code_review_agent/skills/code-review/detectors/base.py create mode 100644 examples/skills_code_review_agent/skills/code-review/detectors/regex_detector.py create mode 100644 examples/skills_code_review_agent/skills/code-review/detectors/semantic_detector.py create mode 100644 examples/skills_code_review_agent/skills/code-review/models/__init__.py create mode 100644 examples/skills_code_review_agent/skills/code-review/models/finding.py create mode 100644 examples/skills_code_review_agent/skills/code-review/parser/__init__.py create mode 100644 examples/skills_code_review_agent/skills/code-review/parser/diff_parser.py create mode 100644 examples/skills_code_review_agent/skills/code-review/references/rules.md create mode 100644 examples/skills_code_review_agent/skills/code-review/rules/async.yaml create mode 100644 examples/skills_code_review_agent/skills/code-review/rules/database.yaml create mode 100644 examples/skills_code_review_agent/skills/code-review/rules/security.yaml create mode 100644 examples/skills_code_review_agent/skills/code-review/rules/testing.yaml create mode 100644 examples/skills_code_review_agent/skills/code-review/runner.py create mode 100644 examples/skills_code_review_agent/skills/code-review/scripts/parse_diff.py create mode 100644 examples/skills_code_review_agent/skills/code-review/scripts/scan_rules.py create mode 100644 examples/skills_code_review_agent/skills/code-review/validators/__init__.py create mode 100644 examples/skills_code_review_agent/skills/code-review/validators/resource_check.py create mode 100644 examples/skills_code_review_agent/skills/code-review/validators/run_tests.py create mode 100644 examples/skills_code_review_agent/skills/code-review/validators/security_probe.py create mode 100644 tests/code_review/test_review_agent.py diff --git a/examples/skills_code_review_agent/README.md b/examples/skills_code_review_agent/README.md new file mode 100644 index 000000000..c91aa3cd6 --- /dev/null +++ b/examples/skills_code_review_agent/README.md @@ -0,0 +1,186 @@ +# 自动代码评审 Agent + +本示例实现了一个可运行、可审计的自动代码评审 Agent。它读取 unified diff、Git 工作区、本地文件列表或测试 fixture,通过 Code Review Skill 执行确定性规则,并按需运行静态检查和单元测试。所有命令必须先经过 Filter,获准后才能进入隔离 workspace。最终结果会经过行号校验、敏感信息脱敏和去重,写入 JSON、Markdown 与数据库。 + +## 整体流程 + +```text +Diff / Git 工作区 / 文件列表 / Fixture + → 输入规范化与摘要计算 + → 评审任务规划 + → Filter 前置决策 + → Container / Cube 沙箱执行 + → Finding 校验、脱敏与去重 + → JSON / Markdown 报告 + → SQLite / SQL 审计存储 +``` + +单项检查超时、退出异常或沙箱启动失败时,评审任务会进入 `partial`,保留已经完成的检查和发现,不会因一个步骤失败而丢失整条审查记录。 + +## Skill 设计 + +Code Review Skill 位于 `skills/code-review/`,与 Agent 编排、Filter 和数据库层保持解耦: + +- `SKILL.md`:声明能力、输入输出和安全边界。 +- `rules/`:保存带稳定 `rule_id` 的 YAML 规则。 +- `references/rules.md`:解释规则依据、误报边界和修复建议。 +- `detectors/`:提供正则和 Python AST 检测器。 +- `parser/`:解析 diff,并将检查范围限制在候选新增行。 +- `scripts/`:提供可由沙箱直接调用的入口。 +- `validators/`:保存候选问题的后续验证脚本。 +- `runner.py`:加载规则、分派 detector 并输出结构化 Finding。 + +当前规则覆盖危险 shell 与动态执行、硬编码凭据、未跟踪异步任务、文件或数据库连接生命周期、测试缺失等类别。每个 Finding 至少包含: + +```text +severity, category, file, line, title, evidence, +recommendation, confidence, source +``` + +规则还会携带版本和验证状态,便于定位产生结论的准确规则版本。 + +## 沙箱隔离策略 + +生产默认使用 SDK Container workspace runtime,并关闭网络;也可以接入 Cube runtime。`local` runtime 只用于开发回退,不是默认生产方案。 + +```text +agent/review.py + → agent/task_planner.py 规划规则、静态检查和测试 + → agent/sandbox.py 装载源码和 Skill + → sandbox/runner.py 执行 Filter 并调度任务 + → sandbox/executor/ 调用统一 Workspace Runtime + → Container / Cube +``` + +项目快照会排除 `.git`、`.venv`、`node_modules`、缓存、字节码和符号链接,并限制单文件及快照总大小。执行任务配置超时、内存、CPU、PID 和输出长度限制,执行后始终尝试清理 workspace。容器网络默认关闭,运行时不会联网安装依赖,也不会装载 Docker socket、宿主机凭据或 workspace 外路径。 + +## Filter 策略 + +Filter 是所有沙箱任务的前置关卡,并采用 fail-closed 策略: + +```text +ExecutionRequest + → 命令检查 + → 路径检查 + → 网络检查 + → 环境变量检查 + → 资源预算检查 + → allow / deny / needs_human_review +``` + +Filter 会拒绝非白名单命令、危险 shell 组合、受保护路径和非白名单网络目标;内联代码或超出资源阈值的任务进入人工复核。只有 `allow` 可以进入执行器,`deny` 和 `needs_human_review` 都会停在沙箱之外。每次决策都会记录风险等级、命中规则、原因、命令摘要和时间,并同时进入报告、数据库及 `filter_events.json`。 + +Filter 是应用层防线,不能代替 Container/Cube 提供的操作系统级隔离。 + +## 去重与降噪 + +所有候选 Finding 都会经过统一归一化流程: + +1. 校验严重级别、类别、文件和候选新增行号。 +2. 对标题、证据和修复建议进行敏感信息脱敏。 +3. 按“文件、行号、类别”生成稳定指纹并去重。 +4. 重复结果优先保留置信度更高、证据更完整的一项。 +5. 低置信度或位置无效的问题进入 `needs_human_review`,不混入已确认 Finding。 + +该流程同时适用于规则、模型或其他分析器输出,防止不同来源重复报告同一问题。 + +## 安全边界 + +系统不会将 diff 内容拼接进 shell 命令,也不会向任务透传宿主机环境。环境变量采用白名单,网络采用默认拒绝;命令、stdout、stderr、Finding、warning 和最终报告在持久化前统一脱敏。 + +当前脱敏覆盖常见 API Key、OpenAI/GitHub Token、Bearer Token、JWT、AWS Access Key,以及 PostgreSQL、MySQL、MariaDB、MongoDB URL 中的密码。输出会截断到任务配置的最大长度,超时或执行异常会记录错误类型,但不会让整个评审流程崩溃。 + +## 监控字段 + +每次评审都会收集: + +- 总耗时与各阶段耗时; +- 工具调用次数; +- Filter 拦截次数; +- Finding 总数; +- 各严重级别分布; +- 异常类型分布; +- 每个沙箱任务的状态、退出码、耗时和截断标识。 + +这些指标会写入报告和 `telemetry` 表,用于评测、监控、风险趋势分析和故障回放。 + +## 数据库 Schema + +默认存储是 SQLite,运行时通过 SQLAlchemy 操作,接口可切换到 PostgreSQL 等 SQL 后端。SQLite 会显式启用外键,并为任务状态、创建时间、Finding 的 task id 和严重级别建立索引。 + +```text +review_task +├── skill_execution +├── sandbox_run +│ └── filter_event +├── finding +├── review_report +└── telemetry +``` + +- `review_task`:输入类型、摘要、digest、状态和起止时间。 +- `skill_execution`:Skill、规则版本、detector 和执行摘要。 +- `sandbox_run`:runtime、命令摘要、状态、日志、退出码和耗时。 +- `filter_event`:Filter 决策、风险、原因和命中规则。 +- `finding`:问题位置、证据、建议、置信度、规则版本和验证状态。 +- `review_report`:最终 JSON、Markdown 与结论摘要。 +- `telemetry`:耗时、调用、拦截、Finding 和异常分布。 + +可通过 `ReviewRepository.get_task(task_id)` 查询一次评审的完整执行轨迹。 + +## 输入与输出 + +CLI 支持以下输入方式: + +- `--diff-file`:unified diff 或 PR patch 文件; +- `--repo-path`:Git 仓库相对于 `HEAD` 的未提交变更; +- `--fixture`:测试 fixture 文件或目录; +- `--file-list`:逐行保存项目相对文件路径的清单。 + +报告按 task id 分目录保存,避免覆盖历史结果: + +```text +result// +├── review_report.json +├── review_report.md +└── filter_events.json +``` + +默认数据库为 `examples/skills_code_review_agent/result/review.db`。可以通过 `--db-url` 和 `--output-dir` 覆盖默认位置。 + +仓库输入示例: + +```bash +python3 examples/skills_code_review_agent/run_agent.py \ + --repo-path . \ + --runtime container \ + --deterministic-only +``` + +diff fixture 与本地开发回退示例: + +```bash +python3 examples/skills_code_review_agent/run_agent.py \ + --fixture examples/skills_code_review_agent/fixtures/security.diff \ + --runtime local \ + --fake-model +``` + +文件列表输入示例: + +```bash +python3 examples/skills_code_review_agent/run_agent.py \ + --file-list ./review-files.txt \ + --runtime container \ + --deterministic-only +``` + +`--dry-run` 会完成输入解析、任务规划和 Filter 决策,但不会执行任何沙箱命令。报告会明确标注未执行检查,不会把“没有 Finding”描述成“代码没有问题”。 + +## 测试 + +```bash +PYTHONPATH=. pytest -q tests/code_review +``` + +测试覆盖无问题 diff、安全问题、异步资源泄漏、数据库连接生命周期、测试缺失、Finding 去重、沙箱失败、敏感信息脱敏、dry-run、任务规划、文件列表输入和 Filter 拦截等场景。 diff --git a/examples/skills_code_review_agent/__init__.py b/examples/skills_code_review_agent/__init__.py new file mode 100644 index 000000000..2d6112496 --- /dev/null +++ b/examples/skills_code_review_agent/__init__.py @@ -0,0 +1 @@ +"""A policy-governed automatic code review example.""" diff --git a/examples/skills_code_review_agent/agent/__init__.py b/examples/skills_code_review_agent/agent/__init__.py new file mode 100644 index 000000000..b2e68c6e2 --- /dev/null +++ b/examples/skills_code_review_agent/agent/__init__.py @@ -0,0 +1,6 @@ +"""Application modules for the code review example.""" + +from .input_parser import parse_review_input +from .review import run_review + +__all__ = ["parse_review_input", "run_review"] diff --git a/examples/skills_code_review_agent/agent/agent.py b/examples/skills_code_review_agent/agent/agent.py new file mode 100644 index 000000000..baefc5119 --- /dev/null +++ b/examples/skills_code_review_agent/agent/agent.py @@ -0,0 +1,31 @@ +"""Optional LLM agent assembly.""" + +from __future__ import annotations + +from typing import Any + +from trpc_agent_sdk.agents import LlmAgent +from trpc_agent_sdk.skills import SkillToolSet, create_default_skill_repository + + +def create_review_agent(runtime: Any, model: Any, review_filter: Any, *, production: bool = True) -> LlmAgent: + if runtime is None: + raise ValueError("a workspace runtime is required") + if model is None: + raise ValueError("a model is required; use --fake-model for deterministic review") + runtime_name = type(runtime).__name__.lower() + if production and "local" in runtime_name: + raise ValueError("local runtime is an explicit development fallback and is not allowed in production") + skills_path = str(__import__("pathlib").Path(__file__).parents[1] / "skills") + skill_repository = create_default_skill_repository(skills_path, workspace_runtime=runtime) + toolset = SkillToolSet(repository=skill_repository) + return LlmAgent( + name="code_review_agent", + description="Policy-governed automatic code reviewer.", + instruction=( + "Review only added lines. Use the code-review skill, cite concrete evidence, " + "and never execute a command that the review policy has not allowed." + ), + model=model, + tools=[toolset], + ) diff --git a/examples/skills_code_review_agent/agent/input_parser.py b/examples/skills_code_review_agent/agent/input_parser.py new file mode 100644 index 000000000..586f070cd --- /dev/null +++ b/examples/skills_code_review_agent/agent/input_parser.py @@ -0,0 +1,217 @@ +"""Normalize a diff, git worktree, or fixture into a ReviewInput.""" + +from __future__ import annotations + +import hashlib +import re +import subprocess +from pathlib import Path + +from .models import ChangedFile, ChangedLine, ReviewHunk, ReviewInput + +_HUNK_RE = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@") + + +def _safe_path(path: Path, roots: list[Path] | None) -> Path: + resolved = path.expanduser().resolve() + if roots and not any(resolved == root.resolve() or resolved.is_relative_to(root.resolve()) for root in roots): + raise ValueError(f"path is outside the allowed roots: {path}") + return resolved + + +def _read_text(path: Path, max_input_bytes: int) -> str: + data = path.read_bytes() + if len(data) > max_input_bytes: + raise ValueError(f"review input exceeds {max_input_bytes} bytes") + if b"\0" in data: + raise ValueError("binary review input is not supported") + return data.decode("utf-8") + + +def _clean_diff_path(raw: str) -> str: + value = raw.split("\t", 1)[0].strip() + if value == "/dev/null": + return value + if value.startswith(("a/", "b/")): + value = value[2:] + candidate = Path(value) + if candidate.is_absolute() or ".." in candidate.parts: + raise ValueError(f"unsafe path in diff: {raw}") + return candidate.as_posix() + + +def _parse_diff(text: str) -> list[ChangedFile]: + if "GIT binary patch" in text or re.search(r"(?m)^Binary files .+ differ$", text): + raise ValueError("binary diff is not supported") + files: list[ChangedFile] = [] + current: ChangedFile | None = None + hunk: ReviewHunk | None = None + new_line = 0 + lines = text.splitlines() + for raw in lines: + if raw.startswith("diff --git "): + parts = raw.split() + if len(parts) < 4: + raise ValueError("malformed diff header") + old_path, new_path = _clean_diff_path(parts[2]), _clean_diff_path(parts[3]) + current = ChangedFile(path=new_path, old_path=old_path) + files.append(current) + hunk = None + elif raw.startswith("--- "): + old_path = _clean_diff_path(raw[4:]) + if current is None: + current = ChangedFile(path="", old_path=old_path) + files.append(current) + current.old_path = None if old_path == "/dev/null" else old_path + current.is_new = old_path == "/dev/null" + elif raw.startswith("+++ "): + new_path = _clean_diff_path(raw[4:]) + if current is None: + current = ChangedFile(path=new_path) + files.append(current) + current.is_deleted = new_path == "/dev/null" + if not current.is_deleted: + current.path = new_path + elif raw.startswith("@@ "): + if current is None: + raise ValueError("hunk encountered before file header") + match = _HUNK_RE.match(raw) + if not match: + raise ValueError(f"malformed hunk header: {raw}") + old_start, old_count, new_start, new_count = ( + int(match.group(1)), + int(match.group(2) or 1), + int(match.group(3)), + int(match.group(4) or 1), + ) + hunk = ReviewHunk( + old_start=old_start, + old_count=old_count, + new_start=new_start, + new_count=new_count, + header=raw, + ) + current.hunks.append(hunk) + new_line = new_start + elif hunk is not None: + hunk.lines.append(raw) + if raw.startswith("+") and not raw.startswith("+++"): + current.candidate_lines.append(ChangedLine(number=new_line, content=raw[1:])) + new_line += 1 + elif raw.startswith("-") and not raw.startswith("---"): + continue + elif not raw.startswith("\\"): + new_line += 1 + return [item for item in files if item.path and item.path != "/dev/null"] + + +def parse_review_input( + *, + diff_file: str | Path | None = None, + repo_path: str | Path | None = None, + fixture_path: str | Path | None = None, + file_list: str | Path | None = None, + max_input_bytes: int = 2_000_000, + allowed_roots: list[str | Path] | None = None, +) -> ReviewInput: + """Parse exactly one input source. Empty diffs are valid.""" + supplied = [ + diff_file is not None, + repo_path is not None, + fixture_path is not None, + file_list is not None, + ] + if sum(supplied) != 1: + raise ValueError( + "exactly one of diff_file, repo_path, fixture_path, or file_list is required" + ) + roots = [Path(item) for item in allowed_roots] if allowed_roots else None + source_type: str + source: Path + if file_list is not None: + source_type, source = "file_list", _safe_path(Path(file_list), roots) + if not source.is_file(): + raise ValueError(f"file list is not a file: {source}") + manifest = _read_text(source, max_input_bytes) + project_root = source.parent.resolve() + files: list[ChangedFile] = [] + total = len(manifest.encode()) + digest_builder = hashlib.sha256(manifest.encode()) + for raw in manifest.splitlines(): + value = raw.strip() + if not value or value.startswith("#"): + continue + relative = Path(value) + if relative.is_absolute() or ".." in relative.parts: + raise ValueError(f"unsafe path in file list: {value}") + path = (project_root / relative).resolve() + if not path.is_relative_to(project_root) or path.is_symlink(): + raise ValueError(f"unsafe path in file list: {value}") + content = _read_text(path, max_input_bytes) + total += len(content.encode()) + if total > max_input_bytes: + raise ValueError(f"review input exceeds {max_input_bytes} bytes") + digest_builder.update(relative.as_posix().encode()) + digest_builder.update(b"\0") + digest_builder.update(content.encode()) + changed = ChangedFile(path=relative.as_posix(), is_new=True) + changed.candidate_lines = [ + ChangedLine(number=index, content=line) + for index, line in enumerate(content.splitlines(), 1) + ] + files.append(changed) + candidates = { + item.path: [line.number for line in item.candidate_lines] for item in files + } + changed_lines = sum(len(item.candidate_lines) for item in files) + return ReviewInput( + files=files, + context="", + candidate_lines=candidates, + digest=digest_builder.hexdigest(), + summary=f"{len(files)} listed file(s), {changed_lines} candidate line(s)", + source_type=source_type, + source_path=str(project_root), + ) + if repo_path is not None: + source_type, source = "repo", _safe_path(Path(repo_path), roots) + if not source.is_dir(): + raise ValueError(f"repository path is not a directory: {source}") + result = subprocess.run( + ["git", "-C", str(source), "diff", "--no-ext-diff", "--binary", "HEAD"], + check=False, + capture_output=True, + timeout=30, + ) + if result.returncode != 0: + raise ValueError(result.stderr.decode("utf-8", "replace").strip() or "unable to read git diff") + if len(result.stdout) > max_input_bytes: + raise ValueError(f"review input exceeds {max_input_bytes} bytes") + if b"\0" in result.stdout: + raise ValueError("binary diff is not supported") + text = result.stdout.decode("utf-8") + else: + source_type = "fixture" if fixture_path is not None else "diff" + source = _safe_path(Path(fixture_path or diff_file), roots) + if source.is_dir() and fixture_path is not None: + candidates = [source / "change.diff", source / "input.diff"] + source = next((item for item in candidates if item.is_file()), source) + if not source.is_file(): + raise ValueError(f"review input is not a file: {source}") + text = _read_text(source, max_input_bytes) + files = _parse_diff(text) + if text.strip() and not files: + raise ValueError("review input is not a valid unified diff") + digest = hashlib.sha256(text.encode()).hexdigest() + candidates = {item.path: [line.number for line in item.candidate_lines] for item in files} + changed_lines = sum(len(item.candidate_lines) for item in files) + summary = f"{len(files)} changed file(s), {changed_lines} added line(s)" + return ReviewInput( + files=files, + context=text, + candidate_lines=candidates, + digest=digest, + summary=summary, + source_type=source_type, + source_path=str(source), + ) diff --git a/examples/skills_code_review_agent/agent/metrics.py b/examples/skills_code_review_agent/agent/metrics.py new file mode 100644 index 000000000..e18ec94f0 --- /dev/null +++ b/examples/skills_code_review_agent/agent/metrics.py @@ -0,0 +1,39 @@ +"""In-process, serialization-safe review metrics.""" + +from __future__ import annotations + +import time +from collections import Counter, defaultdict + + +class MetricsCollector: + def __init__(self) -> None: + self.started = time.monotonic() + self.stage_duration_ms: dict[str, int] = defaultdict(int) + self.tool_calls = 0 + self.blocked_executions = 0 + self.finding_severity: Counter[str] = Counter() + self.errors: Counter[str] = Counter() + + def record_stage(self, stage: str, duration_seconds: float) -> None: + self.stage_duration_ms[stage] += round(duration_seconds * 1000) + + def record_tool(self, *, blocked: bool = False) -> None: + self.tool_calls += 1 + self.blocked_executions += int(blocked) + + def record_finding(self, severity: str) -> None: + self.finding_severity[severity] += 1 + + def record_error(self, error: BaseException | str) -> None: + self.errors[type(error).__name__ if isinstance(error, BaseException) else error] += 1 + + def snapshot(self) -> dict[str, object]: + return { + "total_duration_ms": round((time.monotonic() - self.started) * 1000), + "stage_duration_ms": dict(self.stage_duration_ms), + "tool_calls": self.tool_calls, + "blocked_executions": self.blocked_executions, + "finding_severity": dict(self.finding_severity), + "errors": dict(self.errors), + } diff --git a/examples/skills_code_review_agent/agent/models.py b/examples/skills_code_review_agent/agent/models.py new file mode 100644 index 000000000..cb0ee5f11 --- /dev/null +++ b/examples/skills_code_review_agent/agent/models.py @@ -0,0 +1,144 @@ +"""Domain models shared by the review pipeline.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from enum import Enum +from pathlib import Path +from typing import Any + +from pydantic import BaseModel, Field + + +def utc_now() -> datetime: + return datetime.now(timezone.utc) + + +class Decision(str, Enum): + ALLOW = "allow" + DENY = "deny" + NEEDS_HUMAN_REVIEW = "needs_human_review" + + +class ChangedLine(BaseModel): + number: int + content: str + + +class ReviewHunk(BaseModel): + old_start: int + old_count: int + new_start: int + new_count: int + header: str + lines: list[str] = Field(default_factory=list) + + +class ChangedFile(BaseModel): + path: str + old_path: str | None = None + hunks: list[ReviewHunk] = Field(default_factory=list) + candidate_lines: list[ChangedLine] = Field(default_factory=list) + is_new: bool = False + is_deleted: bool = False + + +class ReviewInput(BaseModel): + files: list[ChangedFile] = Field(default_factory=list) + context: str = "" + candidate_lines: dict[str, list[int]] = Field(default_factory=dict) + digest: str + summary: str + source_type: str + source_path: str | None = None + + +class ExecutionRequest(BaseModel): + task_id: str = "" + command: list[str] + cwd: str + input_paths: list[str] = Field(default_factory=list) + network_targets: list[str] = Field(default_factory=list) + env: dict[str, str] = Field(default_factory=dict) + timeout: float = 30.0 + memory_limit_mb: int = 0 + + +class ExecutionBudget(BaseModel): + max_calls: int = 10 + max_total_seconds: float = 120.0 + calls_used: int = 0 + seconds_used: float = 0.0 + + +class FilterDecision(BaseModel): + decision: Decision + reason_code: str + reason: str + risk_level: str = "low" + matched_rule: str = "" + task_id: str = "" + command_digest: str = "" + created_at: datetime = Field(default_factory=utc_now) + + +class Finding(BaseModel): + severity: str + category: str + file: str + line: int + title: str + evidence: str + recommendation: str + confidence: float = Field(ge=0.0, le=1.0) + source: str = "rule" + needs_human_review: bool = False + dedupe_key: str = "" + rule_id: str = "" + rule_version: str = "1" + validation_status: str = "not_run" + + +class SandboxRunResult(BaseModel): + id: str + runtime: str + task_type: str = "custom_rule" + command: list[str] + status: str + exit_code: int | None = None + timed_out: bool = False + duration_ms: int = 0 + stdout_summary: str = "" + stderr_summary: str = "" + decision: FilterDecision + + +class ReviewRequest(BaseModel): + review_input: ReviewInput + runtime: str = "local" + dry_run: bool = False + fake_model: bool = True + task_id: str | None = None + + +class ReviewReport(BaseModel): + task_id: str + status: str + conclusion: str + input_summary: str + findings: list[Finding] = Field(default_factory=list) + needs_human_review: list[Finding] = Field(default_factory=list) + warnings: list[str] = Field(default_factory=list) + sandbox_runs: list[SandboxRunResult] = Field(default_factory=list) + filter_decisions: list[FilterDecision] = Field(default_factory=list) + metrics: dict[str, Any] = Field(default_factory=dict) + dry_run: bool = False + rule_set_digest: str = "" + skipped_checks: list[dict[str, str]] = Field(default_factory=list) + created_at: datetime = Field(default_factory=utc_now) + + +class ReportPaths(BaseModel): + json_path: Path + markdown_path: Path + filter_events_path: Path diff --git a/examples/skills_code_review_agent/agent/policy.py b/examples/skills_code_review_agent/agent/policy.py new file mode 100644 index 000000000..018de107e --- /dev/null +++ b/examples/skills_code_review_agent/agent/policy.py @@ -0,0 +1,49 @@ +"""Compatibility facade for the layered governance package.""" + +from __future__ import annotations + +from collections.abc import Callable + +from ..governance import FilterEngine +from ..governance.policy import GovernancePolicy, load_policy +from .models import ExecutionBudget, ExecutionRequest, FilterDecision + + +def evaluate_execution_policy( + request: ExecutionRequest, + *, + workspace_root: str, + budget: ExecutionBudget | None = None, + allowed_network_targets: set[str] | None = None, + allowed_commands: set[str] | None = None, +) -> FilterDecision: + """Evaluate one request; optional overrides preserve the original public API.""" + policy = load_policy() + if allowed_network_targets is not None: + policy.allowed_network_targets = allowed_network_targets + if allowed_commands is not None: + policy.allowed_commands = allowed_commands + return FilterEngine(workspace_root, policy=policy, budget=budget).check(request) + + +class ReviewExecutionFilter: + """Application-facing governance filter.""" + + def __init__( + self, + workspace_root: str, + *, + budget: ExecutionBudget | None = None, + decision_sink: Callable[[FilterDecision], None] | None = None, + policy: GovernancePolicy | None = None, + ) -> None: + self.engine = FilterEngine( + workspace_root, + policy=policy, + budget=budget, + decision_sink=decision_sink, + ) + self.budget = self.engine.budget + + def run(self, request: ExecutionRequest) -> FilterDecision: + return self.engine.check(request) diff --git a/examples/skills_code_review_agent/agent/reporting.py b/examples/skills_code_review_agent/agent/reporting.py new file mode 100644 index 000000000..0f8e893d0 --- /dev/null +++ b/examples/skills_code_review_agent/agent/reporting.py @@ -0,0 +1,124 @@ +"""Atomic JSON and Markdown report rendering.""" + +from __future__ import annotations + +import json +import os +import tempfile +from pathlib import Path + +from .models import ReportPaths, ReviewReport + + +def _atomic_write(path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) + try: + with os.fdopen(fd, "w", encoding="utf-8") as stream: + stream.write(content) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + except Exception: + try: + os.unlink(temporary) + except FileNotFoundError: + pass + raise + + +def render_markdown(report: ReviewReport) -> str: + severity: dict[str, int] = {} + for finding in report.findings: + severity[finding.severity] = severity.get(finding.severity, 0) + 1 + lines = [ + "# Code Review Report", + "", + f"- Task: `{report.task_id}`", + f"- Status: **{report.status}**", + f"- Dry run: **{'yes' if report.dry_run else 'no'}**", + f"- Input: {report.input_summary}", + f"- Rule set digest: `{report.rule_set_digest}`", + f"- Conclusion: {report.conclusion}", + "", + "## Severity summary", + "", + ] + lines.extend([f"- {name}: {count}" for name, count in sorted(severity.items())] or ["- No accepted findings"]) + lines.extend(["", "## Findings", ""]) + for item in report.findings: + lines.extend( + [ + f"### [{item.severity.upper()}] {item.title}", + "", + f"`{item.file}:{item.line}` · {item.category} · confidence {item.confidence:.2f}", + "", + item.evidence, + "", + f"Recommendation: {item.recommendation}", + "", + ] + ) + if not report.findings: + lines.extend(["No accepted findings.", ""]) + lines.extend(["## Human review", ""]) + lines.extend( + [f"- `{item.file}:{item.line}` {item.title} ({item.confidence:.2f})" for item in report.needs_human_review] + or ["- None"] + ) + lines.extend(["", "## Execution and policy", ""]) + lines.append(f"- Sandbox checks: {len(report.sandbox_runs)}") + lines.append(f"- Blocked decisions: {sum(item.decision != 'allow' for item in report.filter_decisions)}") + for run in report.sandbox_runs: + lines.append( + f"- `{run.task_type}`: {run.status}, exit={run.exit_code}, " + f"duration={run.duration_ms}ms" + ) + lines.extend(["", "## Monitoring", ""]) + lines.append(f"- Total duration: {report.metrics.get('total_duration_ms', 0)}ms") + lines.append(f"- Tool calls: {report.metrics.get('tool_calls', 0)}") + lines.append( + f"- Filter blocks: {report.metrics.get('blocked_executions', 0)}" + ) + lines.append( + f"- Errors: {json.dumps(report.metrics.get('errors', {}), ensure_ascii=False)}" + ) + if report.warnings: + lines.extend(["", "## Warnings", "", *[f"- {warning}" for warning in report.warnings]]) + return "\n".join(lines) + "\n" + + +def write_reports(report: ReviewReport, output_dir: str | Path, *, max_report_bytes: int = 5_000_000) -> ReportPaths: + output = Path(output_dir) / report.task_id + json_content = json.dumps(report.model_dump(mode="json"), ensure_ascii=False, indent=2) + "\n" + markdown_content = render_markdown(report) + if max(len(json_content.encode()), len(markdown_content.encode())) > max_report_bytes: + raise ValueError("generated report exceeds the configured size limit") + filter_events_content = json.dumps( + [ + { + "task_id": item.task_id or report.task_id, + "command_digest": item.command_digest, + "decision": item.decision.value, + "risk_level": item.risk_level, + "reason": item.reason, + "matched_rule": item.matched_rule or item.reason_code, + "created_at": item.created_at.isoformat(), + } + for item in report.filter_decisions + ], + ensure_ascii=False, + indent=2, + ) + "\n" + if len(filter_events_content.encode()) > max_report_bytes: + raise ValueError("filter audit output exceeds the configured size limit") + json_path, markdown_path = output / "review_report.json", output / "review_report.md" + filter_events_path = output / "filter_events.json" + _atomic_write(json_path, json_content) + _atomic_write(markdown_path, markdown_content) + _atomic_write(filter_events_path, filter_events_content) + return ReportPaths( + json_path=json_path, + markdown_path=markdown_path, + filter_events_path=filter_events_path, + ) diff --git a/examples/skills_code_review_agent/agent/review.py b/examples/skills_code_review_agent/agent/review.py new file mode 100644 index 000000000..b1fb594e4 --- /dev/null +++ b/examples/skills_code_review_agent/agent/review.py @@ -0,0 +1,100 @@ +"""Application service that orchestrates a complete review.""" + +from __future__ import annotations + +import hashlib +from pathlib import Path +import uuid +from typing import Any, Callable, Iterable + +from .metrics import MetricsCollector +from .models import Finding, ReviewReport, ReviewRequest +from .sandbox import run_sandbox_checks +from .sanitizer import normalize_findings, redact_sensitive_text +from .storage import ReviewRepository + +Analyzer = Callable[[Any], Iterable[Finding | dict[str, Any]]] + + +def _rule_set_digest() -> str: + rules = Path(__file__).parents[1] / "skills" / "code-review" / "rules" + digest = hashlib.sha256() + for path in sorted(rules.glob("*.yaml")): + digest.update(path.name.encode()) + digest.update(b"\0") + digest.update(path.read_bytes()) + return digest.hexdigest() + + +async def run_review( + request: ReviewRequest, + *, + runtime: Any = None, + repository: ReviewRepository | None = None, + analyzer: Analyzer | None = None, + metrics: MetricsCollector | None = None, +) -> ReviewReport: + metrics = metrics or MetricsCollector() + task_id = request.task_id or uuid.uuid4().hex + if repository: + repository.create_task( + task_id, + input_type=request.review_input.source_type, + input_digest=request.review_input.digest, + summary=request.review_input.summary, + ) + try: + runs, candidates, warnings = await run_sandbox_checks( + runtime, + request.review_input, + runtime_name=request.runtime, + dry_run=request.dry_run, + metrics=metrics, + ) + if analyzer and not request.dry_run: + try: + candidates.extend( + item if isinstance(item, Finding) else Finding.model_validate(item) + for item in analyzer(request.review_input) + ) + except Exception as exc: + metrics.record_error(exc) + warnings.append(f"model analyzer failed: {type(exc).__name__}") + findings, normalization_warnings, human = normalize_findings(candidates, request.review_input) + warnings.extend(normalization_warnings) + for finding in findings: + metrics.record_finding(finding.severity) + failed_checks = any(item.status in {"failed", "timed_out"} for item in runs) + status = "partial" if failed_checks or warnings else "completed" + if request.dry_run: + conclusion = ( + f"Dry run only: {len(runs)} sandbox task(s) were planned and " + "no checks were executed." + ) + elif findings: + conclusion = f"Found {len(findings)} actionable issue(s)." + elif human: + conclusion = f"No confirmed issues; {len(human)} item(s) need human review." + else: + conclusion = "No actionable issues found." + report = ReviewReport( + task_id=task_id, + status=status, + conclusion=conclusion, + input_summary=request.review_input.summary, + findings=findings, + needs_human_review=human, + warnings=[redact_sensitive_text(item) for item in warnings], + sandbox_runs=runs, + filter_decisions=[item.decision for item in runs], + metrics=metrics.snapshot(), + dry_run=request.dry_run, + rule_set_digest=_rule_set_digest(), + ) + if repository: + repository.save_review_result(report) + return report + except Exception as exc: + if repository: + repository.mark_failed(task_id, type(exc).__name__) + raise diff --git a/examples/skills_code_review_agent/agent/sandbox.py b/examples/skills_code_review_agent/agent/sandbox.py new file mode 100644 index 000000000..fa7d7c3c0 --- /dev/null +++ b/examples/skills_code_review_agent/agent/sandbox.py @@ -0,0 +1,101 @@ +"""Application adapter for the layered sandbox package.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from trpc_agent_sdk.code_executors import create_local_workspace_runtime + +from ..sandbox import SandboxRunner +from ..sandbox.runtime import WorkspaceManager +from .task_planner import ReviewTaskPlanner +from .metrics import MetricsCollector +from .models import Decision, ExecutionBudget, Finding, ReviewInput, SandboxRunResult +from .sanitizer import redact_sensitive_text + + +async def run_sandbox_checks( + runtime: Any, + review_input: ReviewInput, + *, + runtime_name: str = "local", + dry_run: bool = False, + metrics: MetricsCollector | None = None, + budget: ExecutionBudget | None = None, +) -> tuple[list[SandboxRunResult], list[Finding], list[str]]: + """Run the code-review custom-rule task and map it to application models.""" + metrics = metrics or MetricsCollector() + warnings: list[str] = [] + if runtime is None: + if runtime_name != "local": + return [], [], [f"{runtime_name} runtime is required; rule check was skipped"] + runtime = create_local_workspace_runtime() + + workspaces = WorkspaceManager(runtime) + skill_root = Path(__file__).parents[1] / "skills" / "code-review" + project_source = ( + Path(review_input.source_path) + if review_input.source_type in {"repo", "file_list"} and review_input.source_path + else None + ) + directories = {"code-review": skill_root} + if project_source and project_source.is_dir(): + directories["project"] = project_source + prepared = await workspaces.prepare( + review_input.digest[:32], + directories=directories, + files={"review.json": review_input.model_dump_json().encode()}, + ) + try: + plan = ReviewTaskPlanner().build_plan( + review_input, + prepared.info.path, + runtime_name, + project_staged="project" in directories, + ) + findings: list[Finding] = [] + runs: list[SandboxRunResult] = [] + runner = SandboxRunner(runtime, budget=budget) + for task in plan.tasks: + result = await runner.run(prepared.info, task, dry_run=dry_run) + metrics.record_tool(blocked=result.decision.decision != Decision.ALLOW) + metrics.record_stage(task.task_type, result.duration_ms / 1000) + if result.error_type: + metrics.record_error(result.error_type) + if result.status in {"failed", "timed_out", "blocked"}: + detail = result.error_type or ( + f"exit code {result.exit_code}" if result.exit_code is not None else result.status + ) + warnings.append(f"{task.task_type} check {result.status}: {detail}") + if task.task_type == "custom_rule" and result.status == "completed": + try: + findings = [Finding.model_validate(item) for item in json.loads(result.stdout)] + except Exception as exc: + metrics.record_error(exc) + warnings.append(f"rule check output is invalid: {type(exc).__name__}") + runs.append( + SandboxRunResult( + id=result.task_id, + runtime=runtime_name, + task_type=task.task_type, + command=task.command, + status=result.status, + exit_code=result.exit_code, + timed_out=result.timed_out, + duration_ms=result.duration_ms, + stdout_summary=redact_sensitive_text(result.stdout), + stderr_summary=redact_sensitive_text(result.stderr), + decision=result.decision, + ) + ) + if result.output_truncated: + warnings.append(f"{task.task_type} output was truncated") + return runs, findings, warnings + finally: + try: + await workspaces.cleanup(prepared) + except Exception as exc: + metrics.record_error(exc) + warnings.append(f"workspace cleanup failed: {type(exc).__name__}") diff --git a/examples/skills_code_review_agent/agent/sanitizer.py b/examples/skills_code_review_agent/agent/sanitizer.py new file mode 100644 index 000000000..e60f110d0 --- /dev/null +++ b/examples/skills_code_review_agent/agent/sanitizer.py @@ -0,0 +1,101 @@ +"""Finding validation, secret redaction, and de-duplication.""" + +from __future__ import annotations + +import hashlib +import json +import re +from typing import Any, Iterable + +from .models import Finding, ReviewInput + +_SECRET_PATTERNS = [ + ( + re.compile(r"(?i)\b((?:postgres(?:ql)?|mysql|mariadb|mongodb(?:\+srv)?)://[^:\s/@]+:)([^@\s/]+)(@)"), + r"\1[REDACTED_PASSWORD]\3", + ), + ( + re.compile( + r"(?i)\b(api[_-]?key|access[_-]?key|token|password|passwd|secret)" + r"\b(\s*[:=]\s*)(?:['\"][^'\"]+['\"]|[^\s,;]+)" + ), + r"\1\2[REDACTED_CREDENTIAL]", + ), + ( + re.compile( + r"\b(?:sk-[A-Za-z0-9_-]{12,}|ghp_[A-Za-z0-9_-]{12,}|" + r"github_pat_[A-Za-z0-9_-]{12,})\b" + ), + "[REDACTED_TOKEN]", + ), + (re.compile(r"(?i)\bBearer\s+[A-Za-z0-9._~+/=-]{8,}"), "Bearer [REDACTED_TOKEN]"), + (re.compile(r"\bAKIA[0-9A-Z]{16}\b"), "[REDACTED_AWS_KEY]"), + ( + re.compile(r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b"), + "[REDACTED_JWT]", + ), +] +_SEVERITIES = {"critical", "high", "medium", "low", "info"} +_CATEGORIES = {"security", "correctness", "reliability", "performance", "maintainability", "testing", "style"} + + +def redact_sensitive_text(value: Any) -> str: + if not isinstance(value, str): + try: + value = json.dumps(value, ensure_ascii=False, default=str) + except Exception: + value = repr(value) + for pattern, replacement in _SECRET_PATTERNS: + value = pattern.sub(replacement, value) + return value + + +def _dedupe_key(finding: Finding) -> str: + raw = f"{finding.file}\0{finding.line}\0{finding.category}".encode() + return hashlib.sha256(raw).hexdigest() + + +def normalize_findings( + candidates: Iterable[Finding | dict[str, Any]], + review_input: ReviewInput, + *, + confidence_threshold: float = 0.65, +) -> tuple[list[Finding], list[str], list[Finding]]: + accepted: dict[tuple[str, int, str], Finding] = {} + human: dict[tuple[str, int, str], Finding] = {} + warnings: list[str] = [] + valid_lines = {name: set(lines) for name, lines in review_input.candidate_lines.items()} + for index, candidate in enumerate(candidates): + try: + finding = candidate if isinstance(candidate, Finding) else Finding.model_validate(candidate) + finding.evidence = redact_sensitive_text(finding.evidence) + finding.recommendation = redact_sensitive_text(finding.recommendation) + finding.title = redact_sensitive_text(finding.title) + invalid = ( + finding.severity not in _SEVERITIES + or finding.category not in _CATEGORIES + or finding.file not in valid_lines + or finding.line not in valid_lines[finding.file] + or not finding.evidence.strip() + ) + if invalid or finding.confidence < confidence_threshold: + finding.needs_human_review = True + finding.dedupe_key = _dedupe_key(finding) + key = (finding.file, finding.line, finding.category) + if finding.needs_human_review: + previous = human.get(key) + if previous is None or (finding.confidence, len(finding.evidence)) > ( + previous.confidence, + len(previous.evidence), + ): + human[key] = finding + continue + previous = accepted.get(key) + if previous is None or (finding.confidence, len(finding.evidence)) > ( + previous.confidence, + len(previous.evidence), + ): + accepted[key] = finding + except Exception as exc: + warnings.append(f"candidate {index} could not be normalized: {type(exc).__name__}") + return list(accepted.values()), warnings, list(human.values()) diff --git a/examples/skills_code_review_agent/agent/storage.py b/examples/skills_code_review_agent/agent/storage.py new file mode 100644 index 000000000..04c0ebe11 --- /dev/null +++ b/examples/skills_code_review_agent/agent/storage.py @@ -0,0 +1,382 @@ +"""Execution-trace persistence for the code-review agent.""" + +from __future__ import annotations + +import hashlib +import json +import uuid +from datetime import datetime +from typing import Any + +from sqlalchemy import ( + Boolean, + Column, + DateTime, + Float, + ForeignKey, + Integer, + MetaData, + String, + Table, + Text, + UniqueConstraint, + create_engine, + event, + Index, + select, +) + +from .models import ReviewReport, utc_now +from .reporting import render_markdown +from .sanitizer import redact_sensitive_text + + +def _json(value: Any) -> str: + return json.dumps(value, ensure_ascii=False, sort_keys=True) + + +def _id(prefix: str) -> str: + return f"{prefix}_{uuid.uuid4().hex}" + + +class ReviewRepository: + """Store a complete, queryable review trace. + + One transaction commits the final skill executions, policy decisions, + sandbox runs, findings, report and telemetry. A failed transaction leaves + the already-created task in ``running`` so the caller can mark it failed. + """ + + def __init__(self, db_url: str = "sqlite:///code_review.db") -> None: + self.engine = create_engine(db_url) + if self.engine.dialect.name == "sqlite": + @event.listens_for(self.engine, "connect") + def _enable_sqlite_foreign_keys(dbapi_connection: Any, _: Any) -> None: + cursor = dbapi_connection.cursor() + cursor.execute("PRAGMA foreign_keys=ON") + cursor.close() + self.metadata = MetaData() + self.tasks = Table( + "review_task", + self.metadata, + Column("id", String(64), primary_key=True), + Column("repo_path", Text), + Column("commit_hash", String(128)), + Column("input_type", String(32), nullable=False), + Column("input_digest", String(64), nullable=False), + Column("diff_summary", Text, nullable=False), + Column("status", String(32), nullable=False), + Column("created_at", DateTime(timezone=True), nullable=False), + Column("started_at", DateTime(timezone=True)), + Column("finished_at", DateTime(timezone=True)), + Column("error_type", String(128)), + ) + self.skill_executions = Table( + "skill_execution", + self.metadata, + Column("id", String(64), primary_key=True), + Column("task_id", ForeignKey("review_task.id"), nullable=False), + Column("skill_name", String(128), nullable=False), + Column("skill_version", String(64), nullable=False), + Column("rule_version", String(64), nullable=False), + Column("detector_type", String(64), nullable=False), + Column("started_at", DateTime(timezone=True), nullable=False), + Column("finished_at", DateTime(timezone=True), nullable=False), + Column("result_summary", Text, nullable=False), + ) + self.runs = Table( + "sandbox_run", + self.metadata, + Column("id", String(64), primary_key=True), + Column("task_id", ForeignKey("review_task.id"), nullable=False), + Column("runtime", String(64), nullable=False), + Column("runtime_version", String(128), nullable=False), + Column("image", Text), + Column("command", Text, nullable=False), + Column("command_digest", String(64), nullable=False), + Column("status", String(32), nullable=False), + Column("exit_code", Integer), + Column("timed_out", Boolean, nullable=False), + Column("stdout", Text, nullable=False), + Column("stderr", Text, nullable=False), + Column("duration", Float, nullable=False), + Column("created_at", DateTime(timezone=True), nullable=False), + ) + self.decisions = Table( + "filter_event", + self.metadata, + Column("id", String(64), primary_key=True), + Column("task_id", ForeignKey("review_task.id"), nullable=False), + Column("sandbox_run_id", ForeignKey("sandbox_run.id")), + Column("action", String(64), nullable=False), + Column("command", Text, nullable=False), + Column("command_digest", String(64), nullable=False), + Column("decision", String(32), nullable=False), + Column("risk_level", String(16), nullable=False), + Column("reason", Text, nullable=False), + Column("reason_code", String(64), nullable=False), + Column("matched_rule", String(128), nullable=False), + Column("created_at", DateTime(timezone=True), nullable=False), + ) + self.findings = Table( + "finding", + self.metadata, + Column("id", String(64), primary_key=True), + Column("task_id", ForeignKey("review_task.id"), nullable=False), + Column("skill_execution_id", ForeignKey("skill_execution.id")), + Column("sandbox_run_id", ForeignKey("sandbox_run.id")), + Column("severity", String(16), nullable=False), + Column("category", String(32), nullable=False), + Column("file", Text, nullable=False), + Column("line", Integer, nullable=False), + Column("title", Text, nullable=False), + Column("evidence", Text, nullable=False), + Column("recommendation", Text, nullable=False), + Column("confidence", Float, nullable=False), + Column("source", String(64), nullable=False), + Column("status", String(32), nullable=False), + Column("needs_human_review", Boolean, nullable=False), + Column("dedupe_key", String(64), nullable=False), + Column("rule_id", String(128), nullable=False, default=""), + Column("rule_version", String(64), nullable=False, default="1"), + Column("validation_status", String(32), nullable=False, default="not_run"), + Column("created_at", DateTime(timezone=True), nullable=False), + UniqueConstraint( + "task_id", "file", "line", "category", + name="uq_finding_location_category", + ), + ) + self.reports = Table( + "review_report", + self.metadata, + Column("id", String(64), primary_key=True), + Column("task_id", ForeignKey("review_task.id"), unique=True, nullable=False), + Column("summary", Text, nullable=False), + Column("report_json", Text, nullable=False), + Column("report_markdown", Text, nullable=False), + Column("created_at", DateTime(timezone=True), nullable=False), + ) + self.telemetry = Table( + "telemetry", + self.metadata, + Column("id", String(64), primary_key=True), + Column("task_id", ForeignKey("review_task.id"), unique=True, nullable=False), + Column("total_duration", Float, nullable=False), + Column("sandbox_duration", Float, nullable=False), + Column("tool_calls", Integer, nullable=False), + Column("filter_blocks", Integer, nullable=False), + Column("finding_count", Integer, nullable=False), + Column("error_count", Integer, nullable=False), + Column("severity_distribution", Text, nullable=False), + Column("error_distribution", Text, nullable=False), + Column("created_at", DateTime(timezone=True), nullable=False), + ) + Index("ix_review_task_status", self.tasks.c.status) + Index("ix_review_task_created_at", self.tasks.c.created_at) + Index("ix_finding_task_id", self.findings.c.task_id) + Index("ix_finding_severity", self.findings.c.severity) + + def initialize(self) -> None: + self.metadata.create_all(self.engine) + + def create_task( + self, + task_id: str, + *, + input_type: str, + input_digest: str, + summary: str, + repo_path: str | None = None, + commit_hash: str | None = None, + ) -> None: + now = utc_now() + with self.engine.begin() as connection: + connection.execute( + self.tasks.insert().values( + id=task_id, + repo_path=repo_path, + commit_hash=commit_hash, + input_type=input_type, + input_digest=input_digest, + diff_summary=summary, + status="running", + created_at=now, + started_at=now, + ) + ) + + def mark_failed(self, task_id: str, error_type: str) -> None: + with self.engine.begin() as connection: + connection.execute( + self.tasks.update().where(self.tasks.c.id == task_id).values( + status="failed", error_type=error_type, finished_at=utc_now() + ) + ) + + def save_review_result( + self, + report: ReviewReport, + *, + skill_version: str = "1", + rule_version: str = "1", + ) -> None: + """Atomically persist every final entity in a review trace.""" + payload = report.model_dump(mode="json") + now = utc_now() + detector_names = sorted({item.source for item in report.findings}) or ["rule"] + skill_id = _id("skill") + with self.engine.begin() as connection: + connection.execute( + self.tasks.update().where(self.tasks.c.id == report.task_id).values( + status=report.status, finished_at=now + ) + ) + connection.execute( + self.skill_executions.insert().values( + id=skill_id, + task_id=report.task_id, + skill_name="code-review", + skill_version=skill_version, + rule_version=rule_version, + detector_type=",".join(detector_names), + started_at=now, + finished_at=now, + result_summary=_json( + {"findings": len(report.findings), "warnings": len(report.warnings)} + ), + ) + ) + for run in report.sandbox_runs: + command = redact_sensitive_text(_json(run.command)) + connection.execute( + self.runs.insert().values( + id=run.id, + task_id=report.task_id, + runtime=run.runtime, + runtime_version="unknown", + image=None, + command=command, + command_digest=hashlib.sha256( + "\0".join(run.command).encode() + ).hexdigest(), + status=run.status, + exit_code=run.exit_code, + timed_out=run.timed_out, + duration=run.duration_ms / 1000, + stdout=run.stdout_summary, + stderr=run.stderr_summary, + created_at=now, + ) + ) + for index, decision in enumerate(report.filter_decisions): + run = report.sandbox_runs[index] if index < len(report.sandbox_runs) else None + connection.execute( + self.decisions.insert().values( + id=_id("filter"), + task_id=report.task_id, + sandbox_run_id=run.id if run else None, + action="sandbox_execute", + command=redact_sensitive_text(_json(run.command)) if run else "[]", + command_digest=decision.command_digest, + decision=decision.decision.value, + risk_level=decision.risk_level, + reason=decision.reason, + reason_code=decision.reason_code, + matched_rule=decision.matched_rule or decision.reason_code, + created_at=decision.created_at, + ) + ) + all_findings = [*report.findings, *report.needs_human_review] + first_run_id = report.sandbox_runs[0].id if report.sandbox_runs else None + for finding in all_findings: + values = finding.model_dump() + values.update( + id=_id("finding"), + task_id=report.task_id, + skill_execution_id=skill_id, + sandbox_run_id=first_run_id, + status="needs_human_review" if finding.needs_human_review else "open", + created_at=now, + ) + existing = connection.execute( + select(self.findings.c.id, self.findings.c.confidence).where( + self.findings.c.task_id == report.task_id, + self.findings.c.file == finding.file, + self.findings.c.line == finding.line, + self.findings.c.category == finding.category, + ) + ).first() + if existing and existing.confidence < finding.confidence: + values.pop("id") + connection.execute( + self.findings.update() + .where(self.findings.c.id == existing.id) + .values(**values) + ) + elif not existing: + connection.execute(self.findings.insert().values(**values)) + severity = report.metrics.get("finding_severity", {}) + errors = report.metrics.get("errors", {}) + summary = { + "conclusion": report.conclusion, + "severity": severity, + "status": report.status, + } + connection.execute( + self.reports.insert().values( + id=_id("report"), + task_id=report.task_id, + summary=_json(summary), + report_json=_json(payload), + report_markdown=render_markdown(report), + created_at=report.created_at, + ) + ) + stage_ms = report.metrics.get("stage_duration_ms", {}) + connection.execute( + self.telemetry.insert().values( + id=_id("telemetry"), + task_id=report.task_id, + total_duration=float(report.metrics.get("total_duration_ms", 0)) / 1000, + sandbox_duration=float(stage_ms.get("sandbox", 0)) / 1000, + tool_calls=int(report.metrics.get("tool_calls", 0)), + filter_blocks=int(report.metrics.get("blocked_executions", 0)), + finding_count=len(all_findings), + error_count=sum(int(value) for value in errors.values()), + severity_distribution=_json(severity), + error_distribution=_json(errors), + created_at=now, + ) + ) + + def get_task(self, task_id: str) -> dict[str, Any] | None: + """Return the task and all child trace records.""" + with self.engine.connect() as connection: + task = connection.execute( + select(self.tasks).where(self.tasks.c.id == task_id) + ).mappings().first() + if task is None: + return None + result = dict(task) + children = { + "skill_executions": self.skill_executions, + "sandbox_runs": self.runs, + "filter_decisions": self.decisions, + "findings": self.findings, + } + for key, table in children.items(): + result[key] = [ + dict(row) + for row in connection.execute( + select(table).where(table.c.task_id == task_id) + ).mappings() + ] + for key, table in ( + ("report", self.reports), + ("telemetry", self.telemetry), + ): + row = connection.execute( + select(table).where(table.c.task_id == task_id) + ).mappings().first() + result[key] = dict(row) if row else None + return result diff --git a/examples/skills_code_review_agent/agent/task_planner.py b/examples/skills_code_review_agent/agent/task_planner.py new file mode 100644 index 000000000..e86d57154 --- /dev/null +++ b/examples/skills_code_review_agent/agent/task_planner.py @@ -0,0 +1,62 @@ +"""Build a bounded, reproducible sandbox task plan.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path + +from ..sandbox.models import SandboxTask +from ..sandbox.tasks import build_code_review_task, build_static_check_task, build_test_task +from .models import ReviewInput + + +@dataclass +class ReviewPlan: + tasks: list[SandboxTask] = field(default_factory=list) + skipped: list[dict[str, str]] = field(default_factory=list) + + +class ReviewTaskPlanner: + """Select checks from the staged project and changed paths.""" + + def build_plan( + self, + review_input: ReviewInput, + workspace_path: str, + runtime_name: str, + *, + project_staged: bool, + ) -> ReviewPlan: + plan = ReviewPlan( + tasks=[build_code_review_task(workspace_path, runtime_name)] + ) + if not project_staged: + plan.skipped.extend( + [ + {"check": "ruff", "reason": "full project source is unavailable"}, + {"check": "pytest", "reason": "full project source is unavailable"}, + ] + ) + return plan + + python_paths = sorted( + item.path for item in review_input.files + if item.path.endswith(".py") and not item.is_deleted + ) + project_root = f"{workspace_path.rstrip('/')}/project" + if python_paths: + plan.tasks.append(build_static_check_task(project_root, python_paths)) + else: + plan.skipped.append({"check": "ruff", "reason": "no changed Python files"}) + + changed_tests = [ + path for path in python_paths + if Path(path).name.startswith("test_") or "/tests/" in f"/{path}" + ] + if changed_tests: + plan.tasks.append(build_test_task(project_root, changed_tests)) + else: + plan.skipped.append( + {"check": "pytest", "reason": "no changed test files were identified"} + ) + return plan diff --git a/examples/skills_code_review_agent/fixtures/async_leak.diff b/examples/skills_code_review_agent/fixtures/async_leak.diff new file mode 100644 index 000000000..6b6ee0cd4 --- /dev/null +++ b/examples/skills_code_review_agent/fixtures/async_leak.diff @@ -0,0 +1,5 @@ +diff --git a/worker.py b/worker.py +--- a/worker.py ++++ b/worker.py +@@ -0,0 +1 @@ ++asyncio.create_task(process_message(message)) diff --git a/examples/skills_code_review_agent/fixtures/clean.diff b/examples/skills_code_review_agent/fixtures/clean.diff new file mode 100644 index 000000000..1f610dade --- /dev/null +++ b/examples/skills_code_review_agent/fixtures/clean.diff @@ -0,0 +1,6 @@ +diff --git a/tests/test_math.py b/tests/test_math.py +--- a/tests/test_math.py ++++ b/tests/test_math.py +@@ -1 +1,2 @@ + def test_add(): ++ assert 1 + 1 == 2 diff --git a/examples/skills_code_review_agent/fixtures/database_leak.diff b/examples/skills_code_review_agent/fixtures/database_leak.diff new file mode 100644 index 000000000..1bb5f4ebb --- /dev/null +++ b/examples/skills_code_review_agent/fixtures/database_leak.diff @@ -0,0 +1,5 @@ +diff --git a/store.py b/store.py +--- a/store.py ++++ b/store.py +@@ -0,0 +1 @@ ++connection = sqlite3.connect(database_path) diff --git a/examples/skills_code_review_agent/fixtures/duplicate.diff b/examples/skills_code_review_agent/fixtures/duplicate.diff new file mode 100644 index 000000000..d6bff3a9f --- /dev/null +++ b/examples/skills_code_review_agent/fixtures/duplicate.diff @@ -0,0 +1,5 @@ +diff --git a/app.py b/app.py +--- a/app.py ++++ b/app.py +@@ -0,0 +1 @@ ++os.system(user_input) diff --git a/examples/skills_code_review_agent/fixtures/missing_tests.diff b/examples/skills_code_review_agent/fixtures/missing_tests.diff new file mode 100644 index 000000000..b120a6f96 --- /dev/null +++ b/examples/skills_code_review_agent/fixtures/missing_tests.diff @@ -0,0 +1,6 @@ +diff --git a/discount.py b/discount.py +--- a/discount.py ++++ b/discount.py +@@ -0,0 +1,2 @@ ++def apply_discount(amount): ++ return amount * 0.8 diff --git a/examples/skills_code_review_agent/fixtures/sandbox_failure.diff b/examples/skills_code_review_agent/fixtures/sandbox_failure.diff new file mode 100644 index 000000000..78c2a39a9 --- /dev/null +++ b/examples/skills_code_review_agent/fixtures/sandbox_failure.diff @@ -0,0 +1,5 @@ +diff --git a/app.py b/app.py +--- a/app.py ++++ b/app.py +@@ -0,0 +1 @@ ++print("sandbox failure is injected by the test runtime") diff --git a/examples/skills_code_review_agent/fixtures/security.diff b/examples/skills_code_review_agent/fixtures/security.diff new file mode 100644 index 000000000..d1ca1a6d3 --- /dev/null +++ b/examples/skills_code_review_agent/fixtures/security.diff @@ -0,0 +1,5 @@ +diff --git a/app.py b/app.py +--- a/app.py ++++ b/app.py +@@ -0,0 +1 @@ ++subprocess.run(user_input, shell=True) diff --git a/examples/skills_code_review_agent/fixtures/sensitive.diff b/examples/skills_code_review_agent/fixtures/sensitive.diff new file mode 100644 index 000000000..c82c607a8 --- /dev/null +++ b/examples/skills_code_review_agent/fixtures/sensitive.diff @@ -0,0 +1,5 @@ +diff --git a/settings.py b/settings.py +--- a/settings.py ++++ b/settings.py +@@ -0,0 +1 @@ ++api_key = "sk-example-secret-value-123456" diff --git a/examples/skills_code_review_agent/fixtures/unsafe.diff b/examples/skills_code_review_agent/fixtures/unsafe.diff new file mode 100644 index 000000000..ebc2e09c7 --- /dev/null +++ b/examples/skills_code_review_agent/fixtures/unsafe.diff @@ -0,0 +1,7 @@ +diff --git a/app.py b/app.py +index e69de29..d95f3ad 100644 +--- a/app.py ++++ b/app.py +@@ -0,0 +1,2 @@ ++import subprocess ++subprocess.run(user_input, shell=True) diff --git a/examples/skills_code_review_agent/governance/__init__.py b/examples/skills_code_review_agent/governance/__init__.py new file mode 100644 index 000000000..5095e3fe3 --- /dev/null +++ b/examples/skills_code_review_agent/governance/__init__.py @@ -0,0 +1,5 @@ +"""Execution governance: analyzers, policies, engine, and audit decisions.""" + +from .filter_engine import FilterEngine + +__all__ = ["FilterEngine"] diff --git a/examples/skills_code_review_agent/governance/analyzer/__init__.py b/examples/skills_code_review_agent/governance/analyzer/__init__.py new file mode 100644 index 000000000..096e433d6 --- /dev/null +++ b/examples/skills_code_review_agent/governance/analyzer/__init__.py @@ -0,0 +1,15 @@ +"""Request risk analyzers.""" + +from .command_analyzer import analyze_command +from .environment_analyzer import analyze_environment +from .network_analyzer import analyze_network +from .path_analyzer import analyze_paths +from .resource_analyzer import analyze_resources + +__all__ = [ + "analyze_command", + "analyze_environment", + "analyze_network", + "analyze_paths", + "analyze_resources", +] diff --git a/examples/skills_code_review_agent/governance/analyzer/command_analyzer.py b/examples/skills_code_review_agent/governance/analyzer/command_analyzer.py new file mode 100644 index 000000000..e0fa85591 --- /dev/null +++ b/examples/skills_code_review_agent/governance/analyzer/command_analyzer.py @@ -0,0 +1,49 @@ +"""Command allowlist and shell-composition analysis.""" + +import os + +from ...agent.models import Decision, ExecutionRequest +from ..models import AnalysisResult, RiskLevel +from ..policy import GovernancePolicy + + +def analyze_command(request: ExecutionRequest, policy: GovernancePolicy) -> AnalysisResult: + if not request.command: + return AnalysisResult( + decision=Decision.DENY, + risk_level=RiskLevel.HIGH, + reason="No command was provided.", + matched_rule="command_empty", + ) + executable = os.path.basename(request.command[0]) + if executable in policy.denied_commands: + return AnalysisResult( + decision=Decision.DENY, + risk_level=RiskLevel.HIGH, + reason=f"Command {executable} is explicitly forbidden.", + matched_rule="command_denied", + ) + tokens = [os.path.basename(token) for token in request.command] + if "|" in request.command and any(token in {"bash", "sh"} for token in tokens): + return AnalysisResult( + decision=Decision.DENY, + risk_level=RiskLevel.HIGH, + reason="Piping command output into a shell is forbidden.", + matched_rule="shell_pipe_execution", + ) + versioned_python = executable.startswith("python3.") and "python3" in policy.allowed_commands + if executable not in policy.allowed_commands and not versioned_python: + return AnalysisResult( + decision=Decision.DENY, + risk_level=RiskLevel.HIGH, + reason=f"Command {executable} is not allowlisted.", + matched_rule="command_not_allowed", + ) + if any(argument in policy.dangerous_arguments for argument in request.command[1:]): + return AnalysisResult( + decision=Decision.NEEDS_HUMAN_REVIEW, + risk_level=RiskLevel.MEDIUM, + reason="Inline executable code requires human approval.", + matched_rule="inline_code", + ) + return AnalysisResult() diff --git a/examples/skills_code_review_agent/governance/analyzer/environment_analyzer.py b/examples/skills_code_review_agent/governance/analyzer/environment_analyzer.py new file mode 100644 index 000000000..d8dfba97c --- /dev/null +++ b/examples/skills_code_review_agent/governance/analyzer/environment_analyzer.py @@ -0,0 +1,17 @@ +"""Environment-variable exposure control.""" + +from ...agent.models import Decision, ExecutionRequest +from ..models import AnalysisResult, RiskLevel +from ..policy import GovernancePolicy + + +def analyze_environment(request: ExecutionRequest, policy: GovernancePolicy) -> AnalysisResult: + unknown = set(request.env) - policy.allowed_environment + if unknown: + return AnalysisResult( + decision=Decision.DENY, + risk_level=RiskLevel.HIGH, + reason=f"Environment variables are not allowed: {', '.join(sorted(unknown))}.", + matched_rule="environment_not_allowed", + ) + return AnalysisResult() diff --git a/examples/skills_code_review_agent/governance/analyzer/network_analyzer.py b/examples/skills_code_review_agent/governance/analyzer/network_analyzer.py new file mode 100644 index 000000000..c6076a1d2 --- /dev/null +++ b/examples/skills_code_review_agent/governance/analyzer/network_analyzer.py @@ -0,0 +1,17 @@ +"""Default-deny network policy.""" + +from ...agent.models import Decision, ExecutionRequest +from ..models import AnalysisResult, RiskLevel +from ..policy import GovernancePolicy + + +def analyze_network(request: ExecutionRequest, policy: GovernancePolicy) -> AnalysisResult: + denied = set(request.network_targets) - policy.allowed_network_targets + if denied: + return AnalysisResult( + decision=Decision.DENY, + risk_level=RiskLevel.HIGH, + reason="Network target is not allowlisted.", + matched_rule="network_not_allowed", + ) + return AnalysisResult() diff --git a/examples/skills_code_review_agent/governance/analyzer/path_analyzer.py b/examples/skills_code_review_agent/governance/analyzer/path_analyzer.py new file mode 100644 index 000000000..58fcb80c4 --- /dev/null +++ b/examples/skills_code_review_agent/governance/analyzer/path_analyzer.py @@ -0,0 +1,37 @@ +"""Workspace containment and protected-path analysis.""" + +from pathlib import Path + +from ...agent.models import Decision, ExecutionRequest +from ..models import AnalysisResult, RiskLevel +from ..policy import GovernancePolicy + + +def _inside(path: str, root: str) -> bool: + candidate, base = Path(path).expanduser().resolve(), Path(root).resolve() + return candidate == base or candidate.is_relative_to(base) + + +def analyze_paths( + request: ExecutionRequest, + policy: GovernancePolicy, + workspace_root: str, +) -> AnalysisResult: + for raw_path in [request.cwd, *request.input_paths]: + path = Path(raw_path).expanduser() + if not _inside(str(path), workspace_root): + return AnalysisResult( + decision=Decision.DENY, + risk_level=RiskLevel.HIGH, + reason="A requested path escapes the workspace.", + matched_rule="path_escape", + ) + normalized = path.as_posix().lower() + if any(marker.lower() in normalized for marker in policy.protected_paths): + return AnalysisResult( + decision=Decision.DENY, + risk_level=RiskLevel.HIGH, + reason="A protected path was requested.", + matched_rule="protected_path", + ) + return AnalysisResult() diff --git a/examples/skills_code_review_agent/governance/analyzer/resource_analyzer.py b/examples/skills_code_review_agent/governance/analyzer/resource_analyzer.py new file mode 100644 index 000000000..a6c48f3a1 --- /dev/null +++ b/examples/skills_code_review_agent/governance/analyzer/resource_analyzer.py @@ -0,0 +1,27 @@ +"""Per-request and cumulative execution budget analysis.""" + +from ...agent.models import Decision, ExecutionBudget, ExecutionRequest +from ..models import AnalysisResult, RiskLevel +from ..policy import GovernancePolicy + + +def analyze_resources( + request: ExecutionRequest, + policy: GovernancePolicy, + budget: ExecutionBudget, +) -> AnalysisResult: + if request.timeout > policy.max_timeout_seconds or request.memory_limit_mb > policy.max_memory_mb: + return AnalysisResult( + decision=Decision.NEEDS_HUMAN_REVIEW, + risk_level=RiskLevel.MEDIUM, + reason="Requested resources exceed the per-execution policy.", + matched_rule="resource_limit_exceeded", + ) + if budget.calls_used >= budget.max_calls or budget.seconds_used + request.timeout > budget.max_total_seconds: + return AnalysisResult( + decision=Decision.DENY, + risk_level=RiskLevel.HIGH, + reason="Execution budget is exhausted.", + matched_rule="budget_exceeded", + ) + return AnalysisResult() diff --git a/examples/skills_code_review_agent/governance/filter_engine.py b/examples/skills_code_review_agent/governance/filter_engine.py new file mode 100644 index 000000000..a9608f3d3 --- /dev/null +++ b/examples/skills_code_review_agent/governance/filter_engine.py @@ -0,0 +1,73 @@ +"""Aggregate independent risk analyzers into one fail-closed decision.""" + +from __future__ import annotations + +import hashlib +from collections.abc import Callable + +from ..agent.models import Decision, ExecutionBudget, ExecutionRequest, FilterDecision +from .analyzer import ( + analyze_command, + analyze_environment, + analyze_network, + analyze_paths, + analyze_resources, +) +from .models import AnalysisResult, RiskLevel +from .policy import GovernancePolicy, load_policy + +DecisionSink = Callable[[FilterDecision], None] + + +class FilterEngine: + def __init__( + self, + workspace_root: str, + *, + policy: GovernancePolicy | None = None, + budget: ExecutionBudget | None = None, + decision_sink: DecisionSink | None = None, + ) -> None: + self.workspace_root = workspace_root + self.policy = policy or load_policy() + self.budget = budget or ExecutionBudget() + self.decision_sink = decision_sink + + def check(self, request: ExecutionRequest) -> FilterDecision: + try: + results = [ + analyze_command(request, self.policy), + analyze_paths(request, self.policy, self.workspace_root), + analyze_network(request, self.policy), + analyze_environment(request, self.policy), + analyze_resources(request, self.policy, self.budget), + ] + result = self._aggregate(results) + decision = FilterDecision( + decision=result.decision, + reason_code=result.matched_rule, + reason=result.reason, + risk_level=result.risk_level.name.lower(), + matched_rule=result.matched_rule, + task_id=request.task_id, + command_digest=hashlib.sha256("\0".join(request.command).encode()).hexdigest(), + ) + except Exception as exc: + decision = FilterDecision( + decision=Decision.DENY, + reason_code="filter_internal_error", + reason=f"Governance evaluation failed: {type(exc).__name__}", + risk_level="high", + matched_rule="filter_internal_error", + task_id=request.task_id, + ) + if decision.decision == Decision.ALLOW: + self.budget.calls_used += 1 + if self.decision_sink: + self.decision_sink(decision) + return decision + + @staticmethod + def _aggregate(results: list[AnalysisResult]) -> AnalysisResult: + priority = {Decision.ALLOW: 0, Decision.NEEDS_HUMAN_REVIEW: 1, Decision.DENY: 2} + return max(results, key=lambda item: (priority[item.decision], int(item.risk_level))) diff --git a/examples/skills_code_review_agent/governance/models/__init__.py b/examples/skills_code_review_agent/governance/models/__init__.py new file mode 100644 index 000000000..759f1e5d6 --- /dev/null +++ b/examples/skills_code_review_agent/governance/models/__init__.py @@ -0,0 +1,5 @@ +"""Governance analysis models.""" + +from .decision import AnalysisResult, RiskLevel + +__all__ = ["AnalysisResult", "RiskLevel"] diff --git a/examples/skills_code_review_agent/governance/models/decision.py b/examples/skills_code_review_agent/governance/models/decision.py new file mode 100644 index 000000000..9df3727bb --- /dev/null +++ b/examples/skills_code_review_agent/governance/models/decision.py @@ -0,0 +1,20 @@ +"""Intermediate analyzer result models.""" + +from enum import IntEnum + +from pydantic import BaseModel + +from ...agent.models import Decision + + +class RiskLevel(IntEnum): + LOW = 1 + MEDIUM = 2 + HIGH = 3 + + +class AnalysisResult(BaseModel): + decision: Decision = Decision.ALLOW + risk_level: RiskLevel = RiskLevel.LOW + reason: str = "Policy check passed." + matched_rule: str = "allowed" diff --git a/examples/skills_code_review_agent/governance/policy/__init__.py b/examples/skills_code_review_agent/governance/policy/__init__.py new file mode 100644 index 000000000..81c167920 --- /dev/null +++ b/examples/skills_code_review_agent/governance/policy/__init__.py @@ -0,0 +1,5 @@ +"""Governance policy loading.""" + +from .loader import GovernancePolicy, load_policy + +__all__ = ["GovernancePolicy", "load_policy"] diff --git a/examples/skills_code_review_agent/governance/policy/default_policy.yaml b/examples/skills_code_review_agent/governance/policy/default_policy.yaml new file mode 100644 index 000000000..d43ef621f --- /dev/null +++ b/examples/skills_code_review_agent/governance/policy/default_policy.yaml @@ -0,0 +1,27 @@ +allowed_commands: + - python + - python3 + - ruff + - pytest + - mypy +denied_commands: + - rm + - wget + - chmod + - mkfs + - dd +dangerous_arguments: + - -c + - --eval +protected_paths: + - .env + - .ssh + - credentials +allowed_environment: + - LANG + - LC_ALL + - PYTHONPATH + - PYTHONDONTWRITEBYTECODE +allowed_network_targets: [] +max_timeout_seconds: 300 +max_memory_mb: 1024 diff --git a/examples/skills_code_review_agent/governance/policy/loader.py b/examples/skills_code_review_agent/governance/policy/loader.py new file mode 100644 index 000000000..81958e1bc --- /dev/null +++ b/examples/skills_code_review_agent/governance/policy/loader.py @@ -0,0 +1,24 @@ +"""Load the small, review-specific governance policy set.""" + +from __future__ import annotations + +from pathlib import Path + +import yaml +from pydantic import BaseModel, Field + + +class GovernancePolicy(BaseModel): + allowed_commands: set[str] = Field(default_factory=set) + denied_commands: set[str] = Field(default_factory=set) + dangerous_arguments: set[str] = Field(default_factory=set) + protected_paths: set[str] = Field(default_factory=set) + allowed_environment: set[str] = Field(default_factory=set) + allowed_network_targets: set[str] = Field(default_factory=set) + max_timeout_seconds: float = 300 + max_memory_mb: int = 1024 + + +def load_policy(path: str | Path | None = None) -> GovernancePolicy: + policy_path = Path(path) if path else Path(__file__).with_name("default_policy.yaml") + return GovernancePolicy.model_validate(yaml.safe_load(policy_path.read_text(encoding="utf-8"))) diff --git a/examples/skills_code_review_agent/run_agent.py b/examples/skills_code_review_agent/run_agent.py new file mode 100644 index 000000000..0e0232a39 --- /dev/null +++ b/examples/skills_code_review_agent/run_agent.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +"""CLI entry point for one automatic review.""" + +from __future__ import annotations + +import argparse +import asyncio +import sys +from pathlib import Path + +if __package__ in {None, ""}: + sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +from examples.skills_code_review_agent.agent.input_parser import parse_review_input +from examples.skills_code_review_agent.agent.models import ReviewRequest +from examples.skills_code_review_agent.agent.reporting import write_reports +from examples.skills_code_review_agent.agent.review import run_review +from examples.skills_code_review_agent.agent.sanitizer import redact_sensitive_text +from examples.skills_code_review_agent.agent.storage import ReviewRepository + + +async def create_workspace_runtime(name: str): + """Create the selected SDK workspace runtime with network disabled where supported.""" + if name == "local": + from trpc_agent_sdk.code_executors import create_local_workspace_runtime + + return create_local_workspace_runtime() + if name == "container": + from trpc_agent_sdk.code_executors import create_container_workspace_runtime + + return create_container_workspace_runtime(host_config={"network_mode": "none"}) + from trpc_agent_sdk.code_executors.cube import ( + CubeClientConfig, + create_cube_sandbox_client, + create_cube_workspace_runtime, + ) + + config = CubeClientConfig(execute_timeout=30.0, auto_recover=True) + client = await create_cube_sandbox_client(config) + return create_cube_workspace_runtime( + sandbox_client=client, + execute_timeout=config.execute_timeout, + ) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Run a policy-governed automatic code review.") + source = parser.add_mutually_exclusive_group(required=True) + source.add_argument("--diff-file") + source.add_argument("--repo-path") + source.add_argument("--fixture") + source.add_argument( + "--file-list", + help="Text file containing project-relative source paths, one per line.", + ) + parser.add_argument("--runtime", choices=("container", "cube", "local"), default="container") + parser.add_argument("--dry-run", action="store_true") + parser.add_argument("--task-id", help="Optional stable task identifier for replay.") + parser.add_argument("--deterministic-only", action="store_true", default=True) + parser.add_argument("--fake-model", action="store_true", help=argparse.SUPPRESS) + parser.add_argument( + "--db-url", + default="sqlite:///examples/skills_code_review_agent/result/review.db", + ) + parser.add_argument( + "--output-dir", + default="examples/skills_code_review_agent/result", + ) + return parser + + +async def _main(args: argparse.Namespace) -> int: + review_input = parse_review_input( + diff_file=args.diff_file, + repo_path=args.repo_path, + fixture_path=args.fixture, + file_list=args.file_list, + ) + if args.db_url == "sqlite:///examples/skills_code_review_agent/result/review.db": + Path("examples/skills_code_review_agent/result").mkdir( + parents=True, + exist_ok=True, + ) + repository = ReviewRepository(args.db_url) + repository.initialize() + runtime = await create_workspace_runtime(args.runtime) + try: + report = await run_review( + ReviewRequest( + review_input=review_input, + runtime=args.runtime, + dry_run=args.dry_run, + fake_model=True, + task_id=args.task_id, + ), + runtime=runtime, + repository=repository, + ) + finally: + if args.runtime == "cube": + await runtime.destroy() + paths = write_reports(report, args.output_dir) + print(f"{report.conclusion}\nJSON: {paths.json_path}\nMarkdown: {paths.markdown_path}") + return 0 if report.status in {"completed", "partial"} else 1 + + +def main(argv: list[str] | None = None) -> int: + try: + return asyncio.run(_main(build_parser().parse_args(argv))) + except Exception as exc: + print(f"review failed: {redact_sensitive_text(exc)}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/skills_code_review_agent/sample_output/example-security/filter_events.json b/examples/skills_code_review_agent/sample_output/example-security/filter_events.json new file mode 100644 index 000000000..90a28ca4f --- /dev/null +++ b/examples/skills_code_review_agent/sample_output/example-security/filter_events.json @@ -0,0 +1,11 @@ +[ + { + "task_id": "d460c8fba3e84a8189c2d0cd566241ab", + "command_digest": "13d6f2050de71ed06af82248fdddb87f2d648e4e82705b4564bf5de247530d44", + "decision": "allow", + "risk_level": "low", + "reason": "Policy check passed.", + "matched_rule": "allowed", + "created_at": "2026-07-28T07:55:45.878700+00:00" + } +] diff --git a/examples/skills_code_review_agent/sample_output/example-security/review_report.json b/examples/skills_code_review_agent/sample_output/example-security/review_report.json new file mode 100644 index 000000000..ba18cd073 --- /dev/null +++ b/examples/skills_code_review_agent/sample_output/example-security/review_report.json @@ -0,0 +1,99 @@ +{ + "task_id": "example-security", + "status": "completed", + "conclusion": "Found 2 actionable issue(s).", + "input_summary": "1 changed file(s), 1 added line(s)", + "findings": [ + { + "severity": "high", + "category": "security", + "file": "app.py", + "line": 1, + "title": "Unsafe shell execution", + "evidence": "subprocess.run(user_input, shell=True)", + "recommendation": "Pass a fixed argument vector and keep shell disabled.", + "confidence": 0.94, + "source": "ast_detector", + "needs_human_review": false, + "dedupe_key": "599e909d031a94ab681c3315bb60216c53c0a422833a2f066db689d08bce7a8a", + "rule_id": "unsafe-shell-execution", + "rule_version": "1", + "validation_status": "pending" + }, + { + "severity": "low", + "category": "testing", + "file": "app.py", + "line": 1, + "title": "No related test change", + "evidence": "subprocess.run(user_input, shell=True)", + "recommendation": "Add or update a focused test for this behavior.", + "confidence": 0.68, + "source": "regex_detector", + "needs_human_review": false, + "dedupe_key": "dd729d0f70f45c3ba64b4a53d3f7be2261b05a54a2c7abbd57cfe6094be71ee6", + "rule_id": "missing-test-change", + "rule_version": "1", + "validation_status": "pending" + } + ], + "needs_human_review": [], + "warnings": [], + "sandbox_runs": [ + { + "id": "d460c8fba3e84a8189c2d0cd566241ab", + "runtime": "local", + "task_type": "custom_rule", + "command": [ + "/usr/bin/python3", + "/tmp/ws_3f930668ab7ad7d7b60267942cb8a3ed_1785225345862387197/code-review/runner.py", + "/tmp/ws_3f930668ab7ad7d7b60267942cb8a3ed_1785225345862387197/review.json" + ], + "status": "completed", + "exit_code": 0, + "timed_out": false, + "duration_ms": 125, + "stdout_summary": "[{\"severity\": \"high\", \"category\": \"security\", \"file\": \"app.py\", \"line\": 1, \"title\": \"Unsafe shell execution\", \"evidence\": \"subprocess.run(user_input, shell=True)\", \"recommendation\": \"Pass a fixed argument vector and keep shell disabled.\", \"confidence\": 0.94, \"source\": \"ast_detector\", \"rule_id\": \"unsafe-shell-execution\", \"rule_version\": \"1\", \"validation_status\": \"pending\"}, {\"severity\": \"low\", \"category\": \"testing\", \"file\": \"app.py\", \"line\": 1, \"title\": \"No related test change\", \"evidence\": \"subprocess.run(user_input, shell=True)\", \"recommendation\": \"Add or update a focused test for this behavior.\", \"confidence\": 0.68, \"source\": \"regex_detector\", \"rule_id\": \"missing-test-change\", \"rule_version\": \"1\", \"validation_status\": \"pending\"}]\n", + "stderr_summary": "", + "decision": { + "decision": "allow", + "reason_code": "allowed", + "reason": "Policy check passed.", + "risk_level": "low", + "matched_rule": "allowed", + "task_id": "d460c8fba3e84a8189c2d0cd566241ab", + "command_digest": "13d6f2050de71ed06af82248fdddb87f2d648e4e82705b4564bf5de247530d44", + "created_at": "2026-07-28T07:55:45.878700Z" + } + } + ], + "filter_decisions": [ + { + "decision": "allow", + "reason_code": "allowed", + "reason": "Policy check passed.", + "risk_level": "low", + "matched_rule": "allowed", + "task_id": "d460c8fba3e84a8189c2d0cd566241ab", + "command_digest": "13d6f2050de71ed06af82248fdddb87f2d648e4e82705b4564bf5de247530d44", + "created_at": "2026-07-28T07:55:45.878700Z" + } + ], + "metrics": { + "total_duration_ms": 262, + "stage_duration_ms": { + "custom_rule": 125 + }, + "tool_calls": 1, + "blocked_executions": 0, + "finding_severity": { + "high": 1, + "low": 1 + }, + "errors": {} + }, + "dry_run": false, + "rule_set_digest": "daf2588b52f9ba2a84239d97305de003d2d36082a0eefb0e2c0049447674c889", + "skipped_checks": [], + "created_at": "2026-07-28T07:55:46.009786Z" +} diff --git a/examples/skills_code_review_agent/sample_output/example-security/review_report.md b/examples/skills_code_review_agent/sample_output/example-security/review_report.md new file mode 100644 index 000000000..ed81d44c3 --- /dev/null +++ b/examples/skills_code_review_agent/sample_output/example-security/review_report.md @@ -0,0 +1,48 @@ +# Code Review Report + +- Task: `example-security` +- Status: **completed** +- Dry run: **no** +- Input: 1 changed file(s), 1 added line(s) +- Rule set digest: `daf2588b52f9ba2a84239d97305de003d2d36082a0eefb0e2c0049447674c889` +- Conclusion: Found 2 actionable issue(s). + +## Severity summary + +- high: 1 +- low: 1 + +## Findings + +### [HIGH] Unsafe shell execution + +`app.py:1` · security · confidence 0.94 + +subprocess.run(user_input, shell=True) + +Recommendation: Pass a fixed argument vector and keep shell disabled. + +### [LOW] No related test change + +`app.py:1` · testing · confidence 0.68 + +subprocess.run(user_input, shell=True) + +Recommendation: Add or update a focused test for this behavior. + +## Human review + +- None + +## Execution and policy + +- Sandbox checks: 1 +- Blocked decisions: 0 +- `custom_rule`: completed, exit=0, duration=125ms + +## Monitoring + +- Total duration: 262ms +- Tool calls: 1 +- Filter blocks: 0 +- Errors: {} diff --git a/examples/skills_code_review_agent/sandbox/__init__.py b/examples/skills_code_review_agent/sandbox/__init__.py new file mode 100644 index 000000000..66de6609b --- /dev/null +++ b/examples/skills_code_review_agent/sandbox/__init__.py @@ -0,0 +1,5 @@ +"""Layered sandbox execution infrastructure for the review example.""" + +from .runner import SandboxRunner + +__all__ = ["SandboxRunner"] diff --git a/examples/skills_code_review_agent/sandbox/executor/__init__.py b/examples/skills_code_review_agent/sandbox/executor/__init__.py new file mode 100644 index 000000000..047c03c0e --- /dev/null +++ b/examples/skills_code_review_agent/sandbox/executor/__init__.py @@ -0,0 +1,6 @@ +"""Sandbox executor implementations.""" + +from .base import SandboxExecutor +from .workspace_executor import WorkspaceExecutor + +__all__ = ["SandboxExecutor", "WorkspaceExecutor"] diff --git a/examples/skills_code_review_agent/sandbox/executor/base.py b/examples/skills_code_review_agent/sandbox/executor/base.py new file mode 100644 index 000000000..03e79805a --- /dev/null +++ b/examples/skills_code_review_agent/sandbox/executor/base.py @@ -0,0 +1,17 @@ +"""Executor contract independent of Container, Cube, and local backends.""" + +from abc import ABC, abstractmethod +from typing import Any + +from ..models import SandboxExecutionResult, SandboxTask + + +class SandboxExecutor(ABC): + @abstractmethod + async def execute( + self, + workspace: Any, + task: SandboxTask, + decision: Any, + ) -> SandboxExecutionResult: + """Execute one already-authorized task.""" diff --git a/examples/skills_code_review_agent/sandbox/executor/workspace_executor.py b/examples/skills_code_review_agent/sandbox/executor/workspace_executor.py new file mode 100644 index 000000000..0a4cc8dde --- /dev/null +++ b/examples/skills_code_review_agent/sandbox/executor/workspace_executor.py @@ -0,0 +1,58 @@ +"""Executor backed by the SDK's common Workspace Runtime interface.""" + +from __future__ import annotations + +import time +from typing import Any + +from trpc_agent_sdk.code_executors import WorkspaceRunProgramSpec + +from ..models import SandboxExecutionResult, SandboxTask +from ..policy import to_workspace_limits +from .base import SandboxExecutor + + +class WorkspaceExecutor(SandboxExecutor): + def __init__(self, runtime: Any) -> None: + self._runner = runtime.runner() + + async def execute(self, workspace: Any, task: SandboxTask, decision: Any) -> SandboxExecutionResult: + started = time.monotonic() + try: + result = await self._runner.run_program( + workspace, + WorkspaceRunProgramSpec( + cmd=task.command[0], + args=task.command[1:], + cwd=task.cwd, + env=task.env, + timeout=task.resources.timeout_seconds, + limits=to_workspace_limits(task.resources), + ), + ) + duration_ms = round((time.monotonic() - started) * 1000) + stdout = result.stdout[: task.resources.max_output_bytes] + stderr = result.stderr[: task.resources.max_output_bytes] + truncated = len(result.stdout) > len(stdout) or len(result.stderr) > len(stderr) + status = "timed_out" if result.timed_out else ("completed" if result.exit_code == 0 else "failed") + return SandboxExecutionResult( + task_id=task.id, + status=status, + exit_code=result.exit_code, + timed_out=result.timed_out, + duration_ms=duration_ms, + stdout=stdout, + stderr=stderr, + output_truncated=truncated, + error_type="execution_timeout" if result.timed_out else None, + decision=decision, + ) + except Exception as exc: + return SandboxExecutionResult( + task_id=task.id, + status="failed", + duration_ms=round((time.monotonic() - started) * 1000), + stderr=str(exc), + error_type=type(exc).__name__, + decision=decision, + ) diff --git a/examples/skills_code_review_agent/sandbox/models/__init__.py b/examples/skills_code_review_agent/sandbox/models/__init__.py new file mode 100644 index 000000000..868f0d7df --- /dev/null +++ b/examples/skills_code_review_agent/sandbox/models/__init__.py @@ -0,0 +1,5 @@ +"""Sandbox domain models.""" + +from .execution_result import ResourcePolicy, SandboxExecutionResult, SandboxTask + +__all__ = ["ResourcePolicy", "SandboxExecutionResult", "SandboxTask"] diff --git a/examples/skills_code_review_agent/sandbox/models/execution_result.py b/examples/skills_code_review_agent/sandbox/models/execution_result.py new file mode 100644 index 000000000..31474ab47 --- /dev/null +++ b/examples/skills_code_review_agent/sandbox/models/execution_result.py @@ -0,0 +1,39 @@ +"""Backend-neutral sandbox task and result models.""" + +from __future__ import annotations + +from pydantic import BaseModel, Field + +from ...agent.models import FilterDecision + + +class ResourcePolicy(BaseModel): + timeout_seconds: float = 30.0 + cpu_percent: int = 100 + memory_mb: int = 512 + max_pids: int = 64 + max_output_bytes: int = 64_000 + + +class SandboxTask(BaseModel): + id: str + task_type: str + command: list[str] + cwd: str + input_paths: list[str] = Field(default_factory=list) + env: dict[str, str] = Field(default_factory=dict) + network_targets: list[str] = Field(default_factory=list) + resources: ResourcePolicy = Field(default_factory=ResourcePolicy) + + +class SandboxExecutionResult(BaseModel): + task_id: str + status: str + exit_code: int | None = None + timed_out: bool = False + duration_ms: int = 0 + stdout: str = "" + stderr: str = "" + output_truncated: bool = False + error_type: str | None = None + decision: FilterDecision diff --git a/examples/skills_code_review_agent/sandbox/policy/__init__.py b/examples/skills_code_review_agent/sandbox/policy/__init__.py new file mode 100644 index 000000000..e8661c4e9 --- /dev/null +++ b/examples/skills_code_review_agent/sandbox/policy/__init__.py @@ -0,0 +1,5 @@ +"""Sandbox resource policy conversion.""" + +from .resource_limit import to_workspace_limits + +__all__ = ["to_workspace_limits"] diff --git a/examples/skills_code_review_agent/sandbox/policy/resource_limit.py b/examples/skills_code_review_agent/sandbox/policy/resource_limit.py new file mode 100644 index 000000000..8ef20e975 --- /dev/null +++ b/examples/skills_code_review_agent/sandbox/policy/resource_limit.py @@ -0,0 +1,13 @@ +"""Translate application resource policy to SDK limits.""" + +from trpc_agent_sdk.code_executors import WorkspaceResourceLimits + +from ..models import ResourcePolicy + + +def to_workspace_limits(policy: ResourcePolicy) -> WorkspaceResourceLimits: + return WorkspaceResourceLimits( + cpu_percent=policy.cpu_percent, + memory_mb=policy.memory_mb, + max_pids=policy.max_pids, + ) diff --git a/examples/skills_code_review_agent/sandbox/runner.py b/examples/skills_code_review_agent/sandbox/runner.py new file mode 100644 index 000000000..ec03ce440 --- /dev/null +++ b/examples/skills_code_review_agent/sandbox/runner.py @@ -0,0 +1,38 @@ +"""Policy → executor orchestration for every sandbox task type.""" + +from __future__ import annotations + +from typing import Any + +from ..agent.models import Decision, ExecutionBudget, ExecutionRequest +from ..agent.policy import ReviewExecutionFilter +from .executor import WorkspaceExecutor +from .models import SandboxExecutionResult, SandboxTask + + +class SandboxRunner: + def __init__(self, runtime: Any, *, budget: ExecutionBudget | None = None) -> None: + self.executor = WorkspaceExecutor(runtime) + self.budget = budget or ExecutionBudget() + + async def run(self, workspace: Any, task: SandboxTask, *, dry_run: bool = False) -> SandboxExecutionResult: + request = ExecutionRequest( + task_id=task.id, + command=task.command, + cwd=task.cwd, + input_paths=task.input_paths, + network_targets=task.network_targets, + env=task.env, + timeout=task.resources.timeout_seconds, + memory_limit_mb=task.resources.memory_mb, + ) + decision = ReviewExecutionFilter(task.cwd, budget=self.budget).run(request) + if decision.decision != Decision.ALLOW or dry_run: + return SandboxExecutionResult( + task_id=task.id, + status="dry_run" if dry_run else "blocked", + decision=decision, + ) + result = await self.executor.execute(workspace, task, decision) + self.budget.seconds_used += result.duration_ms / 1000 + return result diff --git a/examples/skills_code_review_agent/sandbox/runtime/__init__.py b/examples/skills_code_review_agent/sandbox/runtime/__init__.py new file mode 100644 index 000000000..dd8569a48 --- /dev/null +++ b/examples/skills_code_review_agent/sandbox/runtime/__init__.py @@ -0,0 +1,5 @@ +"""Workspace lifecycle and staging.""" + +from .workspace import PreparedWorkspace, WorkspaceManager + +__all__ = ["PreparedWorkspace", "WorkspaceManager"] diff --git a/examples/skills_code_review_agent/sandbox/runtime/workspace.py b/examples/skills_code_review_agent/sandbox/runtime/workspace.py new file mode 100644 index 000000000..fe63dfe65 --- /dev/null +++ b/examples/skills_code_review_agent/sandbox/runtime/workspace.py @@ -0,0 +1,69 @@ +"""Prepare and reliably clean up isolated workspaces.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from trpc_agent_sdk.code_executors import WorkspacePutFileInfo + + +@dataclass +class PreparedWorkspace: + info: Any + cleanup_id: str + + +class WorkspaceManager: + def __init__(self, runtime: Any) -> None: + self._manager = runtime.manager() + self._fs = runtime.fs() + + async def prepare( + self, + execution_id: str, + *, + directories: dict[str, Path], + files: dict[str, bytes], + max_file_bytes: int = 2_000_000, + max_total_bytes: int = 50_000_000, + ) -> PreparedWorkspace: + workspace = await self._manager.create_workspace(execution_id) + try: + staged: list[WorkspacePutFileInfo] = [] + total = 0 + for destination, source in directories.items(): + for path in source.rglob("*"): + relative = path.relative_to(source) + if ( + not path.is_file() + or any(part in {".git", "__pycache__", ".venv", "node_modules"} for part in relative.parts) + or path.suffix == ".pyc" + or path.is_symlink() + ): + continue + size = path.stat().st_size + if size > max_file_bytes: + continue + total += size + if total > max_total_bytes: + raise ValueError("project snapshot exceeds the configured size limit") + staged.append( + WorkspacePutFileInfo( + path=f"{destination.rstrip('/')}/{relative.as_posix()}", + content=path.read_bytes(), + ) + ) + staged.extend( + WorkspacePutFileInfo(path=path, content=content) + for path, content in files.items() + ) + await self._fs.put_files(workspace, staged) + return PreparedWorkspace(info=workspace, cleanup_id=workspace.id or execution_id) + except Exception: + await self._manager.cleanup(workspace.id or execution_id) + raise + + async def cleanup(self, workspace: PreparedWorkspace) -> None: + await self._manager.cleanup(workspace.cleanup_id) diff --git a/examples/skills_code_review_agent/sandbox/tasks/__init__.py b/examples/skills_code_review_agent/sandbox/tasks/__init__.py new file mode 100644 index 000000000..1c50fab84 --- /dev/null +++ b/examples/skills_code_review_agent/sandbox/tasks/__init__.py @@ -0,0 +1,13 @@ +"""Concrete sandbox task builders.""" + +from .custom_rule import build_code_review_task +from .diff_parser import build_diff_parser_task +from .run_test import build_test_task +from .static_check import build_static_check_task + +__all__ = [ + "build_code_review_task", + "build_diff_parser_task", + "build_static_check_task", + "build_test_task", +] diff --git a/examples/skills_code_review_agent/sandbox/tasks/custom_rule.py b/examples/skills_code_review_agent/sandbox/tasks/custom_rule.py new file mode 100644 index 000000000..465660ccb --- /dev/null +++ b/examples/skills_code_review_agent/sandbox/tasks/custom_rule.py @@ -0,0 +1,22 @@ +"""Custom code-review rule task.""" + +import sys +import uuid + +from ..models import ResourcePolicy, SandboxTask + + +def build_code_review_task(workspace_path: str, runtime_name: str) -> SandboxTask: + root = workspace_path.rstrip("/") + executable = sys.executable if runtime_name == "local" else "python3" + script = f"{root}/code-review/runner.py" + review_input = f"{root}/review.json" + return SandboxTask( + id=uuid.uuid4().hex, + task_type="custom_rule", + command=[executable, script, review_input], + cwd=workspace_path, + input_paths=[script, review_input], + env={"PYTHONDONTWRITEBYTECODE": "1"}, + resources=ResourcePolicy(timeout_seconds=20), + ) diff --git a/examples/skills_code_review_agent/sandbox/tasks/diff_parser.py b/examples/skills_code_review_agent/sandbox/tasks/diff_parser.py new file mode 100644 index 000000000..c064e5fc4 --- /dev/null +++ b/examples/skills_code_review_agent/sandbox/tasks/diff_parser.py @@ -0,0 +1,22 @@ +"""Sandboxed unified-diff parser task builder.""" + +import sys +import uuid + +from ..models import ResourcePolicy, SandboxTask + + +def build_diff_parser_task(workspace_path: str, runtime_name: str) -> SandboxTask: + root = workspace_path.rstrip("/") + executable = sys.executable if runtime_name == "local" else "python3" + script = f"{root}/code-review/scripts/parse_diff.py" + diff_path = f"{root}/change.diff" + return SandboxTask( + id=uuid.uuid4().hex, + task_type="diff_parser", + command=[executable, script, diff_path], + cwd=workspace_path, + input_paths=[script, diff_path], + env={"PYTHONDONTWRITEBYTECODE": "1"}, + resources=ResourcePolicy(timeout_seconds=20), + ) diff --git a/examples/skills_code_review_agent/sandbox/tasks/run_test.py b/examples/skills_code_review_agent/sandbox/tasks/run_test.py new file mode 100644 index 000000000..43f0cfbe5 --- /dev/null +++ b/examples/skills_code_review_agent/sandbox/tasks/run_test.py @@ -0,0 +1,16 @@ +"""Unit-test task builder.""" + +import uuid + +from ..models import ResourcePolicy, SandboxTask + + +def build_test_task(workspace_path: str, test_paths: list[str]) -> SandboxTask: + return SandboxTask( + id=uuid.uuid4().hex, + task_type="test", + command=["pytest", "-q", *test_paths], + cwd=workspace_path, + input_paths=[f"{workspace_path.rstrip('/')}/{path}" for path in test_paths], + resources=ResourcePolicy(timeout_seconds=120, memory_mb=1024), + ) diff --git a/examples/skills_code_review_agent/sandbox/tasks/static_check.py b/examples/skills_code_review_agent/sandbox/tasks/static_check.py new file mode 100644 index 000000000..6f2d4fec2 --- /dev/null +++ b/examples/skills_code_review_agent/sandbox/tasks/static_check.py @@ -0,0 +1,16 @@ +"""Static-analysis task builder.""" + +import uuid + +from ..models import ResourcePolicy, SandboxTask + + +def build_static_check_task(workspace_path: str, paths: list[str]) -> SandboxTask: + return SandboxTask( + id=uuid.uuid4().hex, + task_type="static_check", + command=["ruff", "check", *paths], + cwd=workspace_path, + input_paths=[f"{workspace_path.rstrip('/')}/{path}" for path in paths], + resources=ResourcePolicy(timeout_seconds=60), + ) diff --git a/examples/skills_code_review_agent/schema.sql b/examples/skills_code_review_agent/schema.sql new file mode 100644 index 000000000..712ab0cad --- /dev/null +++ b/examples/skills_code_review_agent/schema.sql @@ -0,0 +1,57 @@ +-- Reference SQLite schema. Runtime creation uses equivalent SQLAlchemy tables. +CREATE TABLE review_task ( + id TEXT PRIMARY KEY, repo_path TEXT, commit_hash TEXT, input_type TEXT NOT NULL, + input_digest TEXT NOT NULL, diff_summary TEXT NOT NULL, status TEXT NOT NULL, + created_at TIMESTAMP NOT NULL, started_at TIMESTAMP, finished_at TIMESTAMP, + error_type TEXT +); +CREATE TABLE skill_execution ( + id TEXT PRIMARY KEY, task_id TEXT NOT NULL REFERENCES review_task(id), + skill_name TEXT NOT NULL, skill_version TEXT NOT NULL, rule_version TEXT NOT NULL, + detector_type TEXT NOT NULL, started_at TIMESTAMP NOT NULL, + finished_at TIMESTAMP NOT NULL, result_summary TEXT NOT NULL +); +CREATE TABLE sandbox_run ( + id TEXT PRIMARY KEY, task_id TEXT NOT NULL REFERENCES review_task(id), + runtime TEXT NOT NULL, runtime_version TEXT NOT NULL, image TEXT, + command TEXT NOT NULL, command_digest TEXT NOT NULL, status TEXT NOT NULL, + exit_code INTEGER, timed_out BOOLEAN NOT NULL, stdout TEXT NOT NULL, + stderr TEXT NOT NULL, duration FLOAT NOT NULL, created_at TIMESTAMP NOT NULL +); +CREATE TABLE filter_event ( + id TEXT PRIMARY KEY, task_id TEXT NOT NULL REFERENCES review_task(id), + sandbox_run_id TEXT REFERENCES sandbox_run(id), action TEXT NOT NULL, + command TEXT NOT NULL, command_digest TEXT NOT NULL, decision TEXT NOT NULL, + risk_level TEXT NOT NULL, reason TEXT NOT NULL, reason_code TEXT NOT NULL, + matched_rule TEXT NOT NULL, created_at TIMESTAMP NOT NULL +); +CREATE TABLE finding ( + id TEXT PRIMARY KEY, task_id TEXT NOT NULL REFERENCES review_task(id), + skill_execution_id TEXT REFERENCES skill_execution(id), + sandbox_run_id TEXT REFERENCES sandbox_run(id), severity TEXT NOT NULL, + category TEXT NOT NULL, file TEXT NOT NULL, line INTEGER NOT NULL, + title TEXT NOT NULL, evidence TEXT NOT NULL, recommendation TEXT NOT NULL, + confidence FLOAT NOT NULL, source TEXT NOT NULL, status TEXT NOT NULL, + needs_human_review BOOLEAN NOT NULL, dedupe_key TEXT NOT NULL, + rule_id TEXT NOT NULL, rule_version TEXT NOT NULL, + validation_status TEXT NOT NULL, + created_at TIMESTAMP NOT NULL, UNIQUE(task_id, file, line, category) +); +CREATE TABLE review_report ( + id TEXT PRIMARY KEY, task_id TEXT NOT NULL UNIQUE REFERENCES review_task(id), + summary TEXT NOT NULL, report_json TEXT NOT NULL, report_markdown TEXT NOT NULL, + created_at TIMESTAMP NOT NULL +); +CREATE TABLE telemetry ( + id TEXT PRIMARY KEY, task_id TEXT NOT NULL UNIQUE REFERENCES review_task(id), + total_duration FLOAT NOT NULL, sandbox_duration FLOAT NOT NULL, + tool_calls INTEGER NOT NULL, filter_blocks INTEGER NOT NULL, + finding_count INTEGER NOT NULL, error_count INTEGER NOT NULL, + severity_distribution TEXT NOT NULL, error_distribution TEXT NOT NULL, + created_at TIMESTAMP NOT NULL +); +CREATE INDEX ix_review_task_status ON review_task(status); +CREATE INDEX ix_review_task_created_at ON review_task(created_at); +CREATE INDEX ix_finding_task_id ON finding(task_id); +CREATE INDEX ix_finding_severity ON finding(severity); +PRAGMA foreign_keys=ON; diff --git a/examples/skills_code_review_agent/skills/code-review/README.md b/examples/skills_code_review_agent/skills/code-review/README.md new file mode 100644 index 000000000..2aacda748 --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/README.md @@ -0,0 +1,5 @@ +# Code-review skill + +This local skill contains deterministic first-pass rules for the automatic +code-review example. The application validates, redacts, and deduplicates all +script output before persistence. diff --git a/examples/skills_code_review_agent/skills/code-review/SKILL.md b/examples/skills_code_review_agent/skills/code-review/SKILL.md new file mode 100644 index 000000000..819a6ee61 --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/SKILL.md @@ -0,0 +1,26 @@ +--- +name: code-review +description: Analyze code changes with configurable security, async, database-lifecycle, and testing rules. +--- + +# Code review + +Inputs are a unified git diff or the host agent's normalized review JSON. +Run `runner.py` through the governed workspace executor. The runner loads YAML +rules, parses only added lines, dispatches regex or Python AST detectors, and +returns `Finding[]` as JSON. A matched rule may schedule a validator task; only +the sandbox layer may execute that task. + +Capabilities: + +- security and sensitive-information detection +- asynchronous task misuse detection +- database and resource-lifecycle detection +- missing-test-change detection + +Treat output as candidates, not unquestionable truth: every issue needs a +changed file, changed line, concrete evidence, and a practical recommendation. + +Never interpolate review text into a shell command. Do not access the network, +credentials, or paths outside the workspace. See `references/rules.md` for the +rule rationale. diff --git a/examples/skills_code_review_agent/skills/code-review/detectors/__init__.py b/examples/skills_code_review_agent/skills/code-review/detectors/__init__.py new file mode 100644 index 000000000..84a507013 --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/detectors/__init__.py @@ -0,0 +1,6 @@ +"""Built-in detector implementations.""" + +from .ast_detector import AstDetector +from .regex_detector import RegexDetector + +__all__ = ["AstDetector", "RegexDetector"] diff --git a/examples/skills_code_review_agent/skills/code-review/detectors/ast_detector.py b/examples/skills_code_review_agent/skills/code-review/detectors/ast_detector.py new file mode 100644 index 000000000..22d3341cc --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/detectors/ast_detector.py @@ -0,0 +1,68 @@ +"""Python AST rules for dangerous call structure.""" + +from __future__ import annotations + +import ast +from typing import Any + +from models.finding import Finding +from parser.diff_parser import ChangedFile + +from .base import Detector + + +def _name(node: ast.AST) -> str: + if isinstance(node, ast.Name): + return node.id + if isinstance(node, ast.Attribute): + parent = _name(node.value) + return f"{parent}.{node.attr}" if parent else node.attr + return "" + + +class AstDetector(Detector): + def detect(self, changed_file: ChangedFile, rule: dict[str, Any]) -> list[Finding]: + detector = rule["detector"] + targets = detector.get("target", []) + targets = [targets] if isinstance(targets, str) else targets + keyword = detector.get("keyword") + expected = detector.get("equals", True) + results: list[Finding] = [] + # Each added line is parsed independently. This deliberately avoids + # inventing unchanged repository context and keeps findings diff-scoped. + for changed in changed_file.changes: + try: + tree = ast.parse(changed.content.strip()) + except (SyntaxError, ValueError): + continue + for node in ast.walk(tree): + if not isinstance(node, ast.Call) or _name(node.func) not in targets: + continue + if keyword and not any( + item.arg == keyword + and isinstance(item.value, ast.Constant) + and item.value.value == expected + for item in node.keywords + ): + continue + results.append( + Finding( + severity=rule["severity"], + category=rule["category"], + file=changed_file.path, + line=changed.number, + title=rule["message"], + evidence=changed.content[:500], + recommendation=rule["recommendation"], + confidence=float(rule.get("confidence", 0.8)), + source="ast_detector", + rule_id=rule["rule_id"], + rule_version=str(rule.get("version", 1)), + validation_status=( + "pending" if rule.get("validator", {}).get("enabled") + else "not_required" + ), + ) + ) + break + return results diff --git a/examples/skills_code_review_agent/skills/code-review/detectors/base.py b/examples/skills_code_review_agent/skills/code-review/detectors/base.py new file mode 100644 index 000000000..f94417349 --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/detectors/base.py @@ -0,0 +1,15 @@ +"""Detector contract.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any + +from models.finding import Finding +from parser.diff_parser import ChangedFile + + +class Detector(ABC): + @abstractmethod + def detect(self, changed_file: ChangedFile, rule: dict[str, Any]) -> list[Finding]: + """Detect rule matches in added lines.""" diff --git a/examples/skills_code_review_agent/skills/code-review/detectors/regex_detector.py b/examples/skills_code_review_agent/skills/code-review/detectors/regex_detector.py new file mode 100644 index 000000000..7f1bc9418 --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/detectors/regex_detector.py @@ -0,0 +1,41 @@ +"""Regular-expression rules for local textual patterns.""" + +from __future__ import annotations + +import re +from typing import Any + +from models.finding import Finding +from parser.diff_parser import ChangedFile + +from .base import Detector + + +class RegexDetector(Detector): + def detect(self, changed_file: ChangedFile, rule: dict[str, Any]) -> list[Finding]: + patterns = rule.get("pattern", []) + if isinstance(patterns, str): + patterns = [patterns] + compiled = [re.compile(pattern) for pattern in patterns] + return [ + _finding(changed_file.path, line.number, line.content, rule) + for line in changed_file.changes + if any(pattern.search(line.content) for pattern in compiled) + ] + + +def _finding(path: str, line: int, evidence: str, rule: dict[str, Any]) -> Finding: + return Finding( + severity=rule["severity"], + category=rule["category"], + file=path, + line=line, + title=rule["message"], + evidence=evidence[:500], + recommendation=rule["recommendation"], + confidence=float(rule.get("confidence", 0.8)), + source="regex_detector", + rule_id=rule["rule_id"], + rule_version=str(rule.get("version", 1)), + validation_status="pending" if rule.get("validator", {}).get("enabled") else "not_required", + ) diff --git a/examples/skills_code_review_agent/skills/code-review/detectors/semantic_detector.py b/examples/skills_code_review_agent/skills/code-review/detectors/semantic_detector.py new file mode 100644 index 000000000..6518129a6 --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/detectors/semantic_detector.py @@ -0,0 +1,15 @@ +"""Extension point for repository-aware or model-backed analysis.""" + +from __future__ import annotations + +from typing import Any + +from models.finding import Finding +from parser.diff_parser import ChangedFile + +from .base import Detector + + +class SemanticDetector(Detector): + def detect(self, changed_file: ChangedFile, rule: dict[str, Any]) -> list[Finding]: + raise NotImplementedError("semantic detection requires a configured external analyzer") diff --git a/examples/skills_code_review_agent/skills/code-review/models/__init__.py b/examples/skills_code_review_agent/skills/code-review/models/__init__.py new file mode 100644 index 000000000..cc22ca1d5 --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/models/__init__.py @@ -0,0 +1,5 @@ +"""Models exposed by the code-review skill.""" + +from .finding import Finding + +__all__ = ["Finding"] diff --git a/examples/skills_code_review_agent/skills/code-review/models/finding.py b/examples/skills_code_review_agent/skills/code-review/models/finding.py new file mode 100644 index 000000000..3127b548f --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/models/finding.py @@ -0,0 +1,24 @@ +"""Stable output model shared by every detector.""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass + + +@dataclass(frozen=True) +class Finding: + severity: str + category: str + file: str + line: int + title: str + evidence: str + recommendation: str + confidence: float + source: str + rule_id: str = "" + rule_version: str = "1" + validation_status: str = "not_run" + + def to_dict(self) -> dict[str, object]: + return asdict(self) diff --git a/examples/skills_code_review_agent/skills/code-review/parser/__init__.py b/examples/skills_code_review_agent/skills/code-review/parser/__init__.py new file mode 100644 index 000000000..a61ef3db8 --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/parser/__init__.py @@ -0,0 +1,5 @@ +"""Diff parsing support.""" + +from .diff_parser import ChangedFile, ChangedLine, parse_diff, parse_review_input + +__all__ = ["ChangedFile", "ChangedLine", "parse_diff", "parse_review_input"] diff --git a/examples/skills_code_review_agent/skills/code-review/parser/diff_parser.py b/examples/skills_code_review_agent/skills/code-review/parser/diff_parser.py new file mode 100644 index 000000000..6b0f7a8b1 --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/parser/diff_parser.py @@ -0,0 +1,74 @@ +"""Normalize unified diffs and the host agent's ReviewInput JSON.""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from typing import Any + +_HUNK = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@") + + +@dataclass(frozen=True) +class ChangedLine: + number: int + content: str + + +@dataclass +class ChangedFile: + path: str + changes: list[ChangedLine] = field(default_factory=list) + + +def _path(raw: str) -> str: + value = raw.split("\t", 1)[0].strip() + if value == "/dev/null": + return value + if value.startswith(("a/", "b/")): + value = value[2:] + if value.startswith("/") or ".." in value.split("/"): + raise ValueError(f"unsafe diff path: {raw}") + return value + + +def parse_diff(diff: str) -> list[ChangedFile]: + """Return only files and added lines from a unified diff.""" + files: list[ChangedFile] = [] + current: ChangedFile | None = None + new_line: int | None = None + for raw in diff.splitlines(): + if raw.startswith("+++ "): + path = _path(raw[4:]) + current = None if path == "/dev/null" else ChangedFile(path) + if current: + files.append(current) + elif raw.startswith("@@ "): + match = _HUNK.match(raw) + if not match or current is None: + raise ValueError(f"malformed diff hunk: {raw}") + new_line = int(match.group(1)) + elif current is not None and new_line is not None: + if raw.startswith("+") and not raw.startswith("+++"): + current.changes.append(ChangedLine(new_line, raw[1:])) + new_line += 1 + elif raw.startswith("-") and not raw.startswith("---"): + continue + elif not raw.startswith("\\"): + new_line += 1 + return files + + +def parse_review_input(data: dict[str, Any]) -> list[ChangedFile]: + """Read the normalized payload produced by the host agent.""" + result = [] + for item in data.get("files", []): + path = str(item.get("path", "")) + if not path or path.startswith("/") or ".." in path.split("/"): + raise ValueError(f"unsafe review path: {path}") + changes = [ + ChangedLine(int(line["number"]), str(line["content"])) + for line in item.get("candidate_lines", []) + ] + result.append(ChangedFile(path, changes)) + return result diff --git a/examples/skills_code_review_agent/skills/code-review/references/rules.md b/examples/skills_code_review_agent/skills/code-review/references/rules.md new file mode 100644 index 000000000..e2cbc1a90 --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/references/rules.md @@ -0,0 +1,10 @@ +# Rules + +- Flag subprocess and shell execution that enables a shell or embeds dynamic input. +- Flag hard-coded credentials and tokens. +- Flag blocking file or database resources opened without an obvious context manager. +- Flag created asyncio tasks that are neither retained nor awaited. +- Flag production-source changes without an accompanying test-file change. + +Rules intentionally prefer high precision. Uncertain results belong in the +human-review section. diff --git a/examples/skills_code_review_agent/skills/code-review/rules/async.yaml b/examples/skills_code_review_agent/skills/code-review/rules/async.yaml new file mode 100644 index 000000000..5acbc741f --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/rules/async.yaml @@ -0,0 +1,11 @@ +rules: + - rule_id: untracked-async-task + category: reliability + severity: medium + detector: + type: regex + pattern: + - '^\s*asyncio\.create_task\s*\(' + message: Untracked asynchronous task + recommendation: Retain the task and await or cancel it during shutdown. + confidence: 0.78 diff --git a/examples/skills_code_review_agent/skills/code-review/rules/database.yaml b/examples/skills_code_review_agent/skills/code-review/rules/database.yaml new file mode 100644 index 000000000..f5762a44c --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/rules/database.yaml @@ -0,0 +1,14 @@ +rules: + - rule_id: unmanaged-resource + category: reliability + severity: low + detector: + type: regex + pattern: + - '^\s*(?!with\b).*\b(open|sqlite3\.connect|Session)\s*\(' + message: Resource lifecycle needs verification + recommendation: Use a context manager or an explicit finally block to close the resource. + confidence: 0.66 + validator: + enabled: true + script: validators/resource_check.py diff --git a/examples/skills_code_review_agent/skills/code-review/rules/security.yaml b/examples/skills_code_review_agent/skills/code-review/rules/security.yaml new file mode 100644 index 000000000..0859f66c1 --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/rules/security.yaml @@ -0,0 +1,43 @@ +rules: + - rule_id: hardcoded-secret + category: security + severity: critical + detector: + type: regex + pattern: + - '(?i)\b(api[_-]?key|password|token|secret)\s*=\s*["''][^"'']{6,}' + - '\bsk-[A-Za-z0-9]{20,}' + message: Hard-coded credential + recommendation: Load credentials from a secret provider and rotate the exposed value. + confidence: 0.98 + + - rule_id: unsafe-shell-execution + category: security + severity: high + detector: + type: ast + target: + - subprocess.run + - subprocess.call + - subprocess.Popen + keyword: shell + equals: true + message: Unsafe shell execution + recommendation: Pass a fixed argument vector and keep shell disabled. + confidence: 0.94 + validator: + enabled: true + script: validators/security_probe.py + + - rule_id: dynamic-code-execution + category: security + severity: high + detector: + type: ast + target: + - eval + - exec + - os.system + message: Unsafe dynamic execution + recommendation: Replace dynamic execution with an explicit allowlist. + confidence: 0.94 diff --git a/examples/skills_code_review_agent/skills/code-review/rules/testing.yaml b/examples/skills_code_review_agent/skills/code-review/rules/testing.yaml new file mode 100644 index 000000000..782382b60 --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/rules/testing.yaml @@ -0,0 +1,15 @@ +rules: + - rule_id: missing-test-change + category: testing + severity: low + detector: + type: regex + pattern: + - '.+' + when: missing_test_change + message: No related test change + recommendation: Add or update a focused test for this behavior. + confidence: 0.68 + validator: + enabled: true + script: validators/run_tests.py diff --git a/examples/skills_code_review_agent/skills/code-review/runner.py b/examples/skills_code_review_agent/skills/code-review/runner.py new file mode 100644 index 000000000..45c944740 --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/runner.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +"""Rule-driven entry point for the code-review skill.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from typing import Any + +import yaml + +from detectors import AstDetector, RegexDetector +from models.finding import Finding +from parser.diff_parser import ChangedFile, parse_diff, parse_review_input + +ROOT = Path(__file__).resolve().parent +DETECTORS = {"regex": RegexDetector(), "ast": AstDetector()} +REQUIRED_RULE_FIELDS = { + "rule_id", "category", "severity", "detector", "message", "recommendation" +} + + +class SkillRunner: + def __init__(self, rules_dir: Path | None = None) -> None: + self.rules_dir = rules_dir or ROOT / "rules" + self.pending_validators: list[dict[str, Any]] = [] + + def load_rules(self) -> list[dict[str, Any]]: + rules: list[dict[str, Any]] = [] + for path in sorted(self.rules_dir.glob("*.yaml")): + payload = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + entries = payload.get("rules", []) + if not isinstance(entries, list): + raise ValueError(f"{path.name}: rules must be a list") + for rule in entries: + missing = REQUIRED_RULE_FIELDS - set(rule) + if missing: + raise ValueError(f"{path.name}: missing fields: {', '.join(sorted(missing))}") + detector_type = rule["detector"].get("type") + if detector_type not in DETECTORS: + raise ValueError(f"{path.name}: unsupported detector: {detector_type}") + rules.append(rule) + return rules + + def run(self, files: list[ChangedFile]) -> list[Finding]: + self.pending_validators = [] + findings: list[Finding] = [] + paths = {item.path.lower() for item in files} + has_tests = any("test" in Path(path).name for path in paths) + for rule in self.load_rules(): + detector = DETECTORS[rule["detector"]["type"]] + matched: list[Finding] = [] + for changed_file in files: + if rule.get("when") == "missing_test_change": + is_documentation = changed_file.path.lower().endswith( + (".md", ".rst", ".txt") + ) + if has_tests or not changed_file.changes or is_documentation: + continue + first_change_only = ChangedFile(changed_file.path, changed_file.changes[:1]) + matched.extend(detector.detect(first_change_only, rule)) + continue + matched.extend(detector.detect(changed_file, rule)) + findings.extend(matched) + validator = rule.get("validator") + if matched and validator and validator.get("enabled"): + self.pending_validators.append( + {"rule_id": rule["rule_id"], "script": validator["script"]} + ) + return findings + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("input", type=Path) + parser.add_argument("--unified-diff", action="store_true") + args = parser.parse_args() + text = args.input.read_text(encoding="utf-8") + files = parse_diff(text) if args.unified_diff else parse_review_input(json.loads(text)) + findings = SkillRunner().run(files) + print(json.dumps([item.to_dict() for item in findings], ensure_ascii=False)) + + +if __name__ == "__main__": + main() diff --git a/examples/skills_code_review_agent/skills/code-review/scripts/parse_diff.py b/examples/skills_code_review_agent/skills/code-review/scripts/parse_diff.py new file mode 100644 index 000000000..578702745 --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/scripts/parse_diff.py @@ -0,0 +1,8 @@ +#!/usr/bin/env python3 +"""Print a compact summary from a normalized ReviewInput JSON file.""" + +import json +import sys + +data = json.load(open(sys.argv[1], encoding="utf-8")) # noqa: SIM115 - short-lived script +print(json.dumps({"summary": data["summary"], "candidate_lines": data["candidate_lines"]})) diff --git a/examples/skills_code_review_agent/skills/code-review/scripts/scan_rules.py b/examples/skills_code_review_agent/skills/code-review/scripts/scan_rules.py new file mode 100644 index 000000000..2f23189f0 --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/scripts/scan_rules.py @@ -0,0 +1,10 @@ +#!/usr/bin/env python3 +"""Compatibility wrapper for the rule-driven Skill runner.""" + +import runpy +import sys +from pathlib import Path + +skill_root = Path(__file__).parents[1] +sys.path.insert(0, str(skill_root)) +runpy.run_path(str(skill_root / "runner.py"), run_name="__main__") diff --git a/examples/skills_code_review_agent/skills/code-review/validators/__init__.py b/examples/skills_code_review_agent/skills/code-review/validators/__init__.py new file mode 100644 index 000000000..ddd431b92 --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/validators/__init__.py @@ -0,0 +1,4 @@ +"""Validator task entry points. + +Validators are executed by the sandbox layer, never directly by SkillRunner. +""" diff --git a/examples/skills_code_review_agent/skills/code-review/validators/resource_check.py b/examples/skills_code_review_agent/skills/code-review/validators/resource_check.py new file mode 100644 index 000000000..18c603233 --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/validators/resource_check.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 +"""Describe the sandbox task for observing resource growth.""" + +import json + +print(json.dumps({"validator": "resource_check", "status": "requires_sandbox_fixture"})) diff --git a/examples/skills_code_review_agent/skills/code-review/validators/run_tests.py b/examples/skills_code_review_agent/skills/code-review/validators/run_tests.py new file mode 100644 index 000000000..395e8dc94 --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/validators/run_tests.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 +"""Validator task contract for a sandbox-provided test command.""" + +import json + +print(json.dumps({"validator": "run_tests", "command": ["pytest"], "status": "pending"})) diff --git a/examples/skills_code_review_agent/skills/code-review/validators/security_probe.py b/examples/skills_code_review_agent/skills/code-review/validators/security_probe.py new file mode 100644 index 000000000..47089b485 --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/validators/security_probe.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 +"""Describe the sandbox task for confirming a security candidate.""" + +import json + +print(json.dumps({"validator": "security_probe", "status": "requires_sandbox_fixture"})) diff --git a/tests/code_review/test_review_agent.py b/tests/code_review/test_review_agent.py new file mode 100644 index 000000000..998ff9864 --- /dev/null +++ b/tests/code_review/test_review_agent.py @@ -0,0 +1,503 @@ +"""Tests for the automatic code-review example.""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +import pytest +from sqlalchemy import inspect + +from examples.skills_code_review_agent.agent.input_parser import parse_review_input +from examples.skills_code_review_agent.agent.models import ( + Decision, + ExecutionBudget, + ExecutionRequest, + FilterDecision, + Finding, + ReviewRequest, + SandboxRunResult, +) +from examples.skills_code_review_agent.agent.policy import evaluate_execution_policy +from examples.skills_code_review_agent.agent.reporting import write_reports +from examples.skills_code_review_agent.agent.review import run_review +from examples.skills_code_review_agent.agent.sanitizer import normalize_findings, redact_sensitive_text +from examples.skills_code_review_agent.agent.storage import ReviewRepository +from examples.skills_code_review_agent.agent.task_planner import ReviewTaskPlanner +from examples.skills_code_review_agent.sandbox.models import ResourcePolicy, SandboxTask +from examples.skills_code_review_agent.sandbox.policy import to_workspace_limits +from examples.skills_code_review_agent.sandbox.runner import SandboxRunner +from examples.skills_code_review_agent.sandbox.tasks import build_code_review_task + + +def _diff(tmp_path: Path) -> Path: + path = tmp_path / "change.diff" + path.write_text( + "diff --git a/app.py b/app.py\n" + "--- a/app.py\n" + "+++ b/app.py\n" + "@@ -1 +1,2 @@\n" + " old\n" + "+subprocess.run(value, shell=True)\n", + encoding="utf-8", + ) + return path + + +def _write_diff(tmp_path: Path, text: str, name: str = "sample.diff") -> Path: + path = tmp_path / name + path.write_text(text, encoding="utf-8") + return path + + +def _run_skill(diff_path: Path) -> list[dict[str, object]]: + runner = ( + Path(__file__).parents[2] + / "examples" + / "skills_code_review_agent" + / "skills" + / "code-review" + / "runner.py" + ) + result = subprocess.run( + [sys.executable, str(runner), str(diff_path), "--unified-diff"], + check=True, + capture_output=True, + text=True, + ) + return json.loads(result.stdout) + + +def test_parse_review_input_extracts_added_line(tmp_path: Path) -> None: + parsed = parse_review_input(diff_file=_diff(tmp_path), allowed_roots=[tmp_path]) + assert parsed.candidate_lines == {"app.py": [2]} + assert parsed.files[0].candidate_lines[0].content.endswith("shell=True)") + + +def test_parse_review_input_rejects_path_escape(tmp_path: Path) -> None: + outside = tmp_path.parent / "outside.diff" + outside.write_text("", encoding="utf-8") + with pytest.raises(ValueError, match="outside"): + parse_review_input(diff_file=outside, allowed_roots=[tmp_path]) + + +def test_parse_review_input_accepts_project_file_list(tmp_path: Path) -> None: + (tmp_path / "app.py").write_text("print('ok')\n", encoding="utf-8") + manifest = tmp_path / "files.txt" + manifest.write_text("app.py\n", encoding="utf-8") + parsed = parse_review_input(file_list=manifest) + assert parsed.source_type == "file_list" + assert parsed.candidate_lines == {"app.py": [1]} + assert parsed.source_path == str(tmp_path) + + +def test_skill_runner_loads_yaml_and_ast_rules(tmp_path: Path) -> None: + runner = ( + Path(__file__).parents[2] + / "examples" + / "skills_code_review_agent" + / "skills" + / "code-review" + / "runner.py" + ) + result = subprocess.run( + [sys.executable, str(runner), str(_diff(tmp_path)), "--unified-diff"], + check=True, + capture_output=True, + text=True, + ) + findings = json.loads(result.stdout) + assert [item["source"] for item in findings].count("ast_detector") == 1 + assert [item["category"] for item in findings].count("testing") == 1 + assert findings[0]["file"] == "app.py" + assert findings[0]["line"] == 2 + + +def test_policy_denies_network_and_budget(tmp_path: Path) -> None: + request = ExecutionRequest(command=["python3", "scan.py"], cwd=str(tmp_path), network_targets=["example.com"]) + assert evaluate_execution_policy(request, workspace_root=str(tmp_path)).decision == Decision.DENY + request.network_targets = [] + decision = evaluate_execution_policy( + request, + workspace_root=str(tmp_path), + budget=ExecutionBudget(max_calls=1, calls_used=1), + ) + assert decision.reason_code == "budget_exceeded" + + +@pytest.mark.parametrize( + ("command", "reason"), + [ + (["rm", "-rf", "work"], "command_denied"), + (["curl", "https://example.com", "|", "bash"], "shell_pipe_execution"), + ], +) +def test_governance_denies_dangerous_commands( + tmp_path: Path, + command: list[str], + reason: str, +) -> None: + decision = evaluate_execution_policy( + ExecutionRequest(command=command, cwd=str(tmp_path)), + workspace_root=str(tmp_path), + ) + assert decision.decision == Decision.DENY + assert decision.matched_rule == reason + assert decision.risk_level == "high" + + +def test_governance_protects_paths_and_reviews_resources(tmp_path: Path) -> None: + protected = tmp_path / ".env" + decision = evaluate_execution_policy( + ExecutionRequest( + command=["python3", str(protected)], + cwd=str(tmp_path), + input_paths=[str(protected)], + ), + workspace_root=str(tmp_path), + ) + assert decision.matched_rule == "protected_path" + resource_decision = evaluate_execution_policy( + ExecutionRequest( + command=["python3", "scan.py"], + cwd=str(tmp_path), + timeout=301, + memory_limit_mb=2048, + ), + workspace_root=str(tmp_path), + budget=ExecutionBudget(max_total_seconds=1000), + ) + assert resource_decision.decision == Decision.NEEDS_HUMAN_REVIEW + assert resource_decision.matched_rule == "resource_limit_exceeded" + + +def test_layered_sandbox_task_and_resource_policy(tmp_path: Path) -> None: + task = build_code_review_task(str(tmp_path), "container") + assert task.task_type == "custom_rule" + assert task.command[0] == "python3" + limits = to_workspace_limits(ResourcePolicy(cpu_percent=50, memory_mb=256, max_pids=8)) + assert (limits.cpu_percent, limits.memory_mb, limits.max_pids) == (50, 256, 8) + + +def test_task_planner_adds_static_check_and_changed_tests(tmp_path: Path) -> None: + diff = _write_diff( + tmp_path, + """diff --git a/tests/test_app.py b/tests/test_app.py +--- a/tests/test_app.py ++++ b/tests/test_app.py +@@ -0,0 +1 @@ ++def test_app(): assert True +""", + ) + parsed = parse_review_input(diff_file=diff) + plan = ReviewTaskPlanner().build_plan( + parsed, "/workspace", "container", project_staged=True + ) + assert [task.task_type for task in plan.tasks] == [ + "custom_rule", + "static_check", + "test", + ] + + +@pytest.mark.asyncio +async def test_dry_run_reports_plan_without_claiming_clean( + tmp_path: Path, +) -> None: + parsed = parse_review_input(diff_file=_diff(tmp_path)) + report = await run_review( + ReviewRequest(review_input=parsed, runtime="local", dry_run=True) + ) + assert report.dry_run is True + assert all(run.status == "dry_run" for run in report.sandbox_runs) + assert "no checks were executed" in report.conclusion + paths = write_reports(report, tmp_path / "output") + assert paths.json_path.parent.name == report.task_id + + +def test_redaction_and_deduplication(tmp_path: Path) -> None: + parsed = parse_review_input(diff_file=_diff(tmp_path)) + assert "secret-value" not in redact_sensitive_text("token=secret-value") + candidates = [ + Finding( + severity="high", + category="security", + file="app.py", + line=2, + title="Unsafe", + evidence="token=secret-value", + recommendation="fix", + confidence=confidence, + ) + for confidence in (0.7, 0.9) + ] + findings, warnings, human = normalize_findings(candidates, parsed) + assert not warnings and not human + assert len(findings) == 1 and findings[0].confidence == 0.9 + assert "secret-value" not in findings[0].evidence + + +@pytest.mark.asyncio +async def test_fake_review_persists_and_writes_reports(tmp_path: Path) -> None: + parsed = parse_review_input(diff_file=_diff(tmp_path)) + repository = ReviewRepository(f"sqlite:///{tmp_path / 'review.db'}") + repository.initialize() + report = await run_review( + ReviewRequest(review_input=parsed, runtime="local", fake_model=True), + repository=repository, + ) + paths = write_reports(report, tmp_path / "output") + trace = repository.get_task(report.task_id) + assert trace["status"] == "completed" + assert json.loads(paths.json_path.read_text(encoding="utf-8"))["findings"] + assert paths.markdown_path.is_file() + events = json.loads(paths.filter_events_path.read_text(encoding="utf-8")) + assert events[0]["matched_rule"] == "allowed" + assert trace["filter_decisions"][0]["risk_level"] == "low" + assert trace["skill_executions"][0]["rule_version"] == "1" + assert trace["findings"][0]["skill_execution_id"] == trace["skill_executions"][0]["id"] + assert trace["findings"][0]["sandbox_run_id"] == trace["sandbox_runs"][0]["id"] + assert trace["telemetry"]["finding_count"] == len(report.findings) + assert "# Code Review Report" in trace["report"]["report_markdown"] + assert { + "review_task", + "skill_execution", + "sandbox_run", + "filter_event", + "finding", + "review_report", + "telemetry", + } <= set(inspect(repository.engine).get_table_names()) + + +def test_case_clean_diff_has_no_findings(tmp_path: Path) -> None: + diff = _write_diff( + tmp_path, + """diff --git a/test_math.py b/test_math.py +--- a/test_math.py ++++ b/test_math.py +@@ -1 +1,2 @@ + def test_add(): ++ assert 1 + 1 == 2 +""", + ) + assert _run_skill(diff) == [] + + +def test_case_security_issue_is_reported(tmp_path: Path) -> None: + diff = _write_diff( + tmp_path, + """diff --git a/app.py b/app.py +--- a/app.py ++++ b/app.py +@@ -0,0 +1 @@ ++os.system("ping -c 1 " + host) +""", + ) + findings = _run_skill(diff) + assert any(item["category"] == "security" and item["severity"] == "high" for item in findings) + + +def test_case_async_resource_leak_is_reported(tmp_path: Path) -> None: + diff = _write_diff( + tmp_path, + """diff --git a/worker.py b/worker.py +--- a/worker.py ++++ b/worker.py +@@ -0,0 +1 @@ ++asyncio.create_task(process_message(message)) +""", + ) + findings = _run_skill(diff) + assert any(item["title"] == "Untracked asynchronous task" for item in findings) + + +def test_case_database_connection_lifecycle_is_reported(tmp_path: Path) -> None: + diff = _write_diff( + tmp_path, + """diff --git a/store.py b/store.py +--- a/store.py ++++ b/store.py +@@ -0,0 +1 @@ ++connection = sqlite3.connect(database_path) +""", + ) + findings = _run_skill(diff) + assert any( + item["category"] == "reliability" and item["title"] == "Resource lifecycle needs verification" + for item in findings + ) + + +def test_case_missing_tests_is_reported(tmp_path: Path) -> None: + diff = _write_diff( + tmp_path, + """diff --git a/discount.py b/discount.py +--- a/discount.py ++++ b/discount.py +@@ -0,0 +1,2 @@ ++def apply_discount(amount): ++ return amount * 0.8 +""", + ) + findings = _run_skill(diff) + assert any(item["category"] == "testing" and item["title"] == "No related test change" for item in findings) + + +def test_case_duplicate_findings_are_collapsed(tmp_path: Path) -> None: + parsed = parse_review_input(diff_file=_diff(tmp_path)) + candidates = [ + Finding( + severity="high", + category="security", + file="app.py", + line=2, + title="Unsafe shell", + evidence="shell=True", + recommendation="Disable shell", + confidence=confidence, + source=source, + ) + for confidence, source in ((0.72, "rule"), (0.96, "model")) + ] + findings, warnings, human = normalize_findings(candidates, parsed) + assert not warnings and not human + assert len(findings) == 1 + assert findings[0].confidence == 0.96 + assert findings[0].source == "model" + + +@pytest.mark.asyncio +async def test_case_sandbox_failure_produces_partial_report( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + parsed = parse_review_input(diff_file=_diff(tmp_path)) + failed_run = SandboxRunResult( + id="sandbox-failed", + runtime="container", + command=["python3", "runner.py"], + status="failed", + exit_code=None, + stderr_summary="container initialization failed", + decision=FilterDecision( + decision=Decision.ALLOW, + reason_code="allowed", + reason="Execution allowed", + ), + ) + + async def fake_sandbox_checks( + *args: object, + **kwargs: object, + ) -> tuple[list[SandboxRunResult], list[Finding], list[str]]: + return [failed_run], [], ["rule check failed: SandboxError"] + + monkeypatch.setattr( + "examples.skills_code_review_agent.agent.review.run_sandbox_checks", + fake_sandbox_checks, + ) + report = await run_review(ReviewRequest(review_input=parsed, runtime="container")) + assert report.status == "partial" + assert report.findings == [] + assert report.sandbox_runs[0].status == "failed" + assert "SandboxError" in report.warnings[0] + + +def test_case_sensitive_information_is_redacted_everywhere(tmp_path: Path) -> None: + api_key = "sk-prod-1234567890abcdef" + database_url = "postgres://admin:SuperSecret123@db.example.com/prod" + text = f"api_key={api_key} database={database_url}" + redacted = redact_sensitive_text(text) + assert api_key not in redacted + assert "SuperSecret123" not in redacted + assert "[REDACTED_CREDENTIAL]" in redacted + assert "[REDACTED_PASSWORD]" in redacted + + parsed = parse_review_input(diff_file=_diff(tmp_path)) + finding = Finding( + severity="critical", + category="security", + file="app.py", + line=2, + title=text, + evidence=text, + recommendation=text, + confidence=0.99, + ) + findings, _, _ = normalize_findings([finding], parsed) + serialized = findings[0].model_dump_json() + assert api_key not in serialized + assert "SuperSecret123" not in serialized + + +@pytest.mark.parametrize( + "secret", + [ + "sk-proj-1234567890abcdefghijklmnop", + "ghp_1234567890abcdefghijklmnop", + "github_pat_1234567890abcdefghijklmnop", + "Bearer abcdefghijklmnopqrstuvwxyz", + "AKIA1234567890ABCDEF", + "token='value with spaces'", + "password: SuperSecret123", + "postgresql://user:password@localhost/db", + "eyJabcdefghij.abcdefghijkl.abcdefghijkl", + ], +) +def test_common_secret_formats_are_redacted(secret: str) -> None: + assert secret not in redact_sensitive_text(secret) + + +@pytest.mark.asyncio +async def test_needs_human_review_never_reaches_executor(tmp_path: Path) -> None: + runner = SandboxRunner.__new__(SandboxRunner) + runner.executor = object() + runner.budget = ExecutionBudget(max_total_seconds=1000) + task = SandboxTask( + id="high-resource", + task_type="test", + command=["pytest"], + cwd=str(tmp_path), + resources=ResourcePolicy(timeout_seconds=301, memory_mb=1024), + ) + result = await runner.run(object(), task) + assert result.status == "blocked" + assert result.decision.decision == Decision.NEEDS_HUMAN_REVIEW + + +@pytest.mark.parametrize( + "fixture_name", + [ + "clean.diff", + "security.diff", + "async_leak.diff", + "database_leak.diff", + "missing_tests.diff", + "duplicate.diff", + "sensitive.diff", + "sandbox_failure.diff", + ], +) +@pytest.mark.asyncio +async def test_public_fixtures_generate_reports( + tmp_path: Path, + fixture_name: str, +) -> None: + fixture = ( + Path(__file__).parents[2] + / "examples" + / "skills_code_review_agent" + / "fixtures" + / fixture_name + ) + parsed = parse_review_input(fixture_path=fixture) + report = await run_review( + ReviewRequest(review_input=parsed, runtime="local", fake_model=True) + ) + paths = write_reports(report, tmp_path / "reports") + assert paths.json_path.is_file() + assert paths.markdown_path.is_file() + assert report.status in {"completed", "partial"} From 31c472b4f1e239fb093db36b7854d7e5a377b6aa Mon Sep 17 00:00:00 2001 From: FeatherCheung Date: Tue, 28 Jul 2026 20:58:59 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(code-review):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=9B=9E=E6=94=BE=E3=80=81=E8=B5=84=E6=BA=90=E6=B8=85=E7=90=86?= =?UTF-8?q?=E4=B8=8E=E9=81=A5=E6=B5=8B=E7=BB=9F=E8=AE=A1=20=E6=8C=89?= =?UTF-8?q?=E5=AE=9E=E9=99=85=20sandbox=20=E4=BB=BB=E5=8A=A1=E6=B1=87?= =?UTF-8?q?=E6=80=BB=E6=89=A7=E8=A1=8C=E8=80=97=E6=97=B6=20=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E9=87=8A=E6=94=BE=E6=94=AF=E6=8C=81=20destroy()=20?= =?UTF-8?q?=E7=9A=84=20runtime=20=E6=94=AF=E6=8C=81=E7=9B=B8=E5=90=8C=20ta?= =?UTF-8?q?sk=5Fid=20=E9=87=8D=E5=A4=8D=E5=9B=9E=E6=94=BE=E5=B9=B6?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E6=89=A7=E8=A1=8C=E8=BD=A8=E8=BF=B9=20?= =?UTF-8?q?=E5=88=A0=E9=99=A4=E6=97=A0=E6=95=88=E7=9A=84=E7=AC=A6=E5=8F=B7?= =?UTF-8?q?=E9=93=BE=E6=8E=A5=E6=A3=80=E6=9F=A5=E5=92=8C=E5=86=B2=E7=AA=81?= =?UTF-8?q?=E7=9A=84=E4=BA=8C=E8=BF=9B=E5=88=B6=20diff=20=E5=8F=82?= =?UTF-8?q?=E6=95=B0=20=E8=A1=A5=E5=85=85=20Finding=20=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E3=80=81=E5=9B=9E=E6=94=BE=E5=8F=8A=E9=81=A5=E6=B5=8B=E5=9B=9E?= =?UTF-8?q?=E5=BD=92=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agent/input_parser.py | 4 +- .../skills_code_review_agent/agent/storage.py | 65 +++- .../skills_code_review_agent/run_agent.py | 15 +- examples/skills_code_review_agent/schema.sql | 3 +- tests/code_review/test_review_agent.py | 283 ++++++++++++++++++ 5 files changed, 349 insertions(+), 21 deletions(-) diff --git a/examples/skills_code_review_agent/agent/input_parser.py b/examples/skills_code_review_agent/agent/input_parser.py index 586f070cd..945a1a9c9 100644 --- a/examples/skills_code_review_agent/agent/input_parser.py +++ b/examples/skills_code_review_agent/agent/input_parser.py @@ -145,7 +145,7 @@ def parse_review_input( if relative.is_absolute() or ".." in relative.parts: raise ValueError(f"unsafe path in file list: {value}") path = (project_root / relative).resolve() - if not path.is_relative_to(project_root) or path.is_symlink(): + if not path.is_relative_to(project_root): raise ValueError(f"unsafe path in file list: {value}") content = _read_text(path, max_input_bytes) total += len(content.encode()) @@ -178,7 +178,7 @@ def parse_review_input( if not source.is_dir(): raise ValueError(f"repository path is not a directory: {source}") result = subprocess.run( - ["git", "-C", str(source), "diff", "--no-ext-diff", "--binary", "HEAD"], + ["git", "-C", str(source), "diff", "--no-ext-diff", "HEAD"], check=False, capture_output=True, timeout=30, diff --git a/examples/skills_code_review_agent/agent/storage.py b/examples/skills_code_review_agent/agent/storage.py index 04c0ebe11..3ff68049b 100644 --- a/examples/skills_code_review_agent/agent/storage.py +++ b/examples/skills_code_review_agent/agent/storage.py @@ -142,8 +142,12 @@ def _enable_sqlite_foreign_keys(dbapi_connection: Any, _: Any) -> None: Column("validation_status", String(32), nullable=False, default="not_run"), Column("created_at", DateTime(timezone=True), nullable=False), UniqueConstraint( - "task_id", "file", "line", "category", - name="uq_finding_location_category", + "task_id", + "file", + "line", + "category", + "needs_human_review", + name="uq_finding_location_category_review_status", ), ) self.reports = Table( @@ -191,19 +195,44 @@ def create_task( ) -> None: now = utc_now() with self.engine.begin() as connection: - connection.execute( - self.tasks.insert().values( - id=task_id, - repo_path=repo_path, - commit_hash=commit_hash, - input_type=input_type, - input_digest=input_digest, - diff_summary=summary, - status="running", - created_at=now, - started_at=now, + task_values = { + "repo_path": repo_path, + "commit_hash": commit_hash, + "input_type": input_type, + "input_digest": input_digest, + "diff_summary": summary, + "status": "running", + "created_at": now, + "started_at": now, + "finished_at": None, + "error_type": None, + } + existing = connection.execute( + select(self.tasks.c.id).where(self.tasks.c.id == task_id) + ).first() + if existing: + # A stable task ID represents the latest replay, so remove the + # previous trace in foreign-key order before starting it again. + for table in ( + self.decisions, + self.findings, + self.reports, + self.telemetry, + self.runs, + self.skill_executions, + ): + connection.execute( + table.delete().where(table.c.task_id == task_id) + ) + connection.execute( + self.tasks.update() + .where(self.tasks.c.id == task_id) + .values(**task_values) + ) + else: + connection.execute( + self.tasks.insert().values(id=task_id, **task_values) ) - ) def mark_failed(self, task_id: str, error_type: str) -> None: with self.engine.begin() as connection: @@ -304,6 +333,8 @@ def save_review_result( self.findings.c.file == finding.file, self.findings.c.line == finding.line, self.findings.c.category == finding.category, + self.findings.c.needs_human_review + == finding.needs_human_review, ) ).first() if existing and existing.confidence < finding.confidence: @@ -332,13 +363,15 @@ def save_review_result( created_at=report.created_at, ) ) - stage_ms = report.metrics.get("stage_duration_ms", {}) + sandbox_duration_ms = sum( + run.duration_ms for run in report.sandbox_runs + ) connection.execute( self.telemetry.insert().values( id=_id("telemetry"), task_id=report.task_id, total_duration=float(report.metrics.get("total_duration_ms", 0)) / 1000, - sandbox_duration=float(stage_ms.get("sandbox", 0)) / 1000, + sandbox_duration=float(sandbox_duration_ms) / 1000, tool_calls=int(report.metrics.get("tool_calls", 0)), filter_blocks=int(report.metrics.get("blocked_executions", 0)), finding_count=len(all_findings), diff --git a/examples/skills_code_review_agent/run_agent.py b/examples/skills_code_review_agent/run_agent.py index 0e0232a39..6898a1232 100644 --- a/examples/skills_code_review_agent/run_agent.py +++ b/examples/skills_code_review_agent/run_agent.py @@ -5,8 +5,10 @@ import argparse import asyncio +import inspect import sys from pathlib import Path +from typing import Any if __package__ in {None, ""}: sys.path.insert(0, str(Path(__file__).resolve().parents[2])) @@ -43,6 +45,16 @@ async def create_workspace_runtime(name: str): ) +async def _destroy_workspace_runtime(runtime: Any) -> None: + """Destroy a workspace runtime when its implementation supports cleanup.""" + destroy = getattr(runtime, "destroy", None) + if not callable(destroy): + return + result = destroy() + if inspect.isawaitable(result): + await result + + def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="Run a policy-governed automatic code review.") source = parser.add_mutually_exclusive_group(required=True) @@ -97,8 +109,7 @@ async def _main(args: argparse.Namespace) -> int: repository=repository, ) finally: - if args.runtime == "cube": - await runtime.destroy() + await _destroy_workspace_runtime(runtime) paths = write_reports(report, args.output_dir) print(f"{report.conclusion}\nJSON: {paths.json_path}\nMarkdown: {paths.markdown_path}") return 0 if report.status in {"completed", "partial"} else 1 diff --git a/examples/skills_code_review_agent/schema.sql b/examples/skills_code_review_agent/schema.sql index 712ab0cad..b49f02d20 100644 --- a/examples/skills_code_review_agent/schema.sql +++ b/examples/skills_code_review_agent/schema.sql @@ -35,7 +35,8 @@ CREATE TABLE finding ( needs_human_review BOOLEAN NOT NULL, dedupe_key TEXT NOT NULL, rule_id TEXT NOT NULL, rule_version TEXT NOT NULL, validation_status TEXT NOT NULL, - created_at TIMESTAMP NOT NULL, UNIQUE(task_id, file, line, category) + created_at TIMESTAMP NOT NULL, + UNIQUE(task_id, file, line, category, needs_human_review) ); CREATE TABLE review_report ( id TEXT PRIMARY KEY, task_id TEXT NOT NULL UNIQUE REFERENCES review_task(id), diff --git a/tests/code_review/test_review_agent.py b/tests/code_review/test_review_agent.py index 998ff9864..f000e7657 100644 --- a/tests/code_review/test_review_agent.py +++ b/tests/code_review/test_review_agent.py @@ -17,6 +17,7 @@ ExecutionRequest, FilterDecision, Finding, + ReviewReport, ReviewRequest, SandboxRunResult, ) @@ -30,6 +31,7 @@ from examples.skills_code_review_agent.sandbox.policy import to_workspace_limits from examples.skills_code_review_agent.sandbox.runner import SandboxRunner from examples.skills_code_review_agent.sandbox.tasks import build_code_review_task +from examples.skills_code_review_agent.run_agent import _destroy_workspace_runtime def _diff(tmp_path: Path) -> Path: @@ -93,6 +95,55 @@ def test_parse_review_input_accepts_project_file_list(tmp_path: Path) -> None: assert parsed.source_path == str(tmp_path) +def test_parse_review_input_rejects_file_list_symlink_escape( + tmp_path: Path, +) -> None: + project = tmp_path / "project" + project.mkdir() + outside = tmp_path / "outside.py" + outside.write_text("print('outside')\n", encoding="utf-8") + (project / "linked.py").symlink_to(outside) + manifest = project / "files.txt" + manifest.write_text("linked.py\n", encoding="utf-8") + + with pytest.raises(ValueError, match="unsafe path"): + parse_review_input(file_list=manifest) + + +def test_parse_review_input_does_not_request_binary_git_patch( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + command: list[str] = [] + + def fake_run(args: list[str], **_: object) -> subprocess.CompletedProcess[bytes]: + command.extend(args) + return subprocess.CompletedProcess( + args, + 0, + stdout=( + b"diff --git a/app.py b/app.py\n" + b"--- a/app.py\n" + b"+++ b/app.py\n" + b"@@ -1 +1 @@\n" + b"-old\n" + b"+new\n" + ), + stderr=b"", + ) + + monkeypatch.setattr( + "examples.skills_code_review_agent.agent.input_parser.subprocess.run", + fake_run, + ) + + parsed = parse_review_input(repo_path=tmp_path) + + assert "--binary" not in command + assert command[-2:] == ["--no-ext-diff", "HEAD"] + assert parsed.candidate_lines == {"app.py": [1]} + + def test_skill_runner_loads_yaml_and_ast_rules(tmp_path: Path) -> None: runner = ( Path(__file__).parents[2] @@ -272,6 +323,238 @@ async def test_fake_review_persists_and_writes_reports(tmp_path: Path) -> None: } <= set(inspect(repository.engine).get_table_names()) +def test_persists_accepted_and_human_review_findings_independently( + tmp_path: Path, +) -> None: + repository = ReviewRepository(f"sqlite:///{tmp_path / 'review.db'}") + repository.initialize() + task_id = "task_mixed_findings" + repository.create_task( + task_id, + input_type="diff", + input_digest="digest", + summary="summary", + ) + common = { + "severity": "high", + "category": "security", + "file": "app.py", + "line": 2, + "title": "Unsafe subprocess call", + "evidence": "subprocess.run(value, shell=True)", + "recommendation": "Avoid shell=True", + } + report = ReviewReport( + task_id=task_id, + status="completed", + conclusion="Review completed", + input_summary="summary", + findings=[Finding(**common, confidence=0.9)], + needs_human_review=[ + Finding(**common, confidence=0.5, needs_human_review=True) + ], + ) + + repository.save_review_result(report) + + trace = repository.get_task(task_id) + assert trace is not None + assert len(trace["findings"]) == 2 + assert { + (finding["needs_human_review"], finding["confidence"]) + for finding in trace["findings"] + } == {(False, 0.9), (True, 0.5)} + assert {finding["status"] for finding in trace["findings"]} == { + "open", + "needs_human_review", + } + + +def test_telemetry_sums_sandbox_run_durations(tmp_path: Path) -> None: + repository = ReviewRepository(f"sqlite:///{tmp_path / 'review.db'}") + repository.initialize() + task_id = "task_sandbox_duration" + repository.create_task( + task_id, + input_type="diff", + input_digest="digest", + summary="summary", + ) + decision = FilterDecision( + decision=Decision.ALLOW, + reason_code="allowed", + reason="Execution allowed", + ) + runs = [ + SandboxRunResult( + id=f"run_{task_type}", + runtime="local", + task_type=task_type, + command=["true"], + status="completed", + duration_ms=duration_ms, + decision=decision, + ) + for task_type, duration_ms in ( + ("custom_rule", 125), + ("static_check", 250), + ("test", 375), + ) + ] + report = ReviewReport( + task_id=task_id, + status="completed", + conclusion="Review completed", + input_summary="summary", + sandbox_runs=runs, + metrics={ + "total_duration_ms": 1000, + "stage_duration_ms": { + run.task_type: run.duration_ms for run in runs + }, + }, + ) + + repository.save_review_result(report) + + trace = repository.get_task(task_id) + assert trace is not None + assert trace["telemetry"]["sandbox_duration"] == pytest.approx(0.75) + + +def test_stable_task_id_replaces_previous_review_trace(tmp_path: Path) -> None: + repository = ReviewRepository(f"sqlite:///{tmp_path / 'review.db'}") + repository.initialize() + task_id = "stable_replay_id" + decision = FilterDecision( + decision=Decision.ALLOW, + reason_code="allowed", + reason="Execution allowed", + ) + + def save(conclusion: str, duration_ms: int) -> None: + repository.create_task( + task_id, + input_type="diff", + input_digest=f"digest-{conclusion}", + summary=conclusion, + ) + repository.save_review_result( + ReviewReport( + task_id=task_id, + status="completed", + conclusion=conclusion, + input_summary=conclusion, + sandbox_runs=[ + SandboxRunResult( + id="stable_run_id", + runtime="local", + command=["true"], + status="completed", + duration_ms=duration_ms, + decision=decision, + ) + ], + filter_decisions=[decision], + metrics={"total_duration_ms": duration_ms}, + ) + ) + + save("first review", 100) + save("replayed review", 250) + + trace = repository.get_task(task_id) + assert trace is not None + assert trace["diff_summary"] == "replayed review" + assert len(trace["skill_executions"]) == 1 + assert len(trace["sandbox_runs"]) == 1 + assert len(trace["filter_decisions"]) == 1 + assert json.loads(trace["report"]["summary"])["conclusion"] == "replayed review" + assert trace["telemetry"]["sandbox_duration"] == pytest.approx(0.25) + + +@pytest.mark.asyncio +async def test_run_review_replays_task_id_and_updates_trace( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + parsed = parse_review_input(diff_file=_diff(tmp_path)) + repository = ReviewRepository(f"sqlite:///{tmp_path / 'review.db'}") + repository.initialize() + task_id = "run_review_replay_id" + invocation = 0 + + async def fake_sandbox_checks( + *args: object, + **kwargs: object, + ) -> tuple[list[SandboxRunResult], list[Finding], list[str]]: + nonlocal invocation + invocation += 1 + duration_ms = 100 * invocation + decision = FilterDecision( + decision=Decision.ALLOW, + reason_code="allowed", + reason="Execution allowed", + ) + return [ + SandboxRunResult( + id="stable_sandbox_run", + runtime="local", + command=["true"], + status="completed", + duration_ms=duration_ms, + stdout_summary=f"replay-{invocation}", + decision=decision, + ) + ], [], [] + + monkeypatch.setattr( + "examples.skills_code_review_agent.agent.review.run_sandbox_checks", + fake_sandbox_checks, + ) + request = ReviewRequest( + review_input=parsed, + runtime="local", + task_id=task_id, + ) + + await run_review(request, repository=repository) + replayed = await run_review(request, repository=repository) + + trace = repository.get_task(task_id) + assert replayed.task_id == task_id + assert trace is not None + assert len(trace["sandbox_runs"]) == 1 + assert trace["sandbox_runs"][0]["stdout"] == "replay-2" + assert trace["sandbox_runs"][0]["duration"] == pytest.approx(0.2) + assert trace["telemetry"]["sandbox_duration"] == pytest.approx(0.2) + + +@pytest.mark.asyncio +async def test_destroy_workspace_runtime_when_supported() -> None: + class AsyncRuntime: + destroyed = False + + async def destroy(self) -> None: + self.destroyed = True + + class SyncRuntime: + destroyed = False + + def destroy(self) -> None: + self.destroyed = True + + async_runtime = AsyncRuntime() + sync_runtime = SyncRuntime() + + await _destroy_workspace_runtime(async_runtime) + await _destroy_workspace_runtime(sync_runtime) + await _destroy_workspace_runtime(object()) + + assert async_runtime.destroyed + assert sync_runtime.destroyed + + def test_case_clean_diff_has_no_findings(tmp_path: Path) -> None: diff = _write_diff( tmp_path,