diff --git a/examples/skills_code_review_agent/.gitignore b/examples/skills_code_review_agent/.gitignore new file mode 100644 index 000000000..d4bfac604 --- /dev/null +++ b/examples/skills_code_review_agent/.gitignore @@ -0,0 +1,3 @@ +data/ +output/ +__pycache__/ diff --git a/examples/skills_code_review_agent/DESIGN.md b/examples/skills_code_review_agent/DESIGN.md new file mode 100644 index 000000000..95bea0e7c --- /dev/null +++ b/examples/skills_code_review_agent/DESIGN.md @@ -0,0 +1,3 @@ +# 方案设计说明 + +原型分为五层。输入层解析 unified diff、文件列表及 Git 工作区,提取文件、hunk、上下文和目标行号,仅保存哈希、统计与脱敏预览。Skill 包含 `SKILL.md`、规则文档、机器规则和确定性脚本,经标准 `skill_load`、`skill_run` 调用,无模型密钥也可复现。治理层继承 `BaseFilter`,在创建 workspace 前检查脚本、路径、网络、环境变量、超时和输出预算;`deny` 与 `needs_human_review` 都会终止执行并记录原因。执行层默认使用禁网 Container,Local 仅作显式开发回退,失败或超时不会中断报告。存储层基于 `SqlStorage`,分别保存 task、sandbox run、filter decision、finding、metrics 和 report,可通过数据库 URL 更换后端。finding 按文件、行号、类别去重,高置信结果进入主列表,低置信结果转人工复核。扫描输出、主机解析、报告渲染和数据库写入均执行脱敏。监控记录总耗时、沙箱耗时、工具调用、拦截、severity、异常与脱敏次数,用于评测和回放。 diff --git a/examples/skills_code_review_agent/README.md b/examples/skills_code_review_agent/README.md new file mode 100644 index 000000000..015194593 --- /dev/null +++ b/examples/skills_code_review_agent/README.md @@ -0,0 +1,111 @@ +# Skills 自动代码评审 Agent + +该示例把 tRPC-Agent 的 Skill、Filter、Container workspace、SQL 存储和 +Telemetry 组合成可复现的代码评审流程。默认扫描器是确定性规则引擎, +`--dry-run` / `--fake-model` 不需要任何模型 API Key,但仍完整执行解析、 +Filter、沙箱、落库和报告生成。 + +## 能力 + +- 输入:unified diff、PR patch、文件列表、Git 工作区和 8 个公开 fixture +- Skill:`skills/code-review/SKILL.md`、规则文档、机器规则和沙箱脚本 +- 规则:安全、异步错误、资源泄漏、测试缺失、敏感信息、数据库生命周期 +- 沙箱:生产默认 Container 且禁网;Local 只能显式选择用于开发 +- 治理:脚本/路径/网络/环境/超时/输出预算在 workspace 创建前由 Filter 决策 +- 存储:SQLite 默认,接口和 SQLAlchemy URL 可切换 MySQL/PostgreSQL +- 输出:`review_report.json`、`review_report.md` 和按 task id 查询的完整记录 +- 审计:总耗时、沙箱耗时、工具调用、拦截、severity、异常及脱敏统计 + +详细设计见 [DESIGN.md](./DESIGN.md)。 + +## 目录 + +```text +agent/ # 编排、Filter、沙箱、存储、报告 +scripts/init_db.py # Schema 初始化 +scripts/query_review.py # 按 task id 回放 +skills/code-review/ + SKILL.md + references/rules.md + references/rules.json + scripts/review_diff.py + scripts/timeout_probe.py +tests/fixtures/ # 8 个公开 diff 样本 +sample_output/ # 稳定的示例 JSON 报告 +``` + +## 环境 + +在仓库根目录安装项目和测试依赖: + +```bash +python3 -m venv .venv +source .venv/bin/activate +pip install -e . +pip install -r requirements-test.txt +``` + +Container 是默认运行时,需要可用的 Docker daemon。镜像由 SDK 的 +Container runtime 管理,网络模式固定为 `none`。 + +## 运行 + +```bash +cd examples/skills_code_review_agent + +# 默认生产路径:Container + SQLite + fake model +python run_agent.py \ + --diff-file tests/fixtures/security.diff \ + --runtime container \ + --fake-model + +# 显式开发回退,不会调用模型 +python run_agent.py \ + --fixture secrets.diff \ + --runtime local \ + --dry-run + +# Git 工作区或文件列表 +python run_agent.py --repo-path /path/to/repo --runtime container +python run_agent.py --files app/a.py app/b.py --runtime container + +# 一次运行全部公开 fixture +python run_agent.py --run-all-fixtures --runtime local --dry-run +``` + +默认产物在 `output/`,数据库在 `data/reviews.db`。这两个目录均被本示例 +忽略,不会污染提交。 + +## 查询数据库 + +```bash +python scripts/init_db.py +python scripts/query_review.py cr_TASK_ID +``` + +查询结果包含 task 状态、输入摘要、sandbox run、Filter 拦截、findings、 +监控摘要和最终报告。所有持久化入口都会再次脱敏。 + +## Filter 与安全边界 + +执行请求必须使用白名单内的 Python 脚本,且不能包含 shell 操作符、绝对 +脚本路径、禁止路径、非白名单网络目标或环境变量。超时和输出预算超过策略 +会进入 `needs_human_review`,与 `deny` 一样不会启动沙箱。Container 默认 +禁网;运行时仅接收临时 diff 文件,不继承宿主机 provider 环境变量。stdout、 +stderr 和输出文件均有大小限制,超时、非零退出和解析失败只会把任务标记为 +`completed_with_warnings`,不会使整个评审崩溃。 + +## 测试 + +```bash +# 单元与 Local 集成测试 +pytest -q tests/examples/test_skills_code_review_agent.py + +# 真实 Docker E2E +RUN_CODE_REVIEW_CONTAINER_TEST=1 \ + pytest -q tests/examples/test_skills_code_review_agent.py -k container +``` + +测试覆盖无问题 diff、安全问题、异步资源泄漏、数据库连接生命周期、测试 +缺失、重复 finding、沙箱失败、敏感信息脱敏,以及额外的 Filter 拦截和超时 +恢复。公开 fixture 的完整 fake-model 流程目标耗时小于 2 分钟。 diff --git a/examples/skills_code_review_agent/__init__.py b/examples/skills_code_review_agent/__init__.py new file mode 100644 index 000000000..da69e4141 --- /dev/null +++ b/examples/skills_code_review_agent/__init__.py @@ -0,0 +1,8 @@ +# +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# +"""Skills-based code review agent 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..34772238a --- /dev/null +++ b/examples/skills_code_review_agent/agent/__init__.py @@ -0,0 +1,13 @@ +# +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# +"""Code review workflow implementation.""" + +from .workflow import CodeReviewAgent +from .workflow import ReviewConfig + +__all__ = ["CodeReviewAgent", "ReviewConfig"] diff --git a/examples/skills_code_review_agent/agent/core.py b/examples/skills_code_review_agent/agent/core.py new file mode 100644 index 000000000..95d739e12 --- /dev/null +++ b/examples/skills_code_review_agent/agent/core.py @@ -0,0 +1,390 @@ +# +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# +"""Core models, input handling, redaction, and finding normalization.""" + +from __future__ import annotations + +import difflib +import hashlib +import re +import shlex +import subprocess +from collections import Counter +from pathlib import Path +from typing import Any +from typing import Iterable +from typing import Optional + +from pydantic import BaseModel +from pydantic import Field + +MAX_DIFF_BYTES = 2 * 1024 * 1024 +CONFIDENCE_THRESHOLD = 0.80 +SEVERITY_ORDER = { + "critical": 5, + "high": 4, + "medium": 3, + "low": 2, + "info": 1, +} +_SECRET_KEY_NAME = ( + r"(?:[a-z0-9]+[_-])*" + r"(?:api[_-]?key|access[_-]?token|auth[_-]?token|secret|password|passwd|pwd)" +) +_SECRET_KEY_PREFIX = rf"(?:(?:\b{_SECRET_KEY_NAME}\b)|(?:[\"']{_SECRET_KEY_NAME}[\"']))\s*[:=]\s*" + + +class Finding(BaseModel): + """A normalized review finding.""" + + severity: str + category: str + file: str + line: int = Field(ge=0) + title: str + evidence: str + recommendation: str + confidence: float = Field(ge=0.0, le=1.0) + source: str + + +class InputSummary(BaseModel): + """Non-sensitive summary of the review input.""" + + input_type: str + sha256: str + byte_count: int + file_count: int + hunk_count: int + changed_line_count: int + files: list[str] + diff_preview: str + + +class FilterDecision(BaseModel): + """Audit record emitted before a sandbox is created.""" + + decision: str + rule_id: str + reason: str + script: str + network_hosts: list[str] = Field(default_factory=list) + + +class SandboxRun(BaseModel): + """Bounded sandbox execution summary.""" + + runtime: str + status: str + command: str + duration_ms: int = 0 + exit_code: Optional[int] = None + timed_out: bool = False + stdout: str = "" + stderr: str = "" + output_truncated: bool = False + error_type: str = "" + skill_loaded: bool = False + + +class MonitoringSummary(BaseModel): + """Metrics persisted with every review.""" + + total_duration_ms: int = 0 + sandbox_duration_ms: int = 0 + tool_calls: int = 0 + interception_count: int = 0 + finding_count: int = 0 + warning_count: int = 0 + severity_distribution: dict[str, int] = Field(default_factory=dict) + exception_distribution: dict[str, int] = Field(default_factory=dict) + redaction_count: int = 0 + + +class ReviewReport(BaseModel): + """Complete machine-readable review report.""" + + task_id: str + status: str + model_mode: str + input_summary: InputSummary + findings: list[Finding] = Field(default_factory=list) + warnings: list[Finding] = Field(default_factory=list) + needs_human_review: list[str] = Field(default_factory=list) + filter_decisions: list[FilterDecision] = Field(default_factory=list) + sandbox_runs: list[SandboxRun] = Field(default_factory=list) + monitoring: MonitoringSummary = Field(default_factory=MonitoringSummary) + operational_warnings: list[str] = Field(default_factory=list) + conclusion: str = "" + generated_at: str + + +class ResolvedInput(BaseModel): + """Resolved review input before sandbox staging.""" + + input_type: str + diff_text: str + summary: InputSummary + + +class SecretRedactor: + """Redact common credentials from every persistence and reporting path.""" + + _known_tokens = ( + re.compile(r"(?i)\bBearer\s+[A-Za-z0-9._~+/=-]{8,}"), + re.compile(r"\b(?:sk|rk)-(?:live|test|proj)-[A-Za-z0-9_-]{8,}"), + re.compile(r"\bgh[pousr]_[A-Za-z0-9]{16,}"), + re.compile(r"\bgithub_pat_[A-Za-z0-9_]{16,}"), + re.compile(r"\bAKIA[0-9A-Z]{16}\b"), + re.compile(r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b"), + ) + _assignment = re.compile( + rf"""(?ix) + ({_SECRET_KEY_PREFIX}) + ("[^"\r\n]{{4,}}"|'[^'\r\n]{{4,}}'|[^"'\s,;}}]{{4,}}) + """ + ) + _url_credentials = re.compile(r"(?i)([a-z][a-z0-9+.-]*://[^:/\s]+:)([^@\s/]+)(@)") + _private_key = re.compile( + r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----.*?" + r"-----END (?:RSA |EC |OPENSSH )?PRIVATE KEY-----", + re.DOTALL, + ) + _private_key_marker = re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----") + + @classmethod + def redact_text(cls, value: str) -> tuple[str, int]: + """Return redacted text and replacement count.""" + + redacted = value + count = 0 + for pattern in cls._known_tokens: + redacted, replaced = pattern.subn("[REDACTED]", redacted) + count += replaced + redacted, replaced = cls._assignment.subn(cls._redact_assignment, redacted) + count += replaced + redacted, replaced = cls._url_credentials.subn(r"\1[REDACTED]\3", redacted) + count += replaced + redacted, replaced = cls._private_key.subn("[REDACTED_PRIVATE_KEY]", redacted) + count += replaced + redacted, replaced = cls._private_key_marker.subn("[REDACTED_PRIVATE_KEY]", redacted) + count += replaced + return redacted, count + + @staticmethod + def _redact_assignment(match: re.Match[str]) -> str: + value = match.group(2) + quote = value[0] if value[0] in {'"', "'"} else "" + return f"{match.group(1)}{quote}[REDACTED]{quote}" + + @classmethod + def redact_value(cls, value: Any) -> tuple[Any, int]: + """Recursively redact strings in structured values.""" + + if isinstance(value, str): + return cls.redact_text(value) + if isinstance(value, list): + output = [] + count = 0 + for item in value: + clean, item_count = cls.redact_value(item) + output.append(clean) + count += item_count + return output, count + if isinstance(value, tuple): + output, count = cls.redact_value(list(value)) + return tuple(output), count + if isinstance(value, dict): + output = {} + count = 0 + for key, item in value.items(): + clean, item_count = cls.redact_value(item) + output[key] = clean + count += item_count + return output, count + return value, 0 + + +class InputResolver: + """Resolve unified diffs, selected files, or git workspace changes.""" + + def __init__(self, max_diff_bytes: int = MAX_DIFF_BYTES): + self.max_diff_bytes = max_diff_bytes + + def resolve_diff_file(self, path: Path, input_type: str = "diff_file") -> ResolvedInput: + path = path.expanduser().resolve() + if not path.is_file(): + raise FileNotFoundError(f"diff file not found: {path}") + raw = path.read_bytes() + if len(raw) > self.max_diff_bytes: + raise ValueError(f"diff exceeds {self.max_diff_bytes} byte input limit") + text = raw.decode("utf-8", errors="replace") + return ResolvedInput(input_type=input_type, diff_text=text, summary=self.summarize(text, input_type)) + + def resolve_repo(self, repo_path: Path, files: Optional[list[str]] = None) -> ResolvedInput: + repo = repo_path.expanduser().resolve() + if not repo.is_dir(): + raise ValueError(f"not a git working tree: {repo}") + try: + is_work_tree = self._run_git(repo, ["rev-parse", "--is-inside-work-tree"]).strip() + except RuntimeError as error: + raise ValueError(f"not a git working tree: {repo}") from error + if is_work_tree != "true": + raise ValueError(f"not a git working tree: {repo}") + + path_args = ["--", *files] if files else [] + diff = self._run_git(repo, ["diff", "--no-ext-diff", "--unified=3", "HEAD", *path_args]) + untracked = self._run_git(repo, ["ls-files", "--others", "--exclude-standard", *path_args]).splitlines() + selected = set(files or []) + for relative in untracked: + if selected and relative not in selected: + continue + diff += self._new_file_diff(repo, relative) + + encoded = diff.encode("utf-8") + if len(encoded) > self.max_diff_bytes: + raise ValueError(f"workspace diff exceeds {self.max_diff_bytes} byte input limit") + return ResolvedInput(input_type="git_workspace", diff_text=diff, + summary=self.summarize(diff, "git_workspace")) + + def resolve_files(self, paths: list[Path]) -> ResolvedInput: + if not paths: + raise ValueError("at least one file path is required") + chunks = [] + for path in paths: + resolved = path.expanduser().resolve() + if not resolved.is_file(): + raise FileNotFoundError(f"input file not found: {resolved}") + content = resolved.read_text(encoding="utf-8", errors="replace").splitlines(keepends=True) + relative = resolved.name + chunks.append(f"diff --git a/{relative} b/{relative}\n") + chunks.extend( + difflib.unified_diff( + [], + content, + fromfile="/dev/null", + tofile=f"b/{relative}", + lineterm="\n", + )) + diff = "".join(chunks) + if len(diff.encode("utf-8")) > self.max_diff_bytes: + raise ValueError(f"selected files exceed {self.max_diff_bytes} byte input limit") + return ResolvedInput(input_type="file_list", diff_text=diff, summary=self.summarize(diff, "file_list")) + + @staticmethod + def _run_git(repo: Path, args: list[str]) -> str: + result = subprocess.run( + ["git", "-C", str(repo), *args], + check=False, + capture_output=True, + text=True, + timeout=20, + ) + if result.returncode != 0: + raise RuntimeError(result.stderr.strip() or f"git {' '.join(args)} failed") + return result.stdout + + @staticmethod + def _new_file_diff(repo: Path, relative: str) -> str: + path = (repo / relative).resolve() + if not path.is_relative_to(repo) or not path.is_file(): + return "" + content = path.read_text(encoding="utf-8", errors="replace").splitlines(keepends=True) + output = [f"diff --git a/{relative} b/{relative}\n", "new file mode 100644\n"] + output.extend( + difflib.unified_diff( + [], + content, + fromfile="/dev/null", + tofile=f"b/{relative}", + lineterm="\n", + )) + return "".join(output) + + @staticmethod + def summarize(diff_text: str, input_type: str) -> InputSummary: + files: list[str] = [] + hunk_count = 0 + changed_line_count = 0 + for line in diff_text.splitlines(): + if line.startswith("diff --git "): + try: + parts = shlex.split(line) + candidate = parts[3] + except (ValueError, IndexError): + candidate = "" + if candidate.startswith("b/"): + candidate = candidate[2:] + if candidate and candidate not in files: + files.append(candidate) + elif line.startswith("+++ "): + header_path = line[4:].split("\t", 1)[0] + try: + candidate = shlex.split(header_path)[0] + except (ValueError, IndexError): + candidate = header_path + if candidate.startswith("b/"): + candidate = candidate[2:] + if candidate != "/dev/null" and candidate not in files: + files.append(candidate) + elif line.startswith("@@ "): + hunk_count += 1 + elif line.startswith(("+", "-")) and not line.startswith(("+++", "---")): + changed_line_count += 1 + + clean_preview, _ = SecretRedactor.redact_text(diff_text[:2000]) + return InputSummary( + input_type=input_type, + sha256=hashlib.sha256(diff_text.encode("utf-8")).hexdigest(), + byte_count=len(diff_text.encode("utf-8")), + file_count=len(files), + hunk_count=hunk_count, + changed_line_count=changed_line_count, + files=files, + diff_preview=clean_preview, + ) + + +def normalize_findings(raw_findings: Iterable[dict[str, Any]]) -> tuple[list[Finding], list[Finding], int]: + """Validate, redact, deduplicate, and bucket scanner findings.""" + + deduplicated: dict[tuple[str, int, str], Finding] = {} + redaction_count = 0 + for raw in raw_findings: + clean, count = SecretRedactor.redact_value(raw) + redaction_count += count + finding = Finding.model_validate(clean) + key = (finding.file, finding.line, finding.category) + previous = deduplicated.get(key) + if previous is None: + deduplicated[key] = finding + continue + previous_score = (SEVERITY_ORDER.get(previous.severity, 0), previous.confidence) + current_score = (SEVERITY_ORDER.get(finding.severity, 0), finding.confidence) + if current_score > previous_score: + deduplicated[key] = finding + + ordered = sorted( + deduplicated.values(), + key=lambda item: ( + -SEVERITY_ORDER.get(item.severity, 0), + item.file, + item.line, + item.category, + ), + ) + findings = [item for item in ordered if item.confidence >= CONFIDENCE_THRESHOLD] + warnings = [item for item in ordered if item.confidence < CONFIDENCE_THRESHOLD] + return findings, warnings, redaction_count + + +def severity_distribution(findings: Iterable[Finding]) -> dict[str, int]: + """Return stable severity counters.""" + + counts = Counter(item.severity for item in findings) + return {severity: counts[severity] for severity in SEVERITY_ORDER if counts[severity]} diff --git a/examples/skills_code_review_agent/agent/governance.py b/examples/skills_code_review_agent/agent/governance.py new file mode 100644 index 000000000..58006ffdf --- /dev/null +++ b/examples/skills_code_review_agent/agent/governance.py @@ -0,0 +1,222 @@ +# +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# +"""Pre-execution Filter policy for code review sandbox runs.""" + +from __future__ import annotations + +import posixpath +import re +import shlex +from collections.abc import Callable +from typing import Any + +from trpc_agent_sdk.abc import FilterResult +from trpc_agent_sdk.context import AgentContext +from trpc_agent_sdk.filter import BaseFilter + +from .core import FilterDecision + +_ENV_NAME = re.compile(r"^[A-Z_][A-Z0-9_]*$") +_SHELL_META = re.compile(r"[;&|`$<>\n\r]") +_HIGH_RISK_COMMANDS = frozenset({ + "bash", + "chmod", + "chown", + "curl", + "dd", + "docker", + "mkfs", + "mount", + "nc", + "netcat", + "rm", + "sh", + "sudo", + "wget", +}) +_FORBIDDEN_PATH_PREFIXES = ( + "/boot", + "/dev", + "/etc", + "/proc", + "/root", + "/sys", + "/var/run", +) + + +class ReviewExecutionFilter(BaseFilter): + """Block unsafe or over-budget Skill runs before workspace creation.""" + + def __init__( + self, + *, + allowed_scripts: set[str], + env_allowlist: set[str], + network_allowlist: set[str], + max_timeout_seconds: int, + max_output_bytes: int, + decision_sink: Callable[[FilterDecision], None], + ) -> None: + super().__init__() + self.name = "code_review_execution_policy" + self._allowed_scripts = {self._normalize_script(item) for item in allowed_scripts} + self._env_allowlist = env_allowlist + self._network_allowlist = network_allowlist + self._max_timeout_seconds = max_timeout_seconds + self._max_output_bytes = max_output_bytes + self._decision_sink = decision_sink + + async def _before(self, ctx: AgentContext, req: Any, rsp: FilterResult) -> None: + """Evaluate command, path, network, environment, and budget policy.""" + + decision = self.evaluate(req) + self._decision_sink(decision) + if decision.decision == "allow": + return + rsp.rsp = { + "filter_blocked": True, + "decision": decision.model_dump(), + } + rsp.is_continue = False + + def evaluate(self, request: Any) -> FilterDecision: + """Return a deterministic policy decision.""" + + if not isinstance(request, dict): + return self._decision("deny", "invalid_request", "Skill request must be an object.", "") + + command = str(request.get("command") or "").strip() + script = self._extract_script(command) + if not command or not script: + return self._decision( + "deny", + "invalid_command", + "Only a single python3 script command is allowed.", + script, + ) + if _SHELL_META.search(command): + return self._decision( + "deny", + "shell_meta", + "Shell operators and variable expansion are prohibited.", + script, + ) + try: + argv = shlex.split(command) + except ValueError: + return self._decision("deny", "invalid_command", "Command quoting is invalid.", script) + executable = posixpath.basename(argv[0]) if argv else "" + if executable in _HIGH_RISK_COMMANDS or executable not in {"python", "python3"}: + return self._decision( + "deny", + "high_risk_command", + f"Executable {executable!r} is not allowed.", + script, + ) + + normalized_script = self._normalize_script(script) + if (script.startswith("/") or normalized_script.startswith("../") + or normalized_script not in self._allowed_scripts): + return self._decision( + "deny", + "script_not_allowed", + f"Script {script!r} is outside the Skill allowlist.", + script, + ) + + for token in argv[2:]: + if token.startswith(_FORBIDDEN_PATH_PREFIXES): + return self._decision( + "deny", + "forbidden_path", + f"Access to path {token!r} is prohibited.", + script, + ) + + requested_hosts = request.get("network_hosts") or [] + if not isinstance(requested_hosts, list): + requested_hosts = [str(requested_hosts)] + denied_hosts = sorted(set(map(str, requested_hosts)) - self._network_allowlist) + if denied_hosts: + return self._decision( + "deny", + "network_not_allowed", + f"Network hosts are not allowlisted: {', '.join(denied_hosts)}.", + script, + requested_hosts, + ) + + env = request.get("env") or {} + if not isinstance(env, dict): + return self._decision("deny", "invalid_environment", "Environment must be an object.", script) + invalid_env = sorted( + key for key in map(str, env) + if not _ENV_NAME.fullmatch(key) or key not in self._env_allowlist) + if invalid_env: + return self._decision( + "deny", + "environment_not_allowed", + f"Environment keys are not allowlisted: {', '.join(invalid_env)}.", + script, + ) + + timeout = int(request.get("timeout") or 0) + if timeout <= 0 or timeout > self._max_timeout_seconds: + return self._decision( + "needs_human_review", + "timeout_budget", + f"Timeout {timeout}s exceeds the 1-{self._max_timeout_seconds}s policy.", + script, + ) + + outputs = request.get("outputs") or {} + if isinstance(outputs, dict): + max_total = int(outputs.get("max_total_bytes") or 0) + max_file = int(outputs.get("max_file_bytes") or 0) + if (max_total <= 0 or max_file <= 0 or max_total > self._max_output_bytes + or max_file > self._max_output_bytes): + return self._decision( + "needs_human_review", + "output_budget", + f"Output budget exceeds {self._max_output_bytes} bytes.", + script, + ) + + return self._decision("allow", "policy_allow", "Execution satisfies the sandbox policy.", script, + requested_hosts) + + @staticmethod + def _normalize_script(script: str) -> str: + return posixpath.normpath(script.strip().replace("\\", "/")) + + @staticmethod + def _extract_script(command: str) -> str: + try: + argv = shlex.split(command) + except ValueError: + return "" + if len(argv) < 2: + return "" + return argv[1] + + @staticmethod + def _decision( + decision: str, + rule_id: str, + reason: str, + script: str, + network_hosts: list[str] | None = None, + ) -> FilterDecision: + return FilterDecision( + decision=decision, + rule_id=rule_id, + reason=reason, + script=script, + network_hosts=network_hosts or [], + ) 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..f58074754 --- /dev/null +++ b/examples/skills_code_review_agent/agent/reporting.py @@ -0,0 +1,165 @@ +# +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# +"""JSON and Markdown report rendering with a final redaction gate.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from .core import Finding +from .core import ReviewReport +from .core import SecretRedactor + + +def sanitize_report(report: ReviewReport) -> ReviewReport: + """Apply defense-in-depth redaction to the complete report.""" + + clean, count = SecretRedactor.redact_value(report.model_dump()) + clean["monitoring"]["redaction_count"] += count + return ReviewReport.model_validate(clean) + + +def write_reports(report: ReviewReport, output_dir: Path) -> tuple[Path, Path]: + """Atomically write machine-readable and human-readable reports.""" + + output_dir.mkdir(parents=True, exist_ok=True) + json_path = output_dir / "review_report.json" + markdown_path = output_dir / "review_report.md" + json_text = json.dumps(report.model_dump(), ensure_ascii=False, indent=2, sort_keys=True) + "\n" + markdown_text = render_markdown(report) + + json_tmp = json_path.with_suffix(".json.tmp") + markdown_tmp = markdown_path.with_suffix(".md.tmp") + json_tmp.write_text(json_text, encoding="utf-8") + markdown_tmp.write_text(markdown_text, encoding="utf-8") + json_tmp.replace(json_path) + markdown_tmp.replace(markdown_path) + return json_path, markdown_path + + +def render_markdown(report: ReviewReport) -> str: + """Render all required review and audit sections.""" + + lines = [ + "# Automated Code Review Report", + "", + f"- Task: `{report.task_id}`", + f"- Status: `{report.status}`", + f"- Model mode: `{report.model_mode}`", + f"- Conclusion: **{report.conclusion}**", + f"- Input SHA-256: `{report.input_summary.sha256}`", + "", + "## Findings Summary", + "", + f"- High-confidence findings: {len(report.findings)}", + f"- Needs human review: {len(report.warnings)}", + f"- Files changed: {report.input_summary.file_count}", + f"- Changed lines: {report.input_summary.changed_line_count}", + "", + "| Severity | Count |", + "| --- | ---: |", + ] + if report.monitoring.severity_distribution: + for severity, count in report.monitoring.severity_distribution.items(): + lines.append(f"| {severity} | {count} |") + else: + lines.append("| none | 0 |") + + lines.extend(["", "## Findings", ""]) + if report.findings: + for finding in report.findings: + lines.extend(_render_finding(finding)) + else: + lines.append("No high-confidence findings.") + + lines.extend(["", "## Human Review", ""]) + if report.warnings: + for finding in report.warnings: + lines.extend(_render_finding(finding)) + if report.needs_human_review: + for reason in report.needs_human_review: + lines.append(f"- {reason}") + if not report.warnings and not report.needs_human_review: + lines.append("No items require human review.") + + lines.extend([ + "", + "## Filter Governance", + "", + "| Decision | Rule | Script | Reason |", + "| --- | --- | --- | --- |", + ]) + if report.filter_decisions: + for decision in report.filter_decisions: + lines.append( + f"| {decision.decision} | `{decision.rule_id}` | `{decision.script}` | " + f"{_table_text(decision.reason)} |" + ) + else: + lines.append("| none | - | - | No execution was requested. |") + + lines.extend([ + "", + "## Sandbox Execution", + "", + "| Runtime | Status | Duration (ms) | Exit | Timed out | Output truncated |", + "| --- | --- | ---: | ---: | --- | --- |", + ]) + if report.sandbox_runs: + for run in report.sandbox_runs: + exit_code = "-" if run.exit_code is None else str(run.exit_code) + lines.append( + f"| {run.runtime} | {run.status} | {run.duration_ms} | {exit_code} | " + f"{run.timed_out} | {run.output_truncated} |" + ) + else: + lines.append("| not started | blocked | 0 | - | false | false |") + + metrics = report.monitoring + lines.extend([ + "", + "## Monitoring", + "", + f"- Total duration: {metrics.total_duration_ms} ms", + f"- Sandbox duration: {metrics.sandbox_duration_ms} ms", + f"- Tool calls: {metrics.tool_calls}", + f"- Filter interceptions: {metrics.interception_count}", + f"- Findings: {metrics.finding_count}", + f"- Warnings: {metrics.warning_count}", + f"- Redactions: {metrics.redaction_count}", + f"- Exception distribution: `{json.dumps(metrics.exception_distribution, sort_keys=True)}`", + "", + "## Operational Warnings", + "", + ]) + if report.operational_warnings: + lines.extend(f"- {warning}" for warning in report.operational_warnings) + else: + lines.append("None.") + lines.append("") + return "\n".join(lines) + + +def _render_finding(finding: Finding) -> list[str]: + evidence = finding.evidence.replace("`", "'") + return [ + f"### [{finding.severity.upper()}] {finding.title}", + "", + f"- Category: `{finding.category}`", + f"- Location: `{finding.file}:{finding.line}`", + f"- Confidence: `{finding.confidence:.2f}`", + f"- Source: `{finding.source}`", + f"- Evidence: `{evidence}`", + f"- Recommendation: {finding.recommendation}", + "", + ] + + +def _table_text(value: str) -> str: + return value.replace("|", "\\|").replace("\n", " ") 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..ff3aa45d4 --- /dev/null +++ b/examples/skills_code_review_agent/agent/sandbox.py @@ -0,0 +1,361 @@ +# +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# +"""Skill loading and bounded execution through tRPC-Agent workspace runtimes.""" + +from __future__ import annotations + +import atexit +import json +import time +from pathlib import Path +from typing import Any +from typing import AsyncGenerator +from typing import Optional + +from pydantic import BaseModel +from pydantic import Field +from trpc_agent_sdk.agents import BaseAgent +from trpc_agent_sdk.code_executors import BaseWorkspaceRuntime +from trpc_agent_sdk.code_executors import DEFAULT_INPUTS_CONTAINER +from trpc_agent_sdk.code_executors import WorkspaceInputSpec +from trpc_agent_sdk.code_executors import create_container_workspace_runtime +from trpc_agent_sdk.code_executors import create_local_workspace_runtime +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.context import create_agent_context +from trpc_agent_sdk.events import Event +from trpc_agent_sdk.sessions import InMemorySessionService +from trpc_agent_sdk.skills import SkillLoadTool +from trpc_agent_sdk.skills import SkillRunTool +from trpc_agent_sdk.skills import create_default_skill_repository +from trpc_agent_sdk.telemetry import tracer + +from .core import FilterDecision +from .core import SandboxRun +from .core import SecretRedactor +from .governance import ReviewExecutionFilter + + +class _ReviewToolAgent(BaseAgent): + """Minimal agent identity for direct deterministic Skill tool calls.""" + + async def _run_async_impl(self, ctx: InvocationContext) -> AsyncGenerator[Event, None]: + if False: + yield Event(invocation_id=ctx.invocation_id, author=self.name) + + +class SandboxExecution(BaseModel): + """Result of policy evaluation and an optional sandbox run.""" + + decision: FilterDecision + run: Optional[SandboxRun] = None + raw_findings: list[dict[str, Any]] = Field(default_factory=list) + redaction_count: int = 0 + tool_calls: int = 0 + + +class SandboxExecutor: + """Execute the approved scanner through ``skill_load`` and ``skill_run``.""" + + def __init__( + self, + *, + runtime: str, + skill_root: Path, + work_root: Path, + allowed_scripts: set[str], + env_allowlist: set[str], + network_allowlist: set[str], + max_timeout_seconds: int, + max_output_bytes: int, + max_policy_output_bytes: int, + ) -> None: + if runtime not in {"container", "local"}: + raise ValueError("runtime must be 'container' or explicit development fallback 'local'") + self.runtime = runtime + self.skill_root = skill_root.expanduser().resolve() + self.work_root = work_root.expanduser().resolve() + self.allowed_scripts = allowed_scripts + self.env_allowlist = env_allowlist + self.network_allowlist = network_allowlist + self.max_timeout_seconds = max_timeout_seconds + self.max_output_bytes = max_output_bytes + self.max_policy_output_bytes = max_policy_output_bytes + + async def execute( + self, + *, + diff_path: Path, + task_id: str, + checker_script: str, + timeout_seconds: int, + environment: Optional[dict[str, str]] = None, + network_hosts: Optional[list[str]] = None, + ) -> SandboxExecution: + """Authorize first; create a runtime only after an allow decision.""" + + command = self._command(checker_script) + tool_args = { + "skill": "code-review", + "command": command, + "cwd": "", + "env": environment or {"PYTHONUNBUFFERED": "1"}, + "timeout": timeout_seconds, + "inputs": [ + WorkspaceInputSpec( + src=f"host://{diff_path.expanduser().resolve()}", + dst="work/inputs/review.diff", + mode="copy", + ).model_dump() + ], + "outputs": { + "globs": ["out/review_findings.json"], + "max_files": 1, + "max_file_bytes": self.max_output_bytes, + "max_total_bytes": self.max_output_bytes, + "inline": True, + "save": False, + }, + } + policy_request = { + **tool_args, + "network_hosts": network_hosts or [], + } + decisions: list[FilterDecision] = [] + policy_filter = ReviewExecutionFilter( + allowed_scripts=self.allowed_scripts, + env_allowlist=self.env_allowlist, + network_allowlist=self.network_allowlist, + max_timeout_seconds=self.max_timeout_seconds, + max_output_bytes=self.max_policy_output_bytes, + decision_sink=decisions.append, + ) + + async def approved() -> str: + return "approved" + + await policy_filter.run(create_agent_context(), policy_request, approved) + decision = decisions[-1] + if decision.decision != "allow": + return SandboxExecution(decision=decision) + + runtime: Optional[BaseWorkspaceRuntime] = None + context: Optional[InvocationContext] = None + started = time.monotonic() + skill_loaded = False + try: + runtime = self._create_runtime(diff_path.parent) + context = await self._create_context(task_id) + repository = create_default_skill_repository( + str(self.skill_root), + workspace_runtime=runtime, + enable_hot_reload=False, + use_cached_repository=False, + ) + load_tool = SkillLoadTool(repository=repository) + run_tool = SkillRunTool( + repository=repository, + require_skill_loaded=True, + allowed_cmds=["python3"], + ) + + with tracer.start_as_current_span("code_review.skill_load") as span: + span.set_attribute("code_review.task_id", task_id) + await load_tool.run_async( + tool_context=context, + args={ + "skill_name": "code-review", + "include_all_docs": True, + }, + ) + skill_loaded = True + + with tracer.start_as_current_span("code_review.skill_run") as span: + span.set_attribute("code_review.task_id", task_id) + span.set_attribute("code_review.runtime", self.runtime) + output = await run_tool.run_async(tool_context=context, args=tool_args) + + duration_ms = int((time.monotonic() - started) * 1000) + run = self._sandbox_run(output, command, duration_ms, skill_loaded) + raw_findings, output_redactions = self._read_findings(output) + return SandboxExecution( + decision=decision, + run=run, + raw_findings=raw_findings, + redaction_count=output_redactions, + tool_calls=2, + ) + except Exception as error: # pylint: disable=broad-except + duration_ms = int((time.monotonic() - started) * 1000) + clean_error, redactions = SecretRedactor.redact_text(str(error)) + run = SandboxRun( + runtime=self.runtime, + status="failed", + command=command, + duration_ms=duration_ms, + exit_code=None, + stderr=clean_error[:self.max_output_bytes], + output_truncated=len(clean_error.encode("utf-8")) > self.max_output_bytes, + error_type=type(error).__name__, + skill_loaded=skill_loaded, + ) + return SandboxExecution( + decision=decision, + run=run, + redaction_count=redactions, + tool_calls=2 if skill_loaded else 1, + ) + finally: + if runtime is not None and context is not None: + await self._cleanup_workspace(runtime, context) + if runtime is not None and self.runtime == "container": + try: + cleanup = getattr(runtime.container, "_cleanup_container", None) + if cleanup: + atexit.unregister(cleanup) + cleanup() + except Exception: # pylint: disable=broad-except + pass + + async def _cleanup_workspace( + self, + runtime: BaseWorkspaceRuntime, + context: InvocationContext, + ) -> None: + manager = runtime.manager(context) + try: + await manager.cleanup(context.session.id, context) + return + except PermissionError: + if self.runtime != "local": + return + except Exception: # pylint: disable=broad-except + return + + workspace = getattr(manager, "ws_paths", {}).get(context.session.id) + if workspace is None: + return + workspace_path = Path(workspace.path).resolve() + if not workspace_path.is_relative_to(self.work_root): + return + for path in [workspace_path, *workspace_path.rglob("*")]: + if path.is_symlink(): + continue + try: + path.chmod(path.stat().st_mode | (0o700 if path.is_dir() else 0o600)) + except FileNotFoundError: + continue + try: + await manager.cleanup(context.session.id, context) + except Exception: # pylint: disable=broad-except + pass + + def _create_runtime(self, inputs_host: Path) -> BaseWorkspaceRuntime: + if self.runtime == "container": + return create_container_workspace_runtime( + host_config={ + "network_mode": "none", + "auto_remove": True, + "Binds": [ + f"{inputs_host.expanduser().resolve()}:{DEFAULT_INPUTS_CONTAINER}:ro", + ], + }, + auto_inputs=True, + enable_provider_env=False, + ) + self.work_root.mkdir(parents=True, exist_ok=True) + return create_local_workspace_runtime( + work_root=str(self.work_root), + read_only_staged_skill=False, + auto_inputs=True, + enable_provider_env=False, + ) + + @staticmethod + async def _create_context(task_id: str) -> InvocationContext: + service = InMemorySessionService() + session = await service.create_session( + app_name="skills_code_review_agent", + user_id="review_user", + session_id=task_id, + ) + agent = _ReviewToolAgent(name="code_review_agent") + return InvocationContext( + session_service=service, + invocation_id=task_id, + agent=agent, + agent_context=create_agent_context(), + session=session, + ) + + @staticmethod + def _command(checker_script: str) -> str: + return f"python3 {checker_script}" + + def _sandbox_run( + self, + output: Any, + command: str, + duration_ms: int, + skill_loaded: bool, + ) -> SandboxRun: + if not isinstance(output, dict): + output = {} + stdout, _ = SecretRedactor.redact_text(str(output.get("stdout") or "")) + stderr, _ = SecretRedactor.redact_text(str(output.get("stderr") or "")) + stdout, stdout_truncated = self._truncate(stdout) + stderr, stderr_truncated = self._truncate(stderr) + file_truncated = any(bool(item.get("truncated")) for item in output.get("output_files") or []) + timed_out = bool(output.get("timed_out")) + exit_code = output.get("exit_code") + if timed_out: + status = "timed_out" + error_type = "TimeoutError" + elif exit_code == 0: + status = "completed" + error_type = "" + else: + status = "failed" + error_type = "SandboxCommandError" + return SandboxRun( + runtime=self.runtime, + status=status, + command=command, + duration_ms=int(output.get("duration_ms") or duration_ms), + exit_code=exit_code, + timed_out=timed_out, + stdout=stdout, + stderr=stderr, + output_truncated=stdout_truncated or stderr_truncated or file_truncated, + error_type=error_type, + skill_loaded=skill_loaded, + ) + + def _read_findings(self, output: Any) -> tuple[list[dict[str, Any]], int]: + if not isinstance(output, dict): + return [], 0 + files = output.get("output_files") or [] + for item in files: + if item.get("name") != "out/review_findings.json": + continue + content = str(item.get("content") or "") + clean_content, host_redactions = SecretRedactor.redact_text(content) + try: + payload = json.loads(clean_content) + except json.JSONDecodeError: + return [], host_redactions + findings = payload.get("findings") if isinstance(payload, dict) else [] + scanner_redactions = int(payload.get("redaction_count") or 0) + if isinstance(findings, list): + return findings, host_redactions + scanner_redactions + return [], 0 + + def _truncate(self, value: str) -> tuple[str, bool]: + encoded = value.encode("utf-8") + if len(encoded) <= self.max_output_bytes: + return value, False + return encoded[:self.max_output_bytes].decode("utf-8", errors="ignore"), True 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..f7b3ea3b7 --- /dev/null +++ b/examples/skills_code_review_agent/agent/storage.py @@ -0,0 +1,365 @@ +# +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# +"""Portable SQL persistence for review tasks and audit records.""" + +from __future__ import annotations + +import inspect +import json +from abc import ABC +from abc import abstractmethod +from pathlib import Path +from typing import Any +from typing import Optional + +from sqlalchemy import Float +from sqlalchemy import ForeignKey +from sqlalchemy import Integer +from sqlalchemy import String +from sqlalchemy import Text +from sqlalchemy import UniqueConstraint +from sqlalchemy import select +from sqlalchemy.orm import DeclarativeBase +from sqlalchemy.orm import Mapped +from sqlalchemy.orm import mapped_column +from trpc_agent_sdk.storage import SqlStorage + +from .core import FilterDecision +from .core import Finding +from .core import InputSummary +from .core import MonitoringSummary +from .core import ReviewReport +from .core import SandboxRun +from .core import SecretRedactor + + +class ReviewStorageBase(DeclarativeBase): + """Separate metadata keeps this example isolated from SDK service tables.""" + + +class ReviewTaskRecord(ReviewStorageBase): + """Top-level review task.""" + + __tablename__ = "cr_review_tasks" + + id: Mapped[str] = mapped_column(String(64), primary_key=True) + status: Mapped[str] = mapped_column(String(32), nullable=False) + model_mode: Mapped[str] = mapped_column(String(32), nullable=False) + input_type: Mapped[str] = mapped_column(String(32), nullable=False) + input_sha256: Mapped[str] = mapped_column(String(64), nullable=False) + input_summary_json: Mapped[str] = mapped_column(Text, nullable=False) + created_at: Mapped[str] = mapped_column(String(40), nullable=False) + updated_at: Mapped[str] = mapped_column(String(40), nullable=False) + duration_ms: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + + +class SandboxRunRecord(ReviewStorageBase): + """One sandbox execution attempt.""" + + __tablename__ = "cr_sandbox_runs" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + task_id: Mapped[str] = mapped_column(ForeignKey("cr_review_tasks.id", ondelete="CASCADE"), index=True) + runtime: Mapped[str] = mapped_column(String(32), nullable=False) + status: Mapped[str] = mapped_column(String(32), nullable=False) + command: Mapped[str] = mapped_column(Text, nullable=False) + duration_ms: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + exit_code: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) + timed_out: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + stdout: Mapped[str] = mapped_column(Text, default="", nullable=False) + stderr: Mapped[str] = mapped_column(Text, default="", nullable=False) + output_truncated: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + error_type: Mapped[str] = mapped_column(String(128), default="", nullable=False) + skill_loaded: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + + +class FilterDecisionRecord(ReviewStorageBase): + """Filter governance audit record.""" + + __tablename__ = "cr_filter_decisions" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + task_id: Mapped[str] = mapped_column(ForeignKey("cr_review_tasks.id", ondelete="CASCADE"), index=True) + decision: Mapped[str] = mapped_column(String(32), nullable=False) + rule_id: Mapped[str] = mapped_column(String(64), nullable=False) + reason: Mapped[str] = mapped_column(Text, nullable=False) + script: Mapped[str] = mapped_column(Text, nullable=False) + network_hosts_json: Mapped[str] = mapped_column(Text, default="[]", nullable=False) + + +class FindingRecord(ReviewStorageBase): + """Deduplicated finding or low-confidence warning.""" + + __tablename__ = "cr_findings" + __table_args__ = ( + UniqueConstraint("task_id", "file", "line", "category", name="uq_cr_finding_location"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + task_id: Mapped[str] = mapped_column(ForeignKey("cr_review_tasks.id", ondelete="CASCADE"), index=True) + bucket: Mapped[str] = mapped_column(String(32), nullable=False) + severity: Mapped[str] = mapped_column(String(16), nullable=False) + category: Mapped[str] = mapped_column(String(64), nullable=False) + file: Mapped[str] = mapped_column(String(512), nullable=False) + line: Mapped[int] = mapped_column(Integer, nullable=False) + title: Mapped[str] = mapped_column(Text, nullable=False) + evidence: Mapped[str] = mapped_column(Text, nullable=False) + recommendation: Mapped[str] = mapped_column(Text, nullable=False) + confidence: Mapped[float] = mapped_column(Float, nullable=False) + source: Mapped[str] = mapped_column(String(128), nullable=False) + + +class MetricsRecord(ReviewStorageBase): + """Monitoring summary for a review.""" + + __tablename__ = "cr_monitoring_summaries" + + task_id: Mapped[str] = mapped_column(ForeignKey("cr_review_tasks.id", ondelete="CASCADE"), primary_key=True) + metrics_json: Mapped[str] = mapped_column(Text, nullable=False) + finding_count: Mapped[int] = mapped_column(Integer, nullable=False) + interception_count: Mapped[int] = mapped_column(Integer, nullable=False) + total_duration_ms: Mapped[int] = mapped_column(Integer, nullable=False) + sandbox_duration_ms: Mapped[int] = mapped_column(Integer, nullable=False) + + +class ReportRecord(ReviewStorageBase): + """Final report snapshot for replay.""" + + __tablename__ = "cr_reports" + + task_id: Mapped[str] = mapped_column(ForeignKey("cr_review_tasks.id", ondelete="CASCADE"), primary_key=True) + conclusion: Mapped[str] = mapped_column(Text, nullable=False) + report_json: Mapped[str] = mapped_column(Text, nullable=False) + created_at: Mapped[str] = mapped_column(String(40), nullable=False) + + +async def _maybe_await(value: Any) -> Any: + if inspect.isawaitable(value): + return await value + return value + + +def _clean(value: Any) -> Any: + clean, _ = SecretRedactor.redact_value(value) + return clean + + +class ReviewStore(ABC): + """Backend-neutral review persistence interface.""" + + @abstractmethod + async def initialize(self) -> None: + """Create or migrate storage.""" + + @abstractmethod + async def get_review(self, task_id: str) -> dict[str, Any]: + """Return the complete persisted review.""" + + @abstractmethod + async def close(self) -> None: + """Release backend resources.""" + + +class SqlReviewStore(ReviewStore): + """Review persistence backed by the SDK's portable ``SqlStorage``.""" + + def __init__(self, db_url: str) -> None: + self._db_url = db_url + self._storage = SqlStorage( + is_async=False, + db_url=db_url, + metadata=ReviewStorageBase.metadata, + expire_on_commit=False, + ) + + async def initialize(self) -> None: + if self._db_url.startswith("sqlite:///") and self._db_url != "sqlite:///:memory:": + Path(self._db_url.removeprefix("sqlite:///")).expanduser().resolve().parent.mkdir( + parents=True, + exist_ok=True, + ) + await self._storage.create_sql_engine() + + async def create_task( + self, + task_id: str, + summary: InputSummary, + model_mode: str, + created_at: str, + ) -> None: + clean_summary = _clean(summary.model_dump()) + async with self._storage.create_db_session() as db: + db.add( + ReviewTaskRecord( + id=task_id, + status="running", + model_mode=model_mode, + input_type=summary.input_type, + input_sha256=summary.sha256, + input_summary_json=json.dumps(clean_summary, ensure_ascii=True, sort_keys=True), + created_at=created_at, + updated_at=created_at, + duration_ms=0, + )) + await _maybe_await(db.commit()) + + async def add_filter_decision(self, task_id: str, decision: FilterDecision) -> None: + clean = _clean(decision.model_dump()) + async with self._storage.create_db_session() as db: + db.add( + FilterDecisionRecord( + task_id=task_id, + decision=clean["decision"], + rule_id=clean["rule_id"], + reason=clean["reason"], + script=clean["script"], + network_hosts_json=json.dumps(clean["network_hosts"], ensure_ascii=True), + )) + await _maybe_await(db.commit()) + + async def add_sandbox_run(self, task_id: str, run: SandboxRun) -> None: + clean = _clean(run.model_dump()) + async with self._storage.create_db_session() as db: + db.add( + SandboxRunRecord( + task_id=task_id, + runtime=clean["runtime"], + status=clean["status"], + command=clean["command"], + duration_ms=clean["duration_ms"], + exit_code=clean["exit_code"], + timed_out=int(clean["timed_out"]), + stdout=clean["stdout"], + stderr=clean["stderr"], + output_truncated=int(clean["output_truncated"]), + error_type=clean["error_type"], + skill_loaded=int(clean["skill_loaded"]), + )) + await _maybe_await(db.commit()) + + async def add_findings( + self, + task_id: str, + findings: list[Finding], + warnings: list[Finding], + ) -> None: + async with self._storage.create_db_session() as db: + for bucket, items in (("finding", findings), ("needs_human_review", warnings)): + for item in items: + clean = _clean(item.model_dump()) + db.add(FindingRecord(task_id=task_id, bucket=bucket, **clean)) + await _maybe_await(db.commit()) + + async def save_metrics(self, task_id: str, metrics: MonitoringSummary) -> None: + clean = _clean(metrics.model_dump()) + async with self._storage.create_db_session() as db: + db.add( + MetricsRecord( + task_id=task_id, + metrics_json=json.dumps(clean, ensure_ascii=True, sort_keys=True), + finding_count=metrics.finding_count, + interception_count=metrics.interception_count, + total_duration_ms=metrics.total_duration_ms, + sandbox_duration_ms=metrics.sandbox_duration_ms, + )) + await _maybe_await(db.commit()) + + async def save_report(self, report: ReviewReport) -> None: + clean = _clean(report.model_dump()) + report_json = json.dumps(clean, ensure_ascii=True, sort_keys=True) + async with self._storage.create_db_session() as db: + db.add( + ReportRecord( + task_id=report.task_id, + conclusion=clean["conclusion"], + report_json=report_json, + created_at=report.generated_at, + )) + await _maybe_await(db.commit()) + + async def complete_task(self, task_id: str, status: str, duration_ms: int, updated_at: str) -> None: + async with self._storage.create_db_session() as db: + task = await _maybe_await(db.get(ReviewTaskRecord, task_id)) + if task is None: + raise KeyError(f"review task not found: {task_id}") + task.status = status + task.duration_ms = duration_ms + task.updated_at = updated_at + await _maybe_await(db.commit()) + + async def get_review(self, task_id: str) -> dict[str, Any]: + async with self._storage.create_db_session() as db: + task = await _maybe_await(db.get(ReviewTaskRecord, task_id)) + if task is None: + raise KeyError(f"review task not found: {task_id}") + sandbox_runs = (await _maybe_await( + db.execute(select(SandboxRunRecord).where(SandboxRunRecord.task_id == task_id) + .order_by(SandboxRunRecord.id)))).scalars().all() + decisions = (await _maybe_await( + db.execute(select(FilterDecisionRecord).where(FilterDecisionRecord.task_id == task_id) + .order_by(FilterDecisionRecord.id)))).scalars().all() + findings = (await _maybe_await( + db.execute(select(FindingRecord).where(FindingRecord.task_id == task_id) + .order_by(FindingRecord.id)))).scalars().all() + metrics = await _maybe_await(db.get(MetricsRecord, task_id)) + report = await _maybe_await(db.get(ReportRecord, task_id)) + + return { + "task": { + "id": task.id, + "status": task.status, + "model_mode": task.model_mode, + "input_type": task.input_type, + "input_sha256": task.input_sha256, + "input_summary": json.loads(task.input_summary_json), + "created_at": task.created_at, + "updated_at": task.updated_at, + "duration_ms": task.duration_ms, + }, + "sandbox_runs": [{ + "runtime": item.runtime, + "status": item.status, + "command": item.command, + "duration_ms": item.duration_ms, + "exit_code": item.exit_code, + "timed_out": bool(item.timed_out), + "stdout": item.stdout, + "stderr": item.stderr, + "output_truncated": bool(item.output_truncated), + "error_type": item.error_type, + "skill_loaded": bool(item.skill_loaded), + } for item in sandbox_runs], + "filter_decisions": [{ + "decision": item.decision, + "rule_id": item.rule_id, + "reason": item.reason, + "script": item.script, + "network_hosts": json.loads(item.network_hosts_json), + } for item in decisions], + "findings": [{ + "bucket": item.bucket, + "severity": item.severity, + "category": item.category, + "file": item.file, + "line": item.line, + "title": item.title, + "evidence": item.evidence, + "recommendation": item.recommendation, + "confidence": item.confidence, + "source": item.source, + } for item in findings], + "metrics": json.loads(metrics.metrics_json) if metrics else None, + "report": { + "conclusion": report.conclusion, + "report_json": report.report_json, + "created_at": report.created_at, + } if report else None, + } + + async def close(self) -> None: + await self._storage.close() diff --git a/examples/skills_code_review_agent/agent/workflow.py b/examples/skills_code_review_agent/agent/workflow.py new file mode 100644 index 000000000..195c1c243 --- /dev/null +++ b/examples/skills_code_review_agent/agent/workflow.py @@ -0,0 +1,266 @@ +# +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# +"""End-to-end deterministic code review workflow.""" + +from __future__ import annotations + +import time +import uuid +from datetime import datetime +from datetime import timezone +from pathlib import Path +from typing import Optional + +from pydantic import BaseModel +from pydantic import Field +from pydantic import model_validator +from trpc_agent_sdk.telemetry import tracer + +from .core import InputResolver +from .core import MonitoringSummary +from .core import ResolvedInput +from .core import ReviewReport +from .core import normalize_findings +from .core import severity_distribution +from .reporting import sanitize_report +from .reporting import write_reports +from .sandbox import SandboxExecutor +from .storage import SqlReviewStore + +EXAMPLE_ROOT = Path(__file__).resolve().parents[1] + + +def _utc_now() -> str: + return datetime.now(timezone.utc).isoformat() + + +class ReviewConfig(BaseModel): + """Configuration with production-safe defaults.""" + + runtime: str = "container" + db_url: str = f"sqlite:///{EXAMPLE_ROOT / 'data' / 'reviews.db'}" + output_dir: Path = EXAMPLE_ROOT / "output" + skill_root: Path = EXAMPLE_ROOT / "skills" + work_root: Path = EXAMPLE_ROOT / "data" / "workspaces" + checker_script: str = "scripts/review_diff.py" + allowed_scripts: set[str] = Field( + default_factory=lambda: { + "scripts/review_diff.py", + "scripts/timeout_probe.py", + }) + env_allowlist: set[str] = Field(default_factory=lambda: {"PYTHONUNBUFFERED"}) + network_allowlist: set[str] = Field(default_factory=set) + sandbox_environment: dict[str, str] = Field(default_factory=lambda: {"PYTHONUNBUFFERED": "1"}) + network_hosts: list[str] = Field(default_factory=list) + timeout_seconds: int = Field(default=30, ge=1) + max_timeout_seconds: int = Field(default=120, ge=1) + max_output_bytes: int = Field(default=64 * 1024, ge=1024, le=1024 * 1024) + max_policy_output_bytes: int = Field(default=64 * 1024, ge=1024, le=1024 * 1024) + dry_run: bool = False + fake_model: bool = False + + @model_validator(mode="after") + def validate_policy(self) -> "ReviewConfig": + if self.runtime not in {"container", "local"}: + raise ValueError("runtime must be container or local") + return self + + +class CodeReviewAgent: + """Orchestrate input parsing, Filter, Skill sandbox, storage, and reports.""" + + def __init__(self, config: ReviewConfig) -> None: + self.config = config + self.input_resolver = InputResolver() + + async def review( + self, + *, + diff_file: Optional[Path] = None, + repo_path: Optional[Path] = None, + files: Optional[list[Path]] = None, + fixture: Optional[Path] = None, + ) -> ReviewReport: + """Run one complete review without requiring a model API key.""" + + resolved = self._resolve_input( + diff_file=diff_file, + repo_path=repo_path, + files=files, + fixture=fixture, + ) + task_id = f"cr_{uuid.uuid4().hex}" + created_at = _utc_now() + started = time.monotonic() + model_mode = "fake" if self.config.fake_model or self.config.dry_run else "deterministic" + store = SqlReviewStore(self.config.db_url) + staged_diff = self._stage_input(task_id, resolved) + + with tracer.start_as_current_span("code_review.review") as span: + span.set_attribute("code_review.task_id", task_id) + span.set_attribute("code_review.runtime", self.config.runtime) + span.set_attribute("code_review.input_sha256", resolved.summary.sha256) + try: + await store.initialize() + await store.create_task(task_id, resolved.summary, model_mode, created_at) + + executor = SandboxExecutor( + runtime=self.config.runtime, + skill_root=self.config.skill_root, + work_root=self.config.work_root, + allowed_scripts=self.config.allowed_scripts, + env_allowlist=self.config.env_allowlist, + network_allowlist=self.config.network_allowlist, + max_timeout_seconds=self.config.max_timeout_seconds, + max_output_bytes=self.config.max_output_bytes, + max_policy_output_bytes=self.config.max_policy_output_bytes, + ) + execution = await executor.execute( + diff_path=staged_diff, + task_id=task_id, + checker_script=self.config.checker_script, + timeout_seconds=self.config.timeout_seconds, + environment=self.config.sandbox_environment, + network_hosts=self.config.network_hosts, + ) + await store.add_filter_decision(task_id, execution.decision) + + filter_decisions = [execution.decision] + sandbox_runs = [] + findings = [] + warnings = [] + operational_warnings = [] + human_review = [] + status = "completed" + exception_distribution: dict[str, int] = {} + normalize_redactions = 0 + + if execution.decision.decision != "allow": + status = "blocked" + human_review.append( + f"Filter {execution.decision.rule_id}: {execution.decision.reason}" + ) + elif execution.run is not None: + sandbox_runs.append(execution.run) + await store.add_sandbox_run(task_id, execution.run) + findings, warnings, normalize_redactions = normalize_findings(execution.raw_findings) + if execution.run.status != "completed": + status = "completed_with_warnings" + warning = ( + f"Sandbox {execution.run.status}; findings may be incomplete. " + f"Error type: {execution.run.error_type or 'unknown'}." + ) + operational_warnings.append(warning) + human_review.append(warning) + if execution.run.output_truncated: + status = "completed_with_warnings" + warning = "Sandbox output reached the configured size limit; findings may be incomplete." + operational_warnings.append(warning) + human_review.append(warning) + if execution.run.error_type: + exception_distribution[execution.run.error_type] = 1 + else: + status = "completed_with_warnings" + warning = "Filter allowed execution but no sandbox result was produced." + operational_warnings.append(warning) + human_review.append(warning) + + if warnings: + human_review.extend( + f"{item.file}:{item.line} {item.title} ({item.confidence:.2f})" + for item in warnings) + if self.config.runtime == "local": + operational_warnings.append( + "Local runtime is an explicit development fallback; use container in production." + ) + + elapsed_ms = int((time.monotonic() - started) * 1000) + metrics = MonitoringSummary( + total_duration_ms=elapsed_ms, + sandbox_duration_ms=sum(item.duration_ms for item in sandbox_runs), + tool_calls=execution.tool_calls, + interception_count=int(execution.decision.decision != "allow"), + finding_count=len(findings), + warning_count=len(warnings), + severity_distribution=severity_distribution(findings + warnings), + exception_distribution=exception_distribution, + redaction_count=execution.redaction_count + normalize_redactions, + ) + conclusion = self._conclusion(status, findings, warnings) + report = ReviewReport( + task_id=task_id, + status=status, + model_mode=model_mode, + input_summary=resolved.summary, + findings=findings, + warnings=warnings, + needs_human_review=human_review, + filter_decisions=filter_decisions, + sandbox_runs=sandbox_runs, + monitoring=metrics, + operational_warnings=operational_warnings, + conclusion=conclusion, + generated_at=_utc_now(), + ) + report = sanitize_report(report) + write_reports(report, self.config.output_dir) + + await store.add_findings(task_id, report.findings, report.warnings) + await store.save_metrics(task_id, report.monitoring) + await store.save_report(report) + await store.complete_task( + task_id, + report.status, + report.monitoring.total_duration_ms, + report.generated_at, + ) + span.set_attribute("code_review.status", report.status) + span.set_attribute("code_review.finding_count", len(report.findings)) + span.set_attribute("code_review.interception_count", metrics.interception_count) + return report + finally: + await store.close() + staged_diff.unlink(missing_ok=True) + staged_diff.parent.rmdir() + + def _resolve_input( + self, + *, + diff_file: Optional[Path], + repo_path: Optional[Path], + files: Optional[list[Path]], + fixture: Optional[Path], + ) -> ResolvedInput: + provided = sum(value is not None and value != [] for value in (diff_file, repo_path, files, fixture)) + if provided != 1: + raise ValueError("provide exactly one of diff_file, repo_path, files, or fixture") + if diff_file is not None: + return self.input_resolver.resolve_diff_file(diff_file) + if fixture is not None: + return self.input_resolver.resolve_diff_file(fixture, input_type="fixture") + if repo_path is not None: + return self.input_resolver.resolve_repo(repo_path) + return self.input_resolver.resolve_files(files or []) + + def _stage_input(self, task_id: str, resolved: ResolvedInput) -> Path: + input_dir = self.config.work_root.expanduser().resolve() / "inputs" / task_id + input_dir.mkdir(parents=True, exist_ok=True) + path = input_dir / "review.diff" + path.write_text(resolved.diff_text, encoding="utf-8") + path.chmod(0o600) + return path + + @staticmethod + def _conclusion(status: str, findings, warnings) -> str: + if status == "blocked": + return "blocked_by_filter" + if any(item.severity in {"critical", "high"} for item in findings): + return "changes_requested" + if findings or warnings or status == "completed_with_warnings": + return "needs_human_review" + return "approved" 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..9235ff6b6 --- /dev/null +++ b/examples/skills_code_review_agent/run_agent.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 +# +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# +"""CLI entry point for the Skills-based code review agent.""" + +from __future__ import annotations + +import argparse +import asyncio +from pathlib import Path + +from agent.workflow import CodeReviewAgent +from agent.workflow import ReviewConfig + +EXAMPLE_ROOT = Path(__file__).resolve().parent +FIXTURE_ROOT = EXAMPLE_ROOT / "tests" / "fixtures" + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Review a diff through Skill + Filter + sandbox + SQL storage.", + ) + inputs = parser.add_mutually_exclusive_group(required=True) + inputs.add_argument("--diff-file", type=Path, help="Unified diff or PR patch file.") + inputs.add_argument("--repo-path", type=Path, help="Git working tree to review.") + inputs.add_argument("--files", type=Path, nargs="+", help="File paths treated as new-file changes.") + inputs.add_argument("--fixture", help="Fixture name under tests/fixtures.") + inputs.add_argument("--run-all-fixtures", action="store_true", help="Run every public diff fixture.") + + parser.add_argument( + "--runtime", + choices=("container", "local"), + default="container", + help="Container is the production default; local is a development fallback.", + ) + parser.add_argument( + "--db-url", + default=f"sqlite:///{EXAMPLE_ROOT / 'data' / 'reviews.db'}", + help="SQLAlchemy URL. SQLite is the default; other SQL backends remain supported.", + ) + parser.add_argument("--output-dir", type=Path, default=EXAMPLE_ROOT / "output") + parser.add_argument("--timeout", type=int, default=30) + parser.add_argument("--max-output-bytes", type=int, default=64 * 1024) + parser.add_argument("--checker-script", default="scripts/review_diff.py") + parser.add_argument( + "--network-host", + action="append", + default=[], + help="Requested network host. The default policy denies all hosts.", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Run the full deterministic pipeline without any model API call.", + ) + parser.add_argument( + "--fake-model", + action="store_true", + help="Record fake-model mode; parsing, sandbox, storage, and reports still execute.", + ) + return parser + + +def make_config(args: argparse.Namespace, output_dir: Path | None = None) -> ReviewConfig: + return ReviewConfig( + runtime=args.runtime, + db_url=args.db_url, + output_dir=output_dir or args.output_dir, + skill_root=EXAMPLE_ROOT / "skills", + work_root=EXAMPLE_ROOT / "data" / "workspaces", + checker_script=args.checker_script, + timeout_seconds=args.timeout, + max_output_bytes=args.max_output_bytes, + network_hosts=args.network_host, + dry_run=args.dry_run, + fake_model=args.fake_model, + ) + + +async def run_one(args: argparse.Namespace, fixture: str | None = None): + output_dir = args.output_dir / Path(fixture).stem if fixture else args.output_dir + agent = CodeReviewAgent(make_config(args, output_dir)) + if fixture: + report = await agent.review(fixture=FIXTURE_ROOT / fixture) + elif args.diff_file: + report = await agent.review(diff_file=args.diff_file) + elif args.repo_path: + report = await agent.review(repo_path=args.repo_path) + elif args.files: + report = await agent.review(files=args.files) + else: + report = await agent.review(fixture=FIXTURE_ROOT / args.fixture) + print_summary(report, args.db_url, output_dir) + return report + + +async def main_async(args: argparse.Namespace) -> int: + if args.run_all_fixtures: + fixtures = sorted(path.name for path in FIXTURE_ROOT.glob("*.diff")) + reports = [await run_one(args, fixture) for fixture in fixtures] + print("=" * 72) + print(f"PUBLIC FIXTURE RESULT: {len(reports)}/{len(fixtures)} reports generated") + print(f"TOTAL FINDINGS: {sum(len(item.findings) for item in reports)}") + print(f"TOTAL FILTER INTERCEPTIONS: {sum(item.monitoring.interception_count for item in reports)}") + return 0 + await run_one(args) + return 0 + + +def print_summary(report, db_url: str, output_dir: Path) -> None: + print("=" * 72) + print("tRPC-Agent Skills Code Review") + print("=" * 72) + print(f"TASK ID {report.task_id}") + print(f"STATUS {report.status}") + print(f"CONCLUSION {report.conclusion}") + print(f"INPUT {report.input_summary.file_count} files, " + f"{report.input_summary.changed_line_count} changed lines") + decision = report.filter_decisions[0] + print(f"FILTER {decision.decision.upper()} ({decision.rule_id})") + if report.sandbox_runs: + run = report.sandbox_runs[0] + print(f"SANDBOX {run.runtime} / {run.status} / {run.duration_ms} ms") + print(f"SKILL FLOW skill_load={run.skill_loaded} skill_run=True") + else: + print("SANDBOX not started") + print("SKILL FLOW skill_run=False") + print(f"FINDINGS {len(report.findings)} high confidence") + print(f"HUMAN REVIEW {len(report.warnings)} low confidence") + print(f"SEVERITY {report.monitoring.severity_distribution}") + print(f"TOOL CALLS {report.monitoring.tool_calls}") + print(f"REDACTIONS {report.monitoring.redaction_count}") + print(f"TOTAL DURATION {report.monitoring.total_duration_ms} ms") + print(f"DATABASE {db_url}") + print(f"JSON REPORT {output_dir / 'review_report.json'}") + print(f"MARKDOWN REPORT {output_dir / 'review_report.md'}") + + +def main() -> int: + args = build_parser().parse_args() + if args.fixture and not (FIXTURE_ROOT / args.fixture).is_file(): + raise SystemExit(f"fixture not found: {args.fixture}") + return asyncio.run(main_async(args)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/skills_code_review_agent/sample_output/review_report.json b/examples/skills_code_review_agent/sample_output/review_report.json new file mode 100644 index 000000000..9b1843d51 --- /dev/null +++ b/examples/skills_code_review_agent/sample_output/review_report.json @@ -0,0 +1,72 @@ +{ + "conclusion": "changes_requested", + "filter_decisions": [ + { + "decision": "allow", + "network_hosts": [], + "reason": "Execution satisfies the sandbox policy.", + "rule_id": "policy_allow", + "script": "scripts/review_diff.py" + } + ], + "findings": [ + { + "category": "security", + "confidence": 0.98, + "evidence": "result = eval(expression, {\"__builtins__\": {}}, namespace)", + "file": "app/template.py", + "line": 3, + "recommendation": "Replace dynamic evaluation with a strict parser or an explicit operation allowlist.", + "severity": "high", + "source": "skill:SEC-001", + "title": "Dynamic code evaluation" + } + ], + "generated_at": "2026-07-25T16:05:49.280390+00:00", + "input_summary": { + "byte_count": 375, + "changed_line_count": 6, + "diff_preview": "diff --git a/app/template.py b/app/template.py\nindex 1111111..2222222 100644\n--- a/app/template.py\n+++ b/app/template.py\n@@ -1,3 +1,8 @@\n def render_template(expression, values):\n- return values.get(expression)\n+ namespace = dict(values)\n+ result = eval(expression, {\"__builtins__\": {}}, namespace)\n+ if result is None:\n+ return \"\"\n+ return str(result)\n", + "file_count": 1, + "files": [ + "app/template.py" + ], + "hunk_count": 1, + "input_type": "fixture", + "sha256": "369b6ae47d5b7a5d8fb4a4f540e9aae78250ff8913b63cd4a8eef8baf581fd75" + }, + "model_mode": "fake", + "monitoring": { + "exception_distribution": {}, + "finding_count": 1, + "interception_count": 0, + "redaction_count": 0, + "sandbox_duration_ms": 442, + "severity_distribution": { + "high": 1 + }, + "tool_calls": 2, + "total_duration_ms": 615, + "warning_count": 0 + }, + "needs_human_review": [], + "operational_warnings": [], + "sandbox_runs": [ + { + "command": "python3 scripts/review_diff.py", + "duration_ms": 442, + "error_type": "", + "exit_code": 0, + "output_truncated": false, + "runtime": "container", + "skill_loaded": true, + "status": "completed", + "stderr": "", + "stdout": "reviewed 1 files, found 1 candidates\n", + "timed_out": false + } + ], + "status": "completed", + "task_id": "cr_00000000000000000000000000000000", + "warnings": [] +} diff --git a/examples/skills_code_review_agent/scripts/init_db.py b/examples/skills_code_review_agent/scripts/init_db.py new file mode 100644 index 000000000..02c619688 --- /dev/null +++ b/examples/skills_code_review_agent/scripts/init_db.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +# +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# +"""Initialize the code review SQL schema.""" + +import argparse +import asyncio +from pathlib import Path + +from examples.skills_code_review_agent.agent.storage import SqlReviewStore + +EXAMPLE_ROOT = Path(__file__).resolve().parents[1] + + +async def initialize(db_url: str) -> None: + store = SqlReviewStore(db_url) + await store.initialize() + await store.close() + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--db-url", default=f"sqlite:///{EXAMPLE_ROOT / 'data' / 'reviews.db'}") + args = parser.parse_args() + asyncio.run(initialize(args.db_url)) + print(f"Initialized review database: {args.db_url}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/skills_code_review_agent/scripts/query_review.py b/examples/skills_code_review_agent/scripts/query_review.py new file mode 100644 index 000000000..513fd9691 --- /dev/null +++ b/examples/skills_code_review_agent/scripts/query_review.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +# +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# +"""Query a complete persisted review by task id.""" + +import argparse +import asyncio +import json +from pathlib import Path + +from examples.skills_code_review_agent.agent.core import SecretRedactor +from examples.skills_code_review_agent.agent.storage import SqlReviewStore + +EXAMPLE_ROOT = Path(__file__).resolve().parents[1] + + +async def query(db_url: str, task_id: str) -> dict: + store = SqlReviewStore(db_url) + await store.initialize() + try: + return await store.get_review(task_id) + finally: + await store.close() + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("task_id") + parser.add_argument("--db-url", default=f"sqlite:///{EXAMPLE_ROOT / 'data' / 'reviews.db'}") + args = parser.parse_args() + payload = asyncio.run(query(args.db_url, args.task_id)) + clean, _ = SecretRedactor.redact_value(payload) + print(json.dumps(clean, ensure_ascii=False, indent=2, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) 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..22b86d737 --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/SKILL.md @@ -0,0 +1,54 @@ +--- +name: code-review +description: Reviews unified diffs with deterministic security and reliability rules. Invoke for PR patches, git changes, or pre-merge risk checks. +--- + +# Code Review + +Use this Skill only for code-review inputs that have already passed the +execution policy Filter. The default scanner is deterministic and does not +require a model API key. + +## Workflow + +1. Load this Skill with `skill_load`. +2. Read `references/rules.md` when rule rationale is needed. +3. Stage the unified diff as `work/inputs/review.diff`. +4. Run the approved scanner with `skill_run`: + + ```text + python3 scripts/review_diff.py + ``` + +5. Collect `out/review_findings.json` with an explicit file and total byte + limit. +6. Validate, redact, deduplicate, persist, and render the findings on the host. + +## Security Constraints + +- Do not execute commands, tests, or code extracted from the reviewed diff. +- Do not enable network access for the default scanner. +- Pass no host environment variables except explicitly allowlisted, + non-secret values. +- Reject absolute paths, path traversal in script names, shell operators, and + scripts not listed by the execution policy. +- Treat `deny` and `needs_human_review` Filter decisions as terminal. They must + never reach `skill_run`. + +## Output Contract + +The scanner writes JSON with `findings`, `stats`, and `redaction_count`. +Every finding contains: + +- `severity` +- `category` +- `file` +- `line` +- `title` +- `evidence` +- `recommendation` +- `confidence` +- `source` + +Low-confidence results are retained for human review instead of being mixed +with high-confidence findings. diff --git a/examples/skills_code_review_agent/skills/code-review/references/rules.json b/examples/skills_code_review_agent/skills/code-review/references/rules.json new file mode 100644 index 000000000..db3b9e0c2 --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/references/rules.json @@ -0,0 +1,112 @@ +{ + "line_rules": [ + { + "id": "SEC-001", + "category": "security", + "severity": "high", + "pattern": "\\b(?:eval|exec)\\s*\\(", + "title": "Dynamic code evaluation", + "recommendation": "Replace dynamic evaluation with a strict parser or an explicit operation allowlist.", + "confidence": 0.98 + }, + { + "id": "SEC-002", + "category": "security", + "severity": "critical", + "pattern": "\\bsubprocess\\.(?:run|Popen|call|check_call|check_output)\\s*\\([^\\n]*shell\\s*=\\s*True", + "title": "Shell-enabled subprocess execution", + "recommendation": "Disable shell execution and pass a fixed executable with a validated argument list.", + "confidence": 0.99 + }, + { + "id": "SEC-003", + "category": "security", + "severity": "high", + "pattern": "\\bshell\\s*=\\s*True\\b", + "title": "Untrusted shell interpretation", + "recommendation": "Use direct process execution with shell disabled and validate every argument.", + "confidence": 0.96 + }, + { + "id": "SEC-004", + "category": "security", + "severity": "high", + "pattern": "\\b(?:os\\.system|Runtime\\.getRuntime\\(\\)\\.exec|child_process\\.exec)\\s*\\(", + "title": "Command execution API", + "recommendation": "Use a fixed command allowlist and an argument-array API without a shell.", + "confidence": 0.96 + }, + { + "id": "SEC-005", + "category": "security", + "severity": "high", + "pattern": "\\.(?:execute|query)\\s*\\(\\s*(?:f[\"']|[\"'][^\"']*\\{|[^,]+\\+)", + "title": "Interpolated database query", + "recommendation": "Use a parameterized query and bind all untrusted values through the database driver.", + "confidence": 0.90 + }, + { + "id": "ASYNC-001", + "category": "async_error", + "severity": "high", + "pattern": "^\\s*(?:asyncio\\.)?(?:create_task|ensure_future)\\s*\\(", + "title": "Detached asynchronous task", + "recommendation": "Retain and await the task, use a task group, or attach explicit exception handling.", + "confidence": 0.94 + }, + { + "id": "ASYNC-002", + "category": "async_error", + "severity": "medium", + "pattern": "^\\s*except\\s+(?:Exception|BaseException)\\s*:\\s*(?:pass)?\\s*$", + "title": "Asynchronous failure may be swallowed", + "recommendation": "Handle expected exceptions explicitly and propagate or record unexpected failures.", + "confidence": 0.68 + }, + { + "id": "RES-001", + "category": "resource_leak", + "severity": "high", + "pattern": "^\\s*(?!with\\b)[A-Za-z_][A-Za-z0-9_]*\\s*=\\s*open\\s*\\(", + "title": "File handle may leak", + "recommendation": "Open the file with a context manager or close it in a guaranteed finally block.", + "confidence": 0.91 + }, + { + "id": "DATA-001", + "category": "sensitive_data", + "severity": "critical", + "pattern": "(?i)[\"']?\\b(?:[a-z0-9]+[_-])*(?:api[_-]?key|access[_-]?token|auth[_-]?token|secret|password|passwd|pwd)\\b[\"']?\\s*[:=]\\s*[\"'][^\"']{4,}[\"']", + "title": "Hard-coded credential", + "recommendation": "Remove the credential from source, rotate it, and load it from an approved secret manager.", + "confidence": 0.99 + }, + { + "id": "DATA-002", + "category": "sensitive_data", + "severity": "critical", + "pattern": "(?i)(?:Bearer\\s+[A-Za-z0-9._~+/=-]{8,}|\\bgh[pousr]_[A-Za-z0-9]{16,}|\\b(?:sk|rk)-(?:live|test|proj)-[A-Za-z0-9_-]{8,}|\\bAKIA[0-9A-Z]{16}\\b)", + "title": "Credential-like token in source", + "recommendation": "Remove and rotate the token, then retrieve it at runtime from an approved secret manager.", + "confidence": 0.99 + }, + { + "id": "DATA-003", + "category": "sensitive_data", + "severity": "critical", + "pattern": "-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----", + "title": "Private key material in source", + "recommendation": "Remove and rotate the private key, then store it in an approved secret manager.", + "confidence": 0.99 + }, + { + "id": "DATA-004", + "category": "sensitive_data", + "severity": "critical", + "pattern": "(?i)\\b[a-z][a-z0-9+.-]*://[^:/\\s]+:[^@\\s/]+@", + "title": "Credential embedded in URL", + "recommendation": "Remove credentials from the URL, rotate them, and inject them from a secret manager.", + "confidence": 0.99 + } + ] +} 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..4057e4ca8 --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/references/rules.md @@ -0,0 +1,54 @@ +# Review Rules + +The deterministic scanner evaluates added lines only and uses hunk context to +avoid reporting lifecycle issues that are closed in the same change. + +## Security + +- Flag direct dynamic evaluation through `eval` or `exec`. +- Flag shell-enabled subprocess calls and command execution APIs. +- Flag SQL built through interpolation at the execution call site. +- Recommend fixed commands, argument arrays, strict parsers, or parameterized + statements. + +## Async Errors + +- Flag detached `asyncio.create_task` or `ensure_future` calls whose handle is + not retained. +- Flag broad exception handlers that silently discard failures as a + low-confidence human-review item. +- Recommend structured task ownership and explicit error propagation. + +## Resource Lifecycle + +- Flag files, HTTP clients, and similar resources opened without a context + manager or visible close operation in the hunk. +- Recommend `with` or `async with`, or a `try/finally` cleanup path. + +## Database Lifecycle + +- Flag database connections without a close operation or context manager. +- Flag transactions without a visible commit, rollback, or managed transaction + scope. +- Recommend short scopes with rollback on every exceptional path. + +## Test Coverage + +- When a non-test source file receives at least six added lines and no test + file changes, emit a medium-confidence review item. +- This heuristic is intentionally routed to human review when confidence drops + below the configured threshold. + +## Sensitive Data + +- Flag API keys, access tokens, passwords, private keys, bearer tokens, cloud + access keys, and common provider token prefixes. +- Evidence is redacted before it leaves the scanner. The host repeats + redaction before report rendering and every database write. + +## Confidence and Deduplication + +Findings at or above `0.80` are included in the primary list. Lower-confidence +items are placed in warnings and `needs_human_review`. Findings are unique by +file, target line, and category; if two rules collide, the higher severity and +confidence result wins. diff --git a/examples/skills_code_review_agent/skills/code-review/scripts/review_diff.py b/examples/skills_code_review_agent/skills/code-review/scripts/review_diff.py new file mode 100644 index 000000000..201799cfa --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/scripts/review_diff.py @@ -0,0 +1,425 @@ +#!/usr/bin/env python3 +# +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# +"""Deterministic unified-diff scanner used inside an isolated workspace.""" + +from __future__ import annotations + +import json +import os +import re +import shlex +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +MAX_DIFF_BYTES = 2 * 1024 * 1024 +MAX_FINDINGS = 200 +SEVERITY_ORDER = { + "critical": 5, + "high": 4, + "medium": 3, + "low": 2, + "info": 1, +} +SECRET_KEY_NAME = ( + r"(?:[a-z0-9]+[_-])*" + r"(?:api[_-]?key|access[_-]?token|auth[_-]?token|secret|password|passwd|pwd)" +) +SECRET_KEY_PREFIX = rf"(?:(?:\b{SECRET_KEY_NAME}\b)|(?:[\"']{SECRET_KEY_NAME}[\"']))\s*[:=]\s*" + +KNOWN_SECRET_PATTERNS = ( + re.compile(r"(?i)\bBearer\s+[A-Za-z0-9._~+/=-]{8,}"), + re.compile(r"\b(?:sk|rk)-(?:live|test|proj)-[A-Za-z0-9_-]{8,}"), + re.compile(r"\bgh[pousr]_[A-Za-z0-9]{16,}"), + re.compile(r"\bgithub_pat_[A-Za-z0-9_]{16,}"), + re.compile(r"\bAKIA[0-9A-Z]{16}\b"), + re.compile(r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b"), + re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----"), + re.compile(r"(?i)[a-z][a-z0-9+.-]*://[^:/\s]+:[^@\s/]+@"), +) +SECRET_ASSIGNMENT = re.compile( + rf"""(?ix) + ({SECRET_KEY_PREFIX}) + ("[^"\r\n]{{4,}}"|'[^'\r\n]{{4,}}'|[^"'\s,;}}]{{4,}}) + """ +) + + +@dataclass(frozen=True) +class AddedLine: + """One candidate added line with target line mapping.""" + + file: str + line: int + content: str + hunk_id: int + + +@dataclass +class ChangedFile: + """Parsed changed file.""" + + path: str + added: list[AddedLine] + hunks: dict[int, list[str]] + + +def redact(value: str) -> tuple[str, int]: + """Redact known credentials before output.""" + + result = value + count = 0 + for pattern in KNOWN_SECRET_PATTERNS: + result, replaced = pattern.subn("[REDACTED]", result) + count += replaced + result, replaced = SECRET_ASSIGNMENT.subn(_redact_assignment, result) + count += replaced + return result[:500], count + + +def _redact_assignment(match: re.Match[str]) -> str: + secret_value = match.group(2) + quote = secret_value[0] if secret_value[0] in {'"', "'"} else "" + return f"{match.group(1)}{quote}[REDACTED]{quote}" + + +def _diff_path(header: str) -> str: + try: + parts = shlex.split(header) + except ValueError: + return "" + if len(parts) < 4: + return "" + path = parts[3] + return path[2:] if path.startswith("b/") else path + + +def parse_unified_diff(text: str) -> list[ChangedFile]: + """Parse files, hunks, target lines, and hunk context.""" + + changed_files: list[ChangedFile] = [] + current: ChangedFile | None = None + hunk_id = 0 + old_line = 0 + new_line = 0 + current_from_git_header = False + hunk_pattern = re.compile(r"^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@") + + for raw_line in text.splitlines(): + if raw_line.startswith("diff --git "): + path = _diff_path(raw_line) + current = ChangedFile(path=path, added=[], hunks={}) + changed_files.append(current) + current_from_git_header = True + continue + if raw_line.startswith("--- "): + if current is None or not current_from_git_header: + current = ChangedFile(path="", added=[], hunks={}) + changed_files.append(current) + current_from_git_header = False + continue + if current is None: + continue + if raw_line.startswith("+++ "): + header_path = raw_line[4:].split("\t", 1)[0] + try: + target = shlex.split(header_path)[0] + except (ValueError, IndexError): + target = header_path + if target != "/dev/null": + current.path = target[2:] if target.startswith("b/") else target + continue + match = hunk_pattern.match(raw_line) + if match: + hunk_id += 1 + old_line = int(match.group(1)) + new_line = int(match.group(2)) + current.hunks[hunk_id] = [] + continue + if not hunk_id or hunk_id not in current.hunks: + continue + if raw_line.startswith("\\ No newline"): + continue + if raw_line.startswith("+") and not raw_line.startswith("+++"): + content = raw_line[1:] + current.added.append(AddedLine(current.path, new_line, content, hunk_id)) + current.hunks[hunk_id].append(content) + new_line += 1 + elif raw_line.startswith("-") and not raw_line.startswith("---"): + old_line += 1 + elif raw_line.startswith(" "): + current.hunks[hunk_id].append(raw_line[1:]) + old_line += 1 + new_line += 1 + + return [item for item in changed_files if item.path] + + +def load_rules() -> list[dict[str, Any]]: + rules_path = Path(__file__).resolve().parents[1] / "references" / "rules.json" + payload = json.loads(rules_path.read_text(encoding="utf-8")) + return payload["line_rules"] + + +def make_finding( + *, + severity: str, + category: str, + added: AddedLine, + title: str, + recommendation: str, + confidence: float, + source: str, +) -> tuple[dict[str, Any], int]: + evidence, redactions = redact(added.content.strip()) + return { + "severity": severity, + "category": category, + "file": added.file, + "line": added.line, + "title": title, + "evidence": evidence, + "recommendation": recommendation, + "confidence": confidence, + "source": f"skill:{source}", + }, redactions + + +def scan_line_rules( + changed_files: list[ChangedFile], + rules: list[dict[str, Any]], +) -> tuple[list[dict[str, Any]], int]: + findings = [] + redaction_count = 0 + compiled = [(rule, re.compile(rule["pattern"])) for rule in rules] + for changed_file in changed_files: + for added in changed_file.added: + for rule, pattern in compiled: + if not pattern.search(added.content): + continue + finding, redactions = make_finding( + severity=rule["severity"], + category=rule["category"], + added=added, + title=rule["title"], + recommendation=rule["recommendation"], + confidence=float(rule["confidence"]), + source=rule["id"], + ) + findings.append(finding) + redaction_count += redactions + return findings, redaction_count + + +def scan_lifecycles(changed_files: list[ChangedFile]) -> tuple[list[dict[str, Any]], int]: + """Evaluate resource, database, transaction, and blocking-async context.""" + + findings = [] + redaction_count = 0 + connection_pattern = re.compile( + r"(?i)(?:\b(?:sqlite3|psycopg2|pymysql|mysql|database|driverManager)\s*" + r"(?:\.|::)\s*(?:connect|getConnection)|\bsql\.Open)\s*\(" + ) + transaction_pattern = re.compile( + r"(?i)(?:\.(?:begin|begin_transaction|beginTransaction|BeginTx)\s*\(|\bSTART\s+TRANSACTION\b)" + ) + client_pattern = re.compile( + r"\b(?:aiohttp\.ClientSession|httpx\.(?:Client|AsyncClient)|requests\.Session)\s*\(" + ) + + for changed_file in changed_files: + for added in changed_file.added: + hunk_lines = changed_file.hunks.get(added.hunk_id, []) + hunk_text = "\n".join(hunk_lines) + lowered = hunk_text.lower() + + if re.search(r"^\s*(?!with\b)[A-Za-z_][A-Za-z0-9_]*\s*=\s*open\s*\(", added.content): + if ".close(" not in lowered and "with open(" not in lowered: + finding, count = make_finding( + severity="high", + category="resource_leak", + added=added, + title="File handle has no guaranteed close", + recommendation="Use a context manager or close the handle in a finally block.", + confidence=0.93, + source="RES-CTX-001", + ) + findings.append(finding) + redaction_count += count + + if client_pattern.search(added.content): + if ".close(" not in lowered and ".aclose(" not in lowered and "async with" not in lowered: + finding, count = make_finding( + severity="high", + category="resource_leak", + added=added, + title="Client resource may leak", + recommendation="Use a context manager or guarantee client shutdown in finally.", + confidence=0.90, + source="RES-CTX-002", + ) + findings.append(finding) + redaction_count += count + + if connection_pattern.search(added.content): + safe = any(token in lowered for token in (".close(", "with ", "defer ")) + if not safe: + finding, count = make_finding( + severity="high", + category="database_lifecycle", + added=added, + title="Database connection may remain open", + recommendation="Use a scoped connection context and guarantee close on every path.", + confidence=0.95, + source="DB-CTX-001", + ) + findings.append(finding) + redaction_count += count + + if transaction_pattern.search(added.content): + safe = any(token in lowered for token in (".commit(", ".rollback(", "with ", "defer ")) + if not safe: + finding, count = make_finding( + severity="high", + category="database_lifecycle", + added=added, + title="Transaction has no visible completion path", + recommendation=( + "Commit on success and rollback in every error path, " + "preferably in a managed scope." + ), + confidence=0.93, + source="DB-CTX-002", + ) + findings.append(finding) + redaction_count += count + + if re.search(r"\btime\.sleep\s*\(", added.content) and "async def " in hunk_text: + finding, count = make_finding( + severity="high", + category="async_error", + added=added, + title="Blocking sleep in asynchronous code", + recommendation="Use the runtime's non-blocking sleep and await it.", + confidence=0.96, + source="ASYNC-CTX-001", + ) + findings.append(finding) + redaction_count += count + + return findings, redaction_count + + +def scan_test_coverage(changed_files: list[ChangedFile]) -> list[dict[str, Any]]: + test_changed = any( + re.search(r"(^|/)(?:tests?|spec)(/|_)|(?:_test|test_|\.spec\.)", item.path, re.IGNORECASE) + for item in changed_files) + if test_changed: + return [] + + findings = [] + source_extensions = { + ".c", + ".cc", + ".cpp", + ".go", + ".java", + ".js", + ".kt", + ".py", + ".rs", + ".ts", + } + for changed_file in changed_files: + if Path(changed_file.path).suffix.lower() not in source_extensions: + continue + if len(changed_file.added) < 6: + continue + added = changed_file.added[0] + finding, _ = make_finding( + severity="medium", + category="test_coverage", + added=added, + title="Behavior change has no accompanying test", + recommendation="Add focused tests for the new branches, boundary conditions, and failure paths.", + confidence=0.86, + source="TEST-001", + ) + findings.append(finding) + return findings + + +def deduplicate(findings: list[dict[str, Any]]) -> list[dict[str, Any]]: + unique: dict[tuple[str, int, str], dict[str, Any]] = {} + for finding in findings: + key = (finding["file"], int(finding["line"]), finding["category"]) + current = unique.get(key) + if current is None: + unique[key] = finding + continue + old_score = (SEVERITY_ORDER.get(current["severity"], 0), float(current["confidence"])) + new_score = (SEVERITY_ORDER.get(finding["severity"], 0), float(finding["confidence"])) + if new_score > old_score: + unique[key] = finding + return sorted( + unique.values(), + key=lambda item: ( + -SEVERITY_ORDER.get(item["severity"], 0), + item["file"], + item["line"], + item["category"], + ), + )[:MAX_FINDINGS] + + +def main() -> int: + if len(sys.argv) == 1: + work_dir = Path(os.environ["WORK_DIR"]) + output_dir = Path(os.environ["OUTPUT_DIR"]) + input_path = work_dir / "inputs" / "review.diff" + output_path = output_dir / "review_findings.json" + elif len(sys.argv) == 3: + input_path = Path(sys.argv[1]) + output_path = Path(sys.argv[2]) + else: + print("usage: review_diff.py INPUT_DIFF OUTPUT_JSON", file=sys.stderr) + return 2 + + raw = input_path.read_bytes() + if len(raw) > MAX_DIFF_BYTES: + print(f"input exceeds {MAX_DIFF_BYTES} bytes", file=sys.stderr) + return 2 + + changed_files = parse_unified_diff(raw.decode("utf-8", errors="replace")) + line_findings, line_redactions = scan_line_rules(changed_files, load_rules()) + lifecycle_findings, lifecycle_redactions = scan_lifecycles(changed_files) + test_findings = scan_test_coverage(changed_files) + findings = deduplicate(line_findings + lifecycle_findings + test_findings) + + payload = { + "findings": findings, + "stats": { + "files_scanned": len(changed_files), + "added_lines_scanned": sum(len(item.added) for item in changed_files), + "finding_count": len(findings), + }, + "redaction_count": line_redactions + lifecycle_redactions, + } + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(json.dumps(payload, ensure_ascii=True, indent=2, sort_keys=True), encoding="utf-8") + print( + f"reviewed {payload['stats']['files_scanned']} files, " + f"found {payload['stats']['finding_count']} candidates" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/skills_code_review_agent/skills/code-review/scripts/timeout_probe.py b/examples/skills_code_review_agent/skills/code-review/scripts/timeout_probe.py new file mode 100644 index 000000000..ff8f98a1c --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/scripts/timeout_probe.py @@ -0,0 +1,13 @@ +#!/usr/bin/env python3 +# +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# +"""Deliberately slow built-in probe used to verify sandbox timeout handling.""" + +import time + +time.sleep(5) diff --git a/examples/skills_code_review_agent/tests/fixtures/async_resource_leak.diff b/examples/skills_code_review_agent/tests/fixtures/async_resource_leak.diff new file mode 100644 index 000000000..c92cf98d9 --- /dev/null +++ b/examples/skills_code_review_agent/tests/fixtures/async_resource_leak.diff @@ -0,0 +1,14 @@ +diff --git a/app/worker.py b/app/worker.py +index 1111111..2222222 100644 +--- a/app/worker.py ++++ b/app/worker.py +@@ -1,3 +1,10 @@ ++import asyncio ++ + async def refresh(path): +- return await load(path) ++ asyncio.create_task(load(path)) ++ handle = open(path, "r") ++ content = handle.read() ++ await publish(content) ++ return content diff --git a/examples/skills_code_review_agent/tests/fixtures/clean.diff b/examples/skills_code_review_agent/tests/fixtures/clean.diff new file mode 100644 index 000000000..1fcc4ff22 --- /dev/null +++ b/examples/skills_code_review_agent/tests/fixtures/clean.diff @@ -0,0 +1,21 @@ +diff --git a/app/math_utils.py b/app/math_utils.py +index 1111111..2222222 100644 +--- a/app/math_utils.py ++++ b/app/math_utils.py +@@ -1,2 +1,6 @@ + def add(left, right): +- return left + right ++ """Return the sum of two numbers.""" ++ result = left + right ++ return result +diff --git a/tests/test_math_utils.py b/tests/test_math_utils.py +index 3333333..4444444 100644 +--- a/tests/test_math_utils.py ++++ b/tests/test_math_utils.py +@@ -1,3 +1,6 @@ + from app.math_utils import add + # Existing coverage for positive numbers. + assert add(1, 2) == 3 ++ ++def test_negative_numbers(): ++ assert add(-1, -2) == -3 diff --git a/examples/skills_code_review_agent/tests/fixtures/database_lifecycle.diff b/examples/skills_code_review_agent/tests/fixtures/database_lifecycle.diff new file mode 100644 index 000000000..169c34056 --- /dev/null +++ b/examples/skills_code_review_agent/tests/fixtures/database_lifecycle.diff @@ -0,0 +1,16 @@ +diff --git a/app/repository.py b/app/repository.py +index 1111111..2222222 100644 +--- a/app/repository.py ++++ b/app/repository.py +@@ -1,3 +1,10 @@ ++import sqlite3 ++ + def save_user(db_path, user): +- raise NotImplementedError ++ connection = sqlite3.connect(db_path) ++ transaction = connection.begin() ++ connection.execute( ++ "INSERT INTO users(id, name) VALUES (?, ?)", ++ (user.id, user.name), ++ ) ++ return transaction diff --git a/examples/skills_code_review_agent/tests/fixtures/duplicate_finding.diff b/examples/skills_code_review_agent/tests/fixtures/duplicate_finding.diff new file mode 100644 index 000000000..5e619c878 --- /dev/null +++ b/examples/skills_code_review_agent/tests/fixtures/duplicate_finding.diff @@ -0,0 +1,13 @@ +diff --git a/app/commands.py b/app/commands.py +index 1111111..2222222 100644 +--- a/app/commands.py ++++ b/app/commands.py +@@ -1,3 +1,8 @@ ++import subprocess ++ + def execute(command): +- return command ++ completed = subprocess.run(command, shell=True, capture_output=True) ++ if completed.returncode: ++ raise RuntimeError(completed.stderr) ++ return completed.stdout diff --git a/examples/skills_code_review_agent/tests/fixtures/missing_tests.diff b/examples/skills_code_review_agent/tests/fixtures/missing_tests.diff new file mode 100644 index 000000000..beaa62962 --- /dev/null +++ b/examples/skills_code_review_agent/tests/fixtures/missing_tests.diff @@ -0,0 +1,14 @@ +diff --git a/app/discount.py b/app/discount.py +index 1111111..2222222 100644 +--- a/app/discount.py ++++ b/app/discount.py +@@ -1,3 +1,10 @@ + def calculate_discount(customer, subtotal): +- return 0 ++ if customer.is_vip: ++ return subtotal * 0.20 ++ if subtotal >= 1000: ++ return subtotal * 0.10 ++ if subtotal >= 500: ++ return subtotal * 0.05 ++ return 0 diff --git a/examples/skills_code_review_agent/tests/fixtures/sandbox_failure.diff b/examples/skills_code_review_agent/tests/fixtures/sandbox_failure.diff new file mode 100644 index 000000000..43920c1a4 --- /dev/null +++ b/examples/skills_code_review_agent/tests/fixtures/sandbox_failure.diff @@ -0,0 +1,11 @@ +diff --git a/app/health.py b/app/health.py +index 1111111..2222222 100644 +--- a/app/health.py ++++ b/app/health.py +@@ -1,2 +1,5 @@ + def health(): +- return "ok" ++ status = { ++ "state": "ok", ++ } ++ return status diff --git a/examples/skills_code_review_agent/tests/fixtures/secrets.diff b/examples/skills_code_review_agent/tests/fixtures/secrets.diff new file mode 100644 index 000000000..1940132f9 --- /dev/null +++ b/examples/skills_code_review_agent/tests/fixtures/secrets.diff @@ -0,0 +1,15 @@ +diff --git a/app/settings.py b/app/settings.py +index 1111111..2222222 100644 +--- a/app/settings.py ++++ b/app/settings.py +@@ -1,2 +1,10 @@ + DEBUG = False +-API_KEY = "" ++API_KEY = "sk-live-51JwQvD7R3aN8mK2pL6x" ++DATABASE_PASSWORD = "correct horse battery staple" ++JSON_CONFIG = {"password": "json secret with spaces"} ++PRIVATE_KEY_PEM = "-----BEGIN PRIVATE KEY-----" ++DB_URL = "postgresql://admin:supersecret@example.internal/app" ++ ++def auth_headers(): ++ return {"Authorization": "Bearer ghp_1234567890abcdefghijklmnop"} diff --git a/examples/skills_code_review_agent/tests/fixtures/security.diff b/examples/skills_code_review_agent/tests/fixtures/security.diff new file mode 100644 index 000000000..57ffa39bd --- /dev/null +++ b/examples/skills_code_review_agent/tests/fixtures/security.diff @@ -0,0 +1,12 @@ +diff --git a/app/template.py b/app/template.py +index 1111111..2222222 100644 +--- a/app/template.py ++++ b/app/template.py +@@ -1,3 +1,8 @@ + def render_template(expression, values): +- return values.get(expression) ++ namespace = dict(values) ++ result = eval(expression, {"__builtins__": {}}, namespace) ++ if result is None: ++ return "" ++ return str(result) diff --git a/tests/examples/test_skills_code_review_agent.py b/tests/examples/test_skills_code_review_agent.py new file mode 100644 index 000000000..5d2c18ae3 --- /dev/null +++ b/tests/examples/test_skills_code_review_agent.py @@ -0,0 +1,346 @@ +# +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# +"""Tests for the Skills-based code review agent example.""" + +from __future__ import annotations + +import json +import os +import subprocess +from pathlib import Path + +import pytest + +from examples.skills_code_review_agent.agent.core import InputResolver +from examples.skills_code_review_agent.agent.core import ReviewReport +from examples.skills_code_review_agent.agent.core import normalize_findings +from examples.skills_code_review_agent.agent.governance import ReviewExecutionFilter +from examples.skills_code_review_agent.agent.storage import SqlReviewStore +from examples.skills_code_review_agent.agent.workflow import CodeReviewAgent +from examples.skills_code_review_agent.agent.workflow import ReviewConfig + +EXAMPLE_ROOT = Path(__file__).parents[2] / "examples" / "skills_code_review_agent" +FIXTURES = EXAMPLE_ROOT / "tests" / "fixtures" + +EXPECTED_CATEGORIES = { + "clean.diff": set(), + "security.diff": {"security"}, + "async_resource_leak.diff": {"async_error", "resource_leak"}, + "database_lifecycle.diff": {"database_lifecycle"}, + "missing_tests.diff": {"test_coverage"}, + "duplicate_finding.diff": {"security"}, + "sandbox_failure.diff": set(), + "secrets.diff": {"sensitive_data"}, +} + + +async def _review(tmp_path: Path, fixture_name: str, **overrides): + output_dir = tmp_path / fixture_name.removesuffix(".diff") + db_path = tmp_path / "reviews.db" + values = { + "runtime": "local", + "db_url": f"sqlite:///{db_path}", + "output_dir": output_dir, + "skill_root": EXAMPLE_ROOT / "skills", + "work_root": tmp_path / "workspaces", + "dry_run": True, + "fake_model": True, + } + values.update(overrides) + agent = CodeReviewAgent(ReviewConfig(**values)) + report = await agent.review(diff_file=FIXTURES / fixture_name) + return report, db_path, output_dir + + +@pytest.mark.parametrize("fixture_name", EXPECTED_CATEGORIES) +@pytest.mark.asyncio +async def test_public_diff_fixtures_generate_reports(tmp_path, fixture_name): + report, _, output_dir = await _review(tmp_path, fixture_name) + + categories = {item.category for item in report.findings + report.warnings} + assert EXPECTED_CATEGORIES[fixture_name] <= categories + assert report.status in {"completed", "completed_with_warnings"} + assert (output_dir / "review_report.json").is_file() + assert (output_dir / "review_report.md").is_file() + assert report.monitoring.total_duration_ms < 120_000 + assert not list((tmp_path / "workspaces").glob("ws_*")) + + +@pytest.mark.asyncio +async def test_duplicate_findings_are_collapsed(tmp_path): + report, _, _ = await _review(tmp_path, "duplicate_finding.diff") + security_findings = [item for item in report.findings if item.category == "security"] + keys = {(item.file, item.line, item.category) for item in security_findings} + + assert len(security_findings) == len(keys) == 1 + + +def test_low_confidence_finding_is_routed_to_human_review(): + raw = { + "severity": "medium", + "category": "async_error", + "file": "app/worker.py", + "line": 12, + "title": "Possible swallowed error", + "evidence": "except Exception:", + "recommendation": "Handle expected exceptions explicitly.", + "confidence": 0.68, + "source": "skill:ASYNC-002", + } + + findings, warnings, _ = normalize_findings([raw]) + + assert findings == [] + assert len(warnings) == 1 + assert warnings[0].source == "skill:ASYNC-002" + + +@pytest.mark.asyncio +async def test_sandbox_failure_is_persisted_without_crashing_review(tmp_path): + report, db_path, _ = await _review( + tmp_path, + "sandbox_failure.diff", + checker_script="scripts/missing.py", + allowed_scripts={"scripts/review_diff.py", "scripts/missing.py"}, + ) + + assert report.status == "completed_with_warnings" + assert report.sandbox_runs[0].status == "failed" + assert report.monitoring.exception_distribution + + store = SqlReviewStore(f"sqlite:///{db_path}") + await store.initialize() + stored = await store.get_review(report.task_id) + await store.close() + assert stored["task"]["status"] == "completed_with_warnings" + assert stored["sandbox_runs"][0]["status"] == "failed" + + +@pytest.mark.asyncio +async def test_filter_denial_never_starts_sandbox(tmp_path): + report, _, _ = await _review( + tmp_path, + "clean.diff", + checker_script="../escape.py", + ) + + assert report.status == "blocked" + assert report.filter_decisions[0].decision == "deny" + assert report.sandbox_runs == [] + assert report.monitoring.tool_calls == 0 + assert report.monitoring.interception_count == 1 + + +def test_filter_denies_high_risk_executable(): + policy = ReviewExecutionFilter( + allowed_scripts={"scripts/review_diff.py"}, + env_allowlist={"PYTHONUNBUFFERED"}, + network_allowlist=set(), + max_timeout_seconds=120, + max_output_bytes=64 * 1024, + decision_sink=lambda _: None, + ) + + decision = policy.evaluate({"command": "bash scripts/review_diff.py"}) + + assert decision.decision == "deny" + assert decision.rule_id == "high_risk_command" + + +@pytest.mark.asyncio +async def test_timeout_is_recorded_and_review_completes(tmp_path): + report, _, _ = await _review( + tmp_path, + "clean.diff", + checker_script="scripts/timeout_probe.py", + timeout_seconds=1, + ) + + assert report.status == "completed_with_warnings" + assert report.sandbox_runs[0].timed_out is True + assert report.sandbox_runs[0].status == "timed_out" + + +@pytest.mark.asyncio +async def test_secrets_are_redacted_from_reports_and_database(tmp_path): + report, db_path, output_dir = await _review(tmp_path, "secrets.diff") + forbidden = ( + "sk-live-51JwQvD7R3aN8mK2pL6x", + "correct horse battery staple", + "json secret with spaces", + "ghp_1234567890abcdefghijklmnop", + "-----BEGIN PRIVATE KEY-----", + "supersecret", + ) + report_text = (output_dir / "review_report.json").read_text(encoding="utf-8") + database_bytes = db_path.read_bytes() + + assert report.monitoring.redaction_count >= 3 + assert "[REDACTED]" in report_text + for secret in forbidden: + assert secret not in report_text + assert secret.encode() not in database_bytes + + +@pytest.mark.asyncio +async def test_database_query_returns_complete_review(tmp_path): + report, db_path, _ = await _review(tmp_path, "security.diff") + store = SqlReviewStore(f"sqlite:///{db_path}") + await store.initialize() + stored = await store.get_review(report.task_id) + await store.close() + + assert stored["task"]["id"] == report.task_id + assert stored["sandbox_runs"] + assert stored["filter_decisions"] + assert stored["findings"] + assert stored["metrics"]["finding_count"] == len(report.findings) + assert json.loads(stored["report"]["report_json"])["task_id"] == report.task_id + + +@pytest.mark.asyncio +async def test_over_budget_execution_requires_human_review_without_sandbox(tmp_path): + report, _, _ = await _review( + tmp_path, + "clean.diff", + timeout_seconds=121, + max_timeout_seconds=120, + ) + + assert report.status == "blocked" + assert report.filter_decisions[0].decision == "needs_human_review" + assert report.filter_decisions[0].rule_id == "timeout_budget" + assert report.sandbox_runs == [] + + +@pytest.mark.asyncio +async def test_over_budget_output_is_blocked_before_sandbox(tmp_path): + report, _, _ = await _review( + tmp_path, + "clean.diff", + max_output_bytes=128 * 1024, + max_policy_output_bytes=64 * 1024, + ) + + assert report.status == "blocked" + assert report.filter_decisions[0].decision == "needs_human_review" + assert report.filter_decisions[0].rule_id == "output_budget" + assert report.sandbox_runs == [] + + +@pytest.mark.asyncio +async def test_non_allowlisted_network_is_blocked_before_sandbox(tmp_path): + report, _, _ = await _review( + tmp_path, + "clean.diff", + network_hosts=["api.github.com"], + ) + + assert report.status == "blocked" + assert report.filter_decisions[0].rule_id == "network_not_allowed" + assert report.sandbox_runs == [] + + +def test_file_list_input_is_converted_to_unified_diff(tmp_path): + first = tmp_path / "first.py" + second = tmp_path / "second.py" + first.write_text("value = 1\n", encoding="utf-8") + second.write_text("value = 2\n", encoding="utf-8") + + resolved = InputResolver().resolve_files([first, second]) + + assert resolved.input_type == "file_list" + assert resolved.summary.file_count == 2 + assert resolved.summary.changed_line_count == 2 + + +@pytest.mark.asyncio +async def test_plain_unified_diff_without_git_header_is_reviewed(tmp_path): + patch = tmp_path / "plain.diff" + patch.write_text( + "--- a/app/runtime.py\n" + "+++ b/app/runtime.py\n" + "@@ -1 +1 @@\n" + "-return values.get(expression)\n" + "+return eval(expression)\n", + encoding="utf-8", + ) + config = ReviewConfig( + runtime="local", + db_url=f"sqlite:///{tmp_path / 'plain.db'}", + output_dir=tmp_path / "output", + skill_root=EXAMPLE_ROOT / "skills", + work_root=tmp_path / "workspaces", + dry_run=True, + ) + + report = await CodeReviewAgent(config).review(diff_file=patch) + + assert report.input_summary.files == ["app/runtime.py"] + assert any(item.category == "security" for item in report.findings) + + +def test_git_workspace_input_includes_tracked_and_untracked_files(tmp_path): + repo = tmp_path / "repo" + repo.mkdir() + subprocess.run(["git", "init", "-q", str(repo)], check=True) + subprocess.run(["git", "-C", str(repo), "config", "user.name", "test"], check=True) + subprocess.run(["git", "-C", str(repo), "config", "user.email", "test@example.com"], check=True) + tracked = repo / "tracked.py" + tracked.write_text("value = 1\n", encoding="utf-8") + subprocess.run(["git", "-C", str(repo), "add", "tracked.py"], check=True) + subprocess.run(["git", "-C", str(repo), "commit", "-qm", "initial"], check=True) + tracked.write_text("value = 2\n", encoding="utf-8") + (repo / "untracked.py").write_text("new_value = 3\n", encoding="utf-8") + + resolved = InputResolver().resolve_repo(repo) + + assert resolved.input_type == "git_workspace" + assert resolved.summary.file_count == 2 + assert set(resolved.summary.files) == {"tracked.py", "untracked.py"} + + +def test_git_linked_worktree_input_is_supported(tmp_path): + repo = tmp_path / "repo" + linked = tmp_path / "linked" + repo.mkdir() + subprocess.run(["git", "init", "-q", str(repo)], check=True) + subprocess.run(["git", "-C", str(repo), "config", "user.name", "test"], check=True) + subprocess.run(["git", "-C", str(repo), "config", "user.email", "test@example.com"], check=True) + tracked = repo / "tracked.py" + tracked.write_text("value = 1\n", encoding="utf-8") + subprocess.run(["git", "-C", str(repo), "add", "tracked.py"], check=True) + subprocess.run(["git", "-C", str(repo), "commit", "-qm", "initial"], check=True) + subprocess.run(["git", "-C", str(repo), "worktree", "add", "-q", str(linked)], check=True) + (linked / "tracked.py").write_text("value = 2\n", encoding="utf-8") + + resolved = InputResolver().resolve_repo(linked) + + assert resolved.input_type == "git_workspace" + assert resolved.summary.files == ["tracked.py"] + + +def test_sample_report_matches_output_contract(): + sample = EXAMPLE_ROOT / "sample_output" / "review_report.json" + report = ReviewReport.model_validate_json(sample.read_text(encoding="utf-8")) + + assert report.status == "completed" + assert report.findings[0].source == "skill:SEC-001" + assert report.sandbox_runs[0].runtime == "container" + + +@pytest.mark.skipif(os.getenv("RUN_CODE_REVIEW_CONTAINER_TEST") != "1", + reason="set RUN_CODE_REVIEW_CONTAINER_TEST=1 for Docker E2E") +@pytest.mark.asyncio +async def test_container_runtime_end_to_end(tmp_path): + report, _, _ = await _review(tmp_path, "security.diff", runtime="container") + + assert report.status == "completed" + assert report.sandbox_runs[0].runtime == "container" + assert report.sandbox_runs[0].exit_code == 0 + assert any(item.category == "security" for item in report.findings)