Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions examples/skills_code_review_agent/DESIGN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# 方案设计说明

code-review Skill 保存稳定工作流,规则与脚本按需加载。Agent 从 diff、路径或 Git 工作区提取新增行,检查代码执行、异步阻塞、资源泄漏、数据库连接、敏感信息和测试缺失。同文件同一行同类仅保留最高置信项,低于阈值的结果进入人工复核。

生产默认使用无网络、只读挂载且限制资源的容器;fake mode 用于验收,本地执行只是开发 fallback。Filter 前置检查命令、路径、网络和预算,deny 或 needs_human_review 均不执行。环境采用白名单,输出、finding、报告和入库前统一脱敏;沙箱失败不终止静态评审。

SQLite 按 task id 关联 task、sandbox run、filter block、finding 与 report,可通过 ReviewStore 接入其他 SQL。监控记录耗时、工具、拦截、finding、severity 和异常。数据库不保存原始 diff,只保存哈希与脱敏摘要;JSON 供机器消费,Markdown 供审批。静态规则和容器不能发现所有风险,生产仍需最小权限、镜像审计和人工复核。
29 changes: 29 additions & 0 deletions examples/skills_code_review_agent/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Skills code-review agent

This prototype composes a reusable `code-review` Skill, deterministic diff rules, a Filter-governed container runner,
SQLite persistence, redaction, monitoring, and JSON/Markdown reports. It accepts unified diffs, a Git workspace, or a
file list.

Run the complete flow without an API key or Docker:

```bash
python examples/skills_code_review_agent/cli.py \
--diff-file examples/skills_code_review_agent/fixtures/security.diff \
--dry-run
```

Outputs are `review_report.json`, `review_report.md`, and `reviews.db`. Query a task with
`SQLiteReviewStore("reviews.db").get_task(task_id)`. The store interface separates orchestration from SQLite so another
SQL backend can implement the same three methods.

Production policy defaults to `container`, never local execution. The runner uses Docker with no network, a read-only
workspace, memory/CPU/PID limits, a timeout, capped output, and an environment allowlist. `--dry-run` selects the fake
runner for tests. Local mode exists only as an explicit development fallback. Every command is checked before sandbox
entry; `deny` and `needs_human_review` are persisted and never executed.

The static rules cover security, async errors, file/network resource lifetime, database connection lifetime, secrets,
and missing tests. They only inspect added lines and are intentionally conservative. Confidence below 0.75 is routed to
human-review warnings. Findings are deduplicated by file, line, and category. Static analysis and containers reduce risk
but do not prove correctness or replace least-privilege infrastructure.

See `DESIGN.md`, `schema.sql`, and `skills/code-review/references/rules.md` for the architecture and extension points.
132 changes: 132 additions & 0 deletions examples/skills_code_review_agent/agent.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
"""Automatic code-review orchestration."""

from __future__ import annotations

import hashlib
import time
import uuid
from collections import Counter
from dataclasses import replace
from pathlib import Path

from diff_parser import DiffParser
from models import Finding
from models import ReviewReport
from redaction import redact_text
from reporting import write_reports
from rules import RuleEngine
from sandbox import SandboxRunner
from storage import SQLiteReviewStore


class CodeReviewAgent:
def __init__(
self,
root: str | Path,
database: str | Path,
policy: str | Path,
dry_run: bool = False,
):
self.root = Path(root)
self.store = SQLiteReviewStore(database)
self.runner = SandboxRunner(policy, self.root, dry_run=dry_run)
self.rules = RuleEngine()
self.confidence_threshold = float(self.runner.policy["confidence_threshold"])

def review_diff(
self,
diff: str,
output_dir: str | Path,
commands: list[list[str]] | None = None,
) -> ReviewReport:
started = time.perf_counter()
task_id = str(uuid.uuid4())
clean_diff, _ = redact_text(diff)
# Rules inspect the in-memory source so secret detectors retain their
# signal. Only redacted derivatives cross a logging/persistence boundary.
lines = DiffParser.parse(diff)
files = sorted({line.file for line in lines})
summary = {
"sha256": hashlib.sha256(diff.encode("utf-8")).hexdigest(),
"files": files,
"added_lines": len(lines),
"redacted": clean_diff != diff,
}
self.store.create_task(task_id, summary["sha256"], summary)
exceptions: Counter[str] = Counter()
sandbox_runs = []
try:
raw_findings = [self._redact_finding(item) for item in self.rules.scan(lines)]
findings = [item for item in raw_findings if item.confidence >= self.confidence_threshold]
warnings = [item for item in raw_findings if item.confidence < self.confidence_threshold]
for index, command in enumerate(commands or [["python", "-m", "compileall", "-q", "."]], 1):
run = self.runner.run(command, index)
sandbox_runs.append(run)
if run.error_type:
exceptions[run.error_type] += 1
filter_blocks = [
{"command": run.command, "decision": run.filter_decision, "reason": run.filter_reason}
for run in sandbox_runs if run.status == "blocked"
]
severity = Counter(item.severity for item in findings)
conclusion = "changes_requested" if any(
item.severity in ("critical", "high") for item in findings
) else "needs_human_review" if warnings or filter_blocks else "approve"
status = "completed_with_errors" if exceptions else "completed"
monitoring = {
"total_duration_ms": (time.perf_counter() - started) * 1000,
"sandbox_duration_ms": sum(run.duration_ms for run in sandbox_runs),
"tool_call_count": len(sandbox_runs),
"blocked_count": len(filter_blocks),
"finding_count": len(findings),
"warning_count": len(warnings),
"severity_distribution": dict(severity),
"exception_type_distribution": dict(exceptions),
}
report = ReviewReport(
task_id=task_id,
status=status,
conclusion=conclusion,
input_summary=summary,
findings=findings,
warnings=warnings,
filter_blocks=filter_blocks,
sandbox_runs=sandbox_runs,
monitoring=monitoring,
)
except Exception as exc:
exceptions[type(exc).__name__] += 1
report = ReviewReport(
task_id=task_id,
status="failed",
conclusion="needs_human_review",
input_summary=summary,
monitoring={
"total_duration_ms": (time.perf_counter() - started) * 1000,
"sandbox_duration_ms": 0.0,
"tool_call_count": 0,
"blocked_count": 0,
"finding_count": 0,
"warning_count": 0,
"severity_distribution": {},
"exception_type_distribution": dict(exceptions),
},
)
self.store.save_report(report)
write_reports(report, output_dir)
return report

def review_file(self, diff_file: str | Path, output_dir: str | Path) -> ReviewReport:
diff = Path(diff_file).read_text(encoding="utf-8")
return self.review_diff(diff, output_dir)

def review_repo(self, repo_path: str | Path, output_dir: str | Path) -> ReviewReport:
diff, _ = DiffParser.from_repo(repo_path)
return self.review_diff(diff, output_dir)

@staticmethod
def _redact_finding(finding: Finding) -> Finding:
evidence, _ = redact_text(finding.evidence)
title, _ = redact_text(finding.title)
recommendation, _ = redact_text(finding.recommendation)
return replace(finding, evidence=evidence, title=title, recommendation=recommendation)
47 changes: 47 additions & 0 deletions examples/skills_code_review_agent/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"""CLI for diff files, repositories, file lists, and database lookup."""

from __future__ import annotations

import argparse
from pathlib import Path

from agent import CodeReviewAgent
from diff_parser import DiffParser


HERE = Path(__file__).resolve().parent


def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
source = parser.add_mutually_exclusive_group()
source.add_argument("--diff-file", type=Path)
source.add_argument("--repo-path", type=Path)
source.add_argument("--files", type=Path, nargs="+")
parser.add_argument("--output-dir", type=Path, default=HERE)
parser.add_argument("--database", type=Path, default=HERE / "reviews.db")
parser.add_argument("--policy", type=Path, default=HERE / "review_policy.yaml")
parser.add_argument("--dry-run", action="store_true", help="Use fake sandbox; no model or Docker required")
args = parser.parse_args()
if not any((args.diff_file, args.repo_path, args.files)):
parser.error("one of --diff-file, --repo-path, or --files is required")

agent = CodeReviewAgent(
root=args.repo_path or Path.cwd(),
database=args.database,
policy=args.policy,
dry_run=args.dry_run,
)
if args.diff_file:
report = agent.review_file(args.diff_file, args.output_dir)
elif args.repo_path:
report = agent.review_repo(args.repo_path, args.output_dir)
else:
diff, _ = DiffParser.from_paths(args.files)
report = agent.review_diff(diff, args.output_dir)
print(f"task_id={report.task_id} conclusion={report.conclusion}")
return 0 if report.status.startswith("completed") else 1


if __name__ == "__main__":
raise SystemExit(main())
64 changes: 64 additions & 0 deletions examples/skills_code_review_agent/diff_parser.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
"""Unified diff and Git workspace input adapters."""

from __future__ import annotations

import subprocess
from pathlib import Path

from models import ChangedLine


class DiffParser:
@staticmethod
def from_file(path: str | Path) -> tuple[str, list[ChangedLine]]:
text = Path(path).read_text(encoding="utf-8")
return text, DiffParser.parse(text)

@staticmethod
def from_repo(path: str | Path) -> tuple[str, list[ChangedLine]]:
result = subprocess.run(
["git", "diff", "--no-ext-diff", "--unified=3", "HEAD"],
cwd=Path(path),
capture_output=True,
text=True,
check=False,
timeout=10,
)
if result.returncode:
raise ValueError(f"git diff failed: {result.stderr.strip()}")
return result.stdout, DiffParser.parse(result.stdout)

@staticmethod
def from_paths(paths: list[str | Path]) -> tuple[str, list[ChangedLine]]:
chunks = []
for raw_path in paths:
path = Path(raw_path)
lines = path.read_text(encoding="utf-8").splitlines()
chunks.extend([f"diff --git a/{path} b/{path}", f"+++ b/{path}", f"@@ -0,0 +1,{len(lines)} @@"])
chunks.extend(f"+{line}" for line in lines)
text = "\n".join(chunks) + "\n"
return text, DiffParser.parse(text)

@staticmethod
def parse(diff: str) -> list[ChangedLine]:
changed: list[ChangedLine] = []
current_file = ""
new_line = 0
hunk_header = ""
for raw in diff.splitlines():
if raw.startswith("+++ "):
current_file = raw[4:].removeprefix("b/")
continue
if raw.startswith("@@"):
hunk_header = raw
marker = raw.split("+", 1)[1].split(" ", 1)[0]
new_line = int(marker.split(",", 1)[0])
continue
if raw.startswith("+") and not raw.startswith("+++"):
changed.append(ChangedLine(current_file, new_line, raw[1:], hunk_header))
new_line += 1
elif raw.startswith("-") and not raw.startswith("---"):
continue
elif hunk_header and not raw.startswith("\\"):
new_line += 1
return changed
8 changes: 8 additions & 0 deletions examples/skills_code_review_agent/fixtures/async_leak.diff
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
diff --git a/worker.py b/worker.py
--- a/worker.py
+++ b/worker.py
@@ -1,1 +1,4 @@
+async def poll():
+ time.sleep(10)
+ asyncio.create_task(refresh())
+ return True
12 changes: 12 additions & 0 deletions examples/skills_code_review_agent/fixtures/clean.diff
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
diff --git a/app.py b/app.py
--- a/app.py
+++ b/app.py
@@ -1,1 +1,2 @@
def add(a, b):
+ return a + b
diff --git a/tests/test_app.py b/tests/test_app.py
--- a/tests/test_app.py
+++ b/tests/test_app.py
@@ -1,1 +1,2 @@
def test_add():
+ assert add(1, 2) == 3
6 changes: 6 additions & 0 deletions examples/skills_code_review_agent/fixtures/database.diff
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
diff --git a/db.py b/db.py
--- a/db.py
+++ b/db.py
@@ -1,1 +1,2 @@
def load():
+ connection = sqlite3.connect("data.db")
6 changes: 6 additions & 0 deletions examples/skills_code_review_agent/fixtures/duplicate.diff
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
diff --git a/unsafe.py b/unsafe.py
--- a/unsafe.py
+++ b/unsafe.py
@@ -1,1 +1,2 @@
def run(value):
+ return eval(value)
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
diff --git a/app.py b/app.py
--- a/app.py
+++ b/app.py
@@ -1,1 +1,2 @@
def healthy():
+ return True
6 changes: 6 additions & 0 deletions examples/skills_code_review_agent/fixtures/secret.diff
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
diff --git a/config.py b/config.py
--- a/config.py
+++ b/config.py
@@ -1,1 +1,2 @@
def config():
+ api_key = "sk-super-secret-value"
6 changes: 6 additions & 0 deletions examples/skills_code_review_agent/fixtures/security.diff
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
diff --git a/app.py b/app.py
--- a/app.py
+++ b/app.py
@@ -1,1 +1,2 @@
def run(user_input):
+ return eval(user_input)
6 changes: 6 additions & 0 deletions examples/skills_code_review_agent/fixtures/test_missing.diff
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
diff --git a/service.py b/service.py
--- a/service.py
+++ b/service.py
@@ -1,1 +1,2 @@
def calculate(value):
+ return value * 2
Loading
Loading