diff --git a/examples/skills_code_review_agent/.env b/examples/skills_code_review_agent/.env new file mode 100644 index 00000000..55794046 --- /dev/null +++ b/examples/skills_code_review_agent/.env @@ -0,0 +1,7 @@ +# Real-model mode (optional). Leave empty to use --dry-run / fake model. +TRPC_AGENT_API_KEY= +TRPC_AGENT_BASE_URL= +TRPC_AGENT_MODEL_NAME= + +# Cube runtime (optional) +CUBE_API_KEY= diff --git a/examples/skills_code_review_agent/README.md b/examples/skills_code_review_agent/README.md new file mode 100644 index 00000000..4e357b58 --- /dev/null +++ b/examples/skills_code_review_agent/README.md @@ -0,0 +1,135 @@ +# Skills Code Review Agent + +Automated code review agent powered by the tRPC-Agent [Skills](https://git.woa.com/trpc-python/trpc-python-agent/trpc-agent/blob/master/docs/skills/skills_zh_CN.md) framework. It demonstrates end-to-end sandboxed execution of review checkers (security, async/resource leaks, DB lifecycle, missing tests, secret leaks), governance via tool-level Filter, SQL-based persistence of findings + metrics + reports, and host-side redaction. The example runs deterministic checker scripts inside a container/workspace sandbox and optionally enriches results with an LLM pass. + +``` + Unified diff (file / repo / fixture) + | + v + +-- Parse diff --+ + | (parse_diff) | + +----------------+ + | + v + +--- Governance Filter ----+ + | script allowlist, paths, | + | network deny, budget, | + | risk → needs_human_review| + +--------------------------+ + | + v + +---- Sandbox (container / local / cube) ----+ + | env -i (PATH+HOME+LANG whitelist) | + | security | async_leak | db_lifecycle | + | tests_missing | secrets | + | timeout: 60s, output cap: 256KB | + +-----------------------------------------------+ + | + v + +--- LLM enrichment (optional) ---+ + | confidence boost, false-positive | + | suppression, prose summary | + +---------------------------------+ + | + v + +--- Dedup + Gating ---+ + | file:line:category | + | confidence >= 0.6 | + +-----------------------+ + | + v + +---- Persist to SQLite ----+ + | 6 tables, task-id indexed | + +---------------------------+ + | + v + +-- Redaction + Reports --+ + | review_report.json/.md | + +-------------------------+ +``` + +## Quick Start + +```bash +cd examples/skills_code_review_agent + +# Dev-fallback runtime, no API key needed: +python run_agent.py review --fixture security_eval --runtime local --dry-run + +# Production default (requires Docker): +python run_agent.py review --diff-file my_change.diff + +# Query a stored review: +python run_agent.py show --task-id +``` + +## CLI Reference + +| Flag | Values | Default | Description | +|---|---|---|---| +| `--diff-file` | path | — | Path to a unified diff / PR patch | +| `--repo-path` | path | — | Git repo; reviews `git diff HEAD` | +| `--fixture` | name | — | Bundled fixture (e.g. `security_eval`) | +| `--runtime` | `local` / `container` / `cube` | `container` | Sandbox runtime; `local` is dev-only | +| `--dry-run` | flag | off | Use deterministic fake model, no API key | +| `--db-url` | URL | `sqlite:///code_review.db` | SQLite path or DB URL | +| `--output-dir` | path | `out` | Directory for JSON + Markdown reports | +| `--no-llm` | flag | off | Skip the LLM enrichment step | + +## Rule Categories + +| Category | Script | Example Patterns | +|---|---|---| +| `security` | `check_security.py` | `eval`, `exec`, `shell=True`, `pickle`, `yaml.load`, SQL injection | +| `async_resource_leak` | `check_async_leak.py` | Unreferenced asyncio tasks, unmanaged sessions/files | +| `db_lifecycle` | `check_db_lifecycle.py` | DB connection/cursor/transaction lifecycle issues | +| `missing_test` | `check_tests_missing.py` | Source files changed without corresponding test changes | +| `secret_leak` | `check_secrets.py` | Hardcoded API keys, tokens, passwords (evidence pre-redacted) | + +## Database Schema + +| Table | Key Columns | +|---|---| +| `cr_review_tasks` | `id`, `status`, `input_type`, `input_ref`, `runtime`, `dry_run`, `diff_summary`, `created_at`, `finished_at` | +| `cr_sandbox_runs` | `id`, `task_id`, `script`, `category`, `status`, `exit_code`, `duration_ms`, `timed_out`, `stdout_summary`, `stderr_summary`, `error_type` | +| `cr_findings` | `id`, `task_id`, `severity`, `category`, `file`, `line`, `title`, `evidence`, `recommendation`, `confidence`, `source`, `status`, `dedup_key` | +| `cr_filter_events` | `id`, `task_id`, `target`, `decision`, `rule`, `reason`, `created_at` | +| `cr_metrics` | `id`, `task_id`, `total_duration_ms`, `sandbox_duration_ms`, `tool_calls`, `intercepts`, `findings_total`, `severity_distribution`, `error_distribution` | +| `cr_reports` | `id`, `task_id`, `report_json`, `report_md`, `created_at` | + +All tables are joinable via `task_id` (foreign key to `cr_review_tasks.id`). + +## Security Boundaries + +- **Environment isolation**: Every checker script runs under `env -i` with only `PATH`, `HOME`, and `LANG` in the environment. No host env vars leak into the sandbox. +- **Timeouts**: Each script execution has a 60-second timeout (configurable). Total sandbox budget is capped at 300 seconds / 20 runs. +- **Output caps**: stdout and stderr are truncated at 256 KB per run. +- **Redaction**: Host-side regex-based redaction (shared pattern library with checkers) is applied to all reports and DB-stored content. No plaintext secrets survive in reports or the database. +- **Filter governance**: The `GovernanceToolFilter` blocks LLM-initiated tool calls outside the script allowlist, denies network tools (`curl`, `wget`, `pip`, `git`, etc.), forbids absolute/relative path escapes, and escalates high-risk commands (`sudo`, `docker`, `rm`, etc.) to `needs_human_review`. +- **Local runtime**: `--runtime local` is a development-only shortcut. Production deployments must use `container` or `cube`. + +## Testing + +```bash +# Run all tests (container tests are skipped by default): +python -m pytest examples/skills_code_review_agent/tests -q + +# Opt into container-based tests (requires Docker): +CR_CONTAINER_TESTS=1 python -m pytest examples/skills_code_review_agent/tests -q +``` + +## 方案设计说明 + +**Skill 设计**:采用 SKILL.md 声明的规则文档 + 脚本架构,每个 checker 脚本以 JSON 契约输出 `{"findings": [...]}`,包含 severity、category、file、line、evidence、recommendation、confidence、source 字段,确保静态分析与 LLM 富化结果统一格式。 + +**沙箱隔离策略**:生产默认使用 container 运行时创建独立工作空间,`env -i` 启动脚本仅注入 PATH/HOME/LANG 白名单,单次超时 60 秒,预算上限 300 秒 / 20 次运行,输出截断 256 KB。local 运行时仅用于开发调试。 + +**Filter 策略**:GovernanceEngine 实施脚本白名单(6 个 checker),禁止网络工具(curl、wget、pip、git 等),拦截绝对路径 / `..` 穿越,高危命令(sudo、docker、rm 等)进入 `needs_human_review` 人工复核,超出预算直接 deny。 + +**监控字段**:`cr_metrics` 表记录 total_duration_ms、sandbox_duration_ms、tool_calls、intercepts、findings_total、severity_distribution(JSON 分布)和 error_distribution,可按 task_id 追溯全链路性能与异常。 + +**数据库 schema**:六表设计 — `cr_review_tasks`(任务),`cr_sandbox_runs`(沙箱运行记录),`cr_findings`(发现项),`cr_filter_events`(拦截事件),`cr_metrics`(监控指标),`cr_reports`(报告),均通过 task_id 外键关联查询。 + +**去重降噪**:file + line + category 三维联合去重,相同键保留最高 severity 与 confidence,置信度 < 0.6 的发现进入 `needs_human_review` 人工确认,避免低质量误报淹没关键问题。 + +**安全边界**:全链路脱敏 — 报告写入前经 host 侧 redaction(与 checker 共享 secret_patterns.py 规则),数据库存储同样经过脱敏,确保报告文件与 SQLite 库中绝无明文密钥泄露。整个方案通过技能化、沙箱化、治理化和持久化四个维度实现生产级代码审查自动化。 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 00000000..4faa7cf1 --- /dev/null +++ b/examples/skills_code_review_agent/agent/__init__.py @@ -0,0 +1,6 @@ +# 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 Apache-2.0. +"""Agent wiring — fake model, prompts, config, and LlmAgent factory.""" diff --git a/examples/skills_code_review_agent/agent/agent.py b/examples/skills_code_review_agent/agent/agent.py new file mode 100644 index 00000000..ba81da9e --- /dev/null +++ b/examples/skills_code_review_agent/agent/agent.py @@ -0,0 +1,31 @@ +# 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 Apache-2.0. +"""LlmAgent wiring for the code review example.""" +from trpc_agent_sdk.agents import LlmAgent +from trpc_agent_sdk.models import OpenAIModel +from trpc_agent_sdk.skills import SkillToolSet + +from .config import get_model_config +from .fake_model import FakeReviewModel +from .prompts import INSTRUCTION + + +def create_review_agent(repository, dry_run: bool, tool_filters: list) -> LlmAgent: + """Create the review agent. tool_filters guard skill_run via governance.""" + if dry_run: + model = FakeReviewModel() + else: + api_key, url, model_name = get_model_config() + model = OpenAIModel(model_name=model_name, api_key=api_key, base_url=url) + toolset = SkillToolSet(repository=repository, filters=list(tool_filters)) + return LlmAgent( + name="code_review_agent", + description="Automated code review agent combining skill scripts and LLM analysis.", + model=model, + instruction=INSTRUCTION, + tools=[toolset], + skill_repository=repository, + ) diff --git a/examples/skills_code_review_agent/agent/config.py b/examples/skills_code_review_agent/agent/config.py new file mode 100644 index 00000000..7044cd85 --- /dev/null +++ b/examples/skills_code_review_agent/agent/config.py @@ -0,0 +1,23 @@ +# 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 Apache-2.0. +"""Model configuration from environment variables.""" +import os + + +def get_model_config() -> tuple[str, str, str]: + """Return (api_key, base_url, model_name) or raise when unset.""" + api_key = os.getenv("TRPC_AGENT_API_KEY", "") + url = os.getenv("TRPC_AGENT_BASE_URL", "") + model_name = os.getenv("TRPC_AGENT_MODEL_NAME", "") + if not api_key or not url or not model_name: + raise ValueError("TRPC_AGENT_API_KEY, TRPC_AGENT_BASE_URL and " + "TRPC_AGENT_MODEL_NAME must be set for real-model mode") + return api_key, url, model_name + + +def is_dry_run(explicit: bool) -> bool: + """Dry-run when requested explicitly or when no API key is configured.""" + return explicit or not os.getenv("TRPC_AGENT_API_KEY", "") diff --git a/examples/skills_code_review_agent/agent/fake_model.py b/examples/skills_code_review_agent/agent/fake_model.py new file mode 100644 index 00000000..690251a1 --- /dev/null +++ b/examples/skills_code_review_agent/agent/fake_model.py @@ -0,0 +1,34 @@ +# 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 Apache-2.0. +"""Deterministic fake model so the full pipeline runs without any API key.""" +import json +from typing import List + +from trpc_agent_sdk.models import LLMModel, LlmResponse +from trpc_agent_sdk.types import Content, Part + +_FAKE_PAYLOAD = { + "summary": "Dry-run review complete. Static findings are authoritative.", + "findings": [], +} + + +class FakeReviewModel(LLMModel): + """LLMModel returning one canned JSON review response.""" + + def __init__(self, model_name: str = "fake-review-model", **kwargs): + super().__init__(model_name=model_name, **kwargs) + + @classmethod + def supported_models(cls) -> List[str]: + return [r"fake-review-.*"] + + def validate_request(self, request) -> None: + return None + + async def _generate_async_impl(self, request, stream=False, ctx=None): + text = json.dumps(_FAKE_PAYLOAD) + yield LlmResponse(content=Content(role="model", parts=[Part.from_text(text=text)])) diff --git a/examples/skills_code_review_agent/agent/prompts.py b/examples/skills_code_review_agent/agent/prompts.py new file mode 100644 index 00000000..17cdfaf3 --- /dev/null +++ b/examples/skills_code_review_agent/agent/prompts.py @@ -0,0 +1,30 @@ +# 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 Apache-2.0. +"""Prompts for the code review agent.""" + +INSTRUCTION = """You are an automated code reviewer. + +You receive a unified diff plus baseline findings produced by static rule +scripts from the code-review skill. Confirm the baseline findings and add any +extra issues you can justify from the diff alone. + +Reply with a single JSON object and nothing else: +{"summary": "", "findings": [{"severity": "critical|high|medium|low|info", +"category": "security|async_resource_leak|db_lifecycle|missing_test|secret_leak", +"file": "", "line": , "title": "", "evidence": "", +"recommendation": "", "confidence": <0.0-1.0>}]} + +Only report issues visible in the diff. Do not repeat baseline findings. +""" + +REVIEW_REQUEST_TEMPLATE = """Review this change. + +Baseline static findings (JSON): +{findings_json} + +Unified diff (secrets already redacted): +{diff} +""" diff --git a/examples/skills_code_review_agent/filters_cr/__init__.py b/examples/skills_code_review_agent/filters_cr/__init__.py new file mode 100644 index 00000000..de8eac1c --- /dev/null +++ b/examples/skills_code_review_agent/filters_cr/__init__.py @@ -0,0 +1,6 @@ +# 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 Apache-2.0. +"""Code review tool filters package.""" diff --git a/examples/skills_code_review_agent/filters_cr/governance_filter.py b/examples/skills_code_review_agent/filters_cr/governance_filter.py new file mode 100644 index 00000000..d6da9745 --- /dev/null +++ b/examples/skills_code_review_agent/filters_cr/governance_filter.py @@ -0,0 +1,38 @@ +# 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 Apache-2.0. +"""Tool-level filter guarding LLM-initiated skill_run calls.""" +from trpc_agent_sdk.abc import FilterType +from trpc_agent_sdk.filter import BaseFilter + +from review.governance import GovernanceEngine + + +class GovernanceToolFilter(BaseFilter): + """Blocks skill_run commands the GovernanceEngine does not allow.""" + + def __init__(self, engine: GovernanceEngine, on_event=None): + super().__init__() + self._type = FilterType.TOOL + self._name = "cr_governance" + self._engine = engine + self._on_event = on_event + + async def _before(self, ctx, req, rsp): + command = "" + if isinstance(req, dict): + command = str(req.get("command", "") or "") + if not command: + return + decision = self._engine.check_command(command) + if self._on_event is not None: + self._on_event(decision) + if decision.decision != "allow": + rsp.rsp = { + "error": f"blocked by governance filter ({decision.rule})", + "decision": decision.decision, + "reason": decision.reason, + } + rsp.is_continue = False diff --git a/examples/skills_code_review_agent/fixtures/async_leak.diff b/examples/skills_code_review_agent/fixtures/async_leak.diff new file mode 100644 index 00000000..21889f87 --- /dev/null +++ b/examples/skills_code_review_agent/fixtures/async_leak.diff @@ -0,0 +1,14 @@ +--- a/app/worker.py ++++ b/app/worker.py +@@ -5,6 +5,9 @@ async def start(): + config = load_config() ++ session = aiohttp.ClientSession() ++ asyncio.create_task(poll_updates()) ++ f = open("data.txt") + return config +--- a/tests/test_worker.py ++++ b/tests/test_worker.py +@@ -1,2 +1,3 @@ + def test_start(): ++ assert True + pass diff --git a/examples/skills_code_review_agent/fixtures/clean.diff b/examples/skills_code_review_agent/fixtures/clean.diff new file mode 100644 index 00000000..f53fc731 --- /dev/null +++ b/examples/skills_code_review_agent/fixtures/clean.diff @@ -0,0 +1,14 @@ +--- a/app/util.py ++++ b/app/util.py +@@ -1,4 +1,6 @@ + def add(a, b): ++ # add two numbers and return the result ++ # used by the billing module + return a + b +--- a/tests/test_util.py ++++ b/tests/test_util.py +@@ -1,3 +1,4 @@ + from app.util import add ++ + def test_add(): + assert add(1, 2) == 3 diff --git a/examples/skills_code_review_agent/fixtures/db_lifecycle.diff b/examples/skills_code_review_agent/fixtures/db_lifecycle.diff new file mode 100644 index 00000000..d2e319fe --- /dev/null +++ b/examples/skills_code_review_agent/fixtures/db_lifecycle.diff @@ -0,0 +1,15 @@ +--- a/app/repository.py ++++ b/app/repository.py +@@ -8,6 +8,10 @@ def reset_balances(): + logger.info("resetting balances") ++ conn = sqlite3.connect("app.db") ++ cur = conn.cursor() ++ cur.execute("UPDATE accounts SET balance = 0") ++ conn.commit() + return True +--- a/tests/test_repository.py ++++ b/tests/test_repository.py +@@ -1,2 +1,3 @@ + def test_reset(): ++ assert True + pass diff --git a/examples/skills_code_review_agent/fixtures/duplicate_finding.diff b/examples/skills_code_review_agent/fixtures/duplicate_finding.diff new file mode 100644 index 00000000..502bd22f --- /dev/null +++ b/examples/skills_code_review_agent/fixtures/duplicate_finding.diff @@ -0,0 +1,12 @@ +--- a/app/dynamic.py ++++ b/app/dynamic.py +@@ -3,5 +3,6 @@ def run_snippet(snippet, use_eval): + logger.debug("running snippet") ++ result = eval(snippet) if use_eval else exec(snippet) + return result +--- a/tests/test_dynamic.py ++++ b/tests/test_dynamic.py +@@ -1,2 +1,3 @@ + def test_run(): ++ assert True + pass diff --git a/examples/skills_code_review_agent/fixtures/missing_test.diff b/examples/skills_code_review_agent/fixtures/missing_test.diff new file mode 100644 index 00000000..f93f6963 --- /dev/null +++ b/examples/skills_code_review_agent/fixtures/missing_test.diff @@ -0,0 +1,8 @@ +--- a/app/service.py ++++ b/app/service.py +@@ -5,6 +5,9 @@ def process(order): + validate(order) ++ if order.total < 0: ++ raise ValueError("negative total") ++ order.status = "processed" + return order diff --git a/examples/skills_code_review_agent/fixtures/sandbox_failure.diff b/examples/skills_code_review_agent/fixtures/sandbox_failure.diff new file mode 100644 index 00000000..153f7803 --- /dev/null +++ b/examples/skills_code_review_agent/fixtures/sandbox_failure.diff @@ -0,0 +1,12 @@ +--- a/app/misc.py ++++ b/app/misc.py +@@ -1,3 +1,4 @@ + def greet(name): ++ # trivial change used by the sandbox-failure scenario + return "hello " + name +--- a/tests/test_misc.py ++++ b/tests/test_misc.py +@@ -1,2 +1,3 @@ + def test_greet(): ++ assert True + pass diff --git a/examples/skills_code_review_agent/fixtures/secret_leak.diff b/examples/skills_code_review_agent/fixtures/secret_leak.diff new file mode 100644 index 00000000..af9cd3bc --- /dev/null +++ b/examples/skills_code_review_agent/fixtures/secret_leak.diff @@ -0,0 +1,14 @@ +--- a/app/settings.py ++++ b/app/settings.py +@@ -1,4 +1,7 @@ + APP_NAME = "demo" ++API_KEY = "sk-fakefakefakefakefakefake123456" ++DB_PASSWORD = "prod-db-pass-2026" ++aws_key = "AKIA0123456789ABCDEF" + DEBUG = False +--- a/tests/test_settings.py ++++ b/tests/test_settings.py +@@ -1,2 +1,3 @@ + def test_settings(): ++ assert True + pass diff --git a/examples/skills_code_review_agent/fixtures/security_eval.diff b/examples/skills_code_review_agent/fixtures/security_eval.diff new file mode 100644 index 00000000..0eeebd21 --- /dev/null +++ b/examples/skills_code_review_agent/fixtures/security_eval.diff @@ -0,0 +1,14 @@ +--- a/app/handler.py ++++ b/app/handler.py +@@ -10,6 +10,9 @@ def handle(request): + data = request.get("data", "") ++ result = eval(data) ++ subprocess.run(cmd, shell=True) ++ row = cursor.execute(f"SELECT * FROM users WHERE name = '{name}'") + return result +--- a/tests/test_handler.py ++++ b/tests/test_handler.py +@@ -1,2 +1,3 @@ + def test_handle(): ++ assert True + pass diff --git a/examples/skills_code_review_agent/review/__init__.py b/examples/skills_code_review_agent/review/__init__.py new file mode 100644 index 00000000..3e5d5e04 --- /dev/null +++ b/examples/skills_code_review_agent/review/__init__.py @@ -0,0 +1,6 @@ +# 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 Apache-2.0. +"""Code review pipeline package.""" diff --git a/examples/skills_code_review_agent/review/diff_input.py b/examples/skills_code_review_agent/review/diff_input.py new file mode 100644 index 00000000..d861213b --- /dev/null +++ b/examples/skills_code_review_agent/review/diff_input.py @@ -0,0 +1,25 @@ +# 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 Apache-2.0. +"""Input resolution: --diff-file, --repo-path or a named fixture.""" +import subprocess +from pathlib import Path + +FIXTURES_DIR = Path(__file__).resolve().parents[1] / "fixtures" + + +def load_diff(diff_file=None, repo_path=None, fixture=None): + """Return (diff_text, input_type, input_ref). Exactly one source required.""" + sources = [s for s in (diff_file, repo_path, fixture) if s] + if len(sources) != 1: + raise ValueError("provide exactly one of --diff-file, --repo-path, --fixture") + if diff_file: + return Path(diff_file).read_text(encoding="utf-8"), "diff_file", str(diff_file) + if repo_path: + out = subprocess.run(["git", "-C", str(repo_path), "diff", "HEAD"], + capture_output=True, text=True, check=True) + return out.stdout, "repo_path", str(repo_path) + name = fixture if str(fixture).endswith(".diff") else f"{fixture}.diff" + return (FIXTURES_DIR / name).read_text(encoding="utf-8"), "fixture", name diff --git a/examples/skills_code_review_agent/review/findings.py b/examples/skills_code_review_agent/review/findings.py new file mode 100644 index 00000000..2f8050e8 --- /dev/null +++ b/examples/skills_code_review_agent/review/findings.py @@ -0,0 +1,68 @@ +# 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 Apache-2.0. +"""Finding model plus dedup and confidence-gating helpers.""" +from pydantic import BaseModel + +SEVERITY_ORDER = {"critical": 4, "high": 3, "medium": 2, "low": 1, "info": 0} +CONFIDENCE_THRESHOLD = 0.6 + + +class Finding(BaseModel): + """One structured review finding.""" + + severity: str + category: str + file: str + line: int + title: str + evidence: str = "" + recommendation: str = "" + confidence: float = 1.0 + source: str = "static" + + @property + def dedup_key(self) -> str: + return f"{self.file}:{self.line}:{self.category}" + + +def _merge(winner: Finding, loser: Finding) -> Finding: + update = {} + if SEVERITY_ORDER.get(loser.severity, 0) > SEVERITY_ORDER.get(winner.severity, 0): + update["severity"] = loser.severity + if loser.confidence > winner.confidence: + update["confidence"] = loser.confidence + if loser.source != winner.source: + update["source"] = "static+llm" + return winner.model_copy(update=update) if update else winner + + +def dedupe(findings: list[Finding]) -> tuple[list[Finding], list[Finding]]: + """Collapse findings sharing (file, line, category). Returns (kept, dropped).""" + kept: dict[str, Finding] = {} + dropped = [] + for f in findings: + cur = kept.get(f.dedup_key) + if cur is None: + kept[f.dedup_key] = f + else: + kept[f.dedup_key] = _merge(cur, f) + dropped.append(f) + return list(kept.values()), dropped + + +def gate(findings: list[Finding], threshold: float = CONFIDENCE_THRESHOLD) -> tuple[list[Finding], list[Finding]]: + """Split into (reported, needs_human_review) by confidence.""" + reported = [f for f in findings if f.confidence >= threshold] + needs = [f for f in findings if f.confidence < threshold] + return reported, needs + + +def severity_distribution(findings: list[Finding]) -> dict[str, int]: + """Count findings per severity.""" + dist: dict[str, int] = {} + for f in findings: + dist[f.severity] = dist.get(f.severity, 0) + 1 + return dist diff --git a/examples/skills_code_review_agent/review/governance.py b/examples/skills_code_review_agent/review/governance.py new file mode 100644 index 00000000..36d61e0e --- /dev/null +++ b/examples/skills_code_review_agent/review/governance.py @@ -0,0 +1,87 @@ +# 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 Apache-2.0. +"""Governance policy: script allowlist, forbidden paths, network policy, +risk heuristics and execution budget.""" +import posixpath +from dataclasses import dataclass, field + +DEFAULT_ALLOWED_SCRIPTS = ( + "parse_diff.py", + "check_security.py", + "check_async_leak.py", + "check_db_lifecycle.py", + "check_tests_missing.py", + "check_secrets.py", +) + +NETWORK_TOOLS = frozenset({"curl", "wget", "pip", "pip3", "ssh", "scp", "nc", "git", "apt", "apt-get"}) +RISK_TOKENS = frozenset({"sudo", "docker", "chmod", "chown", "mount", "rm", "mkfs"}) +PYTHON_EXECUTABLES = frozenset({"python", "python3"}) + + +@dataclass +class GovernanceDecision: + """Outcome of one governance check.""" + + target: str + decision: str # allow | deny | needs_human_review + rule: str = "" + reason: str = "" + + +@dataclass +class GovernanceEngine: + """Stateful policy engine; deny/needs_human_review must never reach the sandbox.""" + + allowed_scripts: tuple = DEFAULT_ALLOWED_SCRIPTS + max_runs: int = 20 + max_sandbox_seconds: float = 300.0 + _runs: int = field(default=0, init=False) + _elapsed: float = field(default=0.0, init=False) + + def record_run(self, duration_s: float) -> None: + self._runs += 1 + self._elapsed += max(0.0, duration_s) + + def _check_budget(self, target: str): + if self._runs >= self.max_runs or self._elapsed >= self.max_sandbox_seconds: + return GovernanceDecision(target, "deny", "budget_exceeded", + f"budget: {self._runs} runs / {self._elapsed:.1f}s used") + return None + + @staticmethod + def _check_paths(target: str, argv): + for arg in argv: + if arg.startswith("/") or arg.startswith("~") or ".." in arg: + return GovernanceDecision(target, "deny", "forbidden_path", + f"path escapes workspace: {arg!r}") + return None + + def check_script(self, script: str, argv) -> GovernanceDecision: + target = f"{script} {' '.join(argv)}".strip() + blocked = self._check_budget(target) or self._check_paths(target, argv) + if blocked: + return blocked + if posixpath.basename(script) not in self.allowed_scripts: + return GovernanceDecision(target, "deny", "script_allowlist", + f"script {script!r} is not allowlisted") + return GovernanceDecision(target, "allow") + + def check_command(self, command: str) -> GovernanceDecision: + tokens = command.split() + if not tokens: + return GovernanceDecision(command, "deny", "empty_command", "empty command") + head = posixpath.basename(tokens[0]) + if head in NETWORK_TOOLS or any(posixpath.basename(t) in NETWORK_TOOLS for t in tokens): + return GovernanceDecision(command, "deny", "network_policy", + "non-whitelisted network access") + if head in RISK_TOKENS or any(posixpath.basename(t) in RISK_TOKENS for t in tokens): + return GovernanceDecision(command, "needs_human_review", "risk_command", + "high-risk command requires human review") + if head in PYTHON_EXECUTABLES and len(tokens) >= 2: + return self.check_script(tokens[1], tokens[2:]) + return GovernanceDecision(command, "needs_human_review", "unknown_command", + f"executable {head!r} is not recognized") diff --git a/examples/skills_code_review_agent/review/llm_review.py b/examples/skills_code_review_agent/review/llm_review.py new file mode 100644 index 00000000..a8ba7ef1 --- /dev/null +++ b/examples/skills_code_review_agent/review/llm_review.py @@ -0,0 +1,78 @@ +# 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 Apache-2.0. +"""LLM enrichment step: run the agent once over the diff + static findings.""" +import json +import re +import uuid + +from trpc_agent_sdk.runners import Runner +from trpc_agent_sdk.sessions import InMemorySessionService +from trpc_agent_sdk.types import Content, Part + +from agent.prompts import REVIEW_REQUEST_TEMPLATE + +from .findings import Finding +from .redaction import redact_text + +_FENCE_RE = re.compile(r"```(?:json)?\s*(\{.*\})\s*```", re.DOTALL) + + +def parse_llm_output(text: str) -> tuple[list[dict], str]: + """Extract (findings_dicts, summary) from model text; ([], "") on failure.""" + candidates = [text.strip()] + fence = _FENCE_RE.search(text) + if fence: + candidates.insert(0, fence.group(1)) + for candidate in candidates: + try: + payload = json.loads(candidate) + except (json.JSONDecodeError, ValueError): + continue + if isinstance(payload, dict): + return list(payload.get("findings") or []), str(payload.get("summary") or "") + return [], "" + + +async def run_llm_review(agent, diff_text: str, static_findings) -> tuple[list[Finding], str, list[str]]: + """Run one review turn. Returns (llm_findings, summary, warnings).""" + warnings: list[str] = [] + runner = Runner(app_name="code_review_agent", agent=agent, + session_service=InMemorySessionService()) + prompt = REVIEW_REQUEST_TEMPLATE.format( + findings_json=json.dumps([f.model_dump() for f in static_findings]), + diff=redact_text(diff_text)) + message = Content(role="user", parts=[Part.from_text(text=prompt)]) + final_text = "" + try: + async for event in runner.run_async(user_id="cr_user", + session_id=uuid.uuid4().hex, + new_message=message): + if event.partial or not event.content or not event.content.parts: + continue + for part in event.content.parts: + if getattr(part, "text", None) and not getattr(part, "thought", None): + final_text += part.text + finally: + await runner.close() + raw_findings, summary = parse_llm_output(final_text) + if not summary and final_text: + warnings.append("llm output was not valid JSON; ignored") + findings = [] + for raw in raw_findings: + try: + findings.append(Finding( + severity=str(raw.get("severity", "info")), + category=str(raw.get("category", "security")), + file=str(raw.get("file", "")), + line=int(raw.get("line", 0)), + title=str(raw.get("title", "")), + evidence=str(raw.get("evidence", "")), + recommendation=str(raw.get("recommendation", "")), + confidence=float(raw.get("confidence", 0.5)), + source="llm")) + except (TypeError, ValueError): + warnings.append(f"skipped malformed llm finding: {raw!r}") + return findings, summary, warnings diff --git a/examples/skills_code_review_agent/review/pipeline.py b/examples/skills_code_review_agent/review/pipeline.py new file mode 100644 index 00000000..c8ad290e --- /dev/null +++ b/examples/skills_code_review_agent/review/pipeline.py @@ -0,0 +1,180 @@ +# 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 Apache-2.0. +"""Deterministic review pipeline: parse -> govern -> sandbox -> merge -> persist -> report.""" +import json +import time +from dataclasses import dataclass, field +from pathlib import Path + +from trpc_agent_sdk.skills import create_default_skill_repository + +from agent.agent import create_review_agent +from filters_cr.governance_filter import GovernanceToolFilter +from storage.store import ReviewStore + +from .findings import Finding, dedupe, gate, severity_distribution +from .governance import DEFAULT_ALLOWED_SCRIPTS, GovernanceEngine +from .llm_review import run_llm_review +from .redaction import redact_text +from .report import build_report, render_markdown, write_reports +from .sandbox import DIFF_WS_PATH, SandboxSession, create_runtime + +DEFAULT_SKILL_ROOT = str(Path(__file__).resolve().parents[1] / "skills" / "code-review") + +CHECKERS = [ + ("check_security.py", "security"), + ("check_async_leak.py", "async_resource_leak"), + ("check_db_lifecycle.py", "db_lifecycle"), + ("check_tests_missing.py", "missing_test"), + ("check_secrets.py", "secret_leak"), +] + + +@dataclass +class ReviewOptions: + diff_text: str + input_type: str + input_ref: str + runtime: str = "local" + dry_run: bool = True + db_url: str = "sqlite:///code_review.db" + output_dir: str = "out" + skill_root: str = DEFAULT_SKILL_ROOT + checkers: list = field(default_factory=lambda: list(CHECKERS)) + allowed_scripts: tuple = DEFAULT_ALLOWED_SCRIPTS + timeout_sec: float = 60.0 + enable_llm: bool = True + + +@dataclass +class ReviewResult: + task_id: str + report: dict + json_path: str + md_path: str + + +def _parse_script_findings(stdout: str, warnings: list) -> list[Finding]: + try: + payload = json.loads(stdout) + except (json.JSONDecodeError, ValueError): + warnings.append("checker produced invalid JSON output") + return [] + findings = [] + for raw in payload.get("findings", []): + try: + findings.append(Finding(**raw)) + except (TypeError, ValueError): + warnings.append(f"skipped malformed finding: {raw!r}") + return findings + + +async def run_review(opts: ReviewOptions) -> ReviewResult: + """Run the full review. Sandbox failures degrade, never crash.""" + start = time.monotonic() + store = ReviewStore(db_url=opts.db_url) + engine = GovernanceEngine(allowed_scripts=opts.allowed_scripts) + task_id = await store.create_task(opts.input_type, opts.input_ref, + opts.runtime, opts.dry_run) + warnings: list[str] = [] + filter_events = [] + sandbox_outcomes = [] + raw_findings: list[Finding] = [] + diff_summary: dict = {} + error_distribution: dict[str, int] = {} + sandbox_ms = 0 + try: + runtime = await create_runtime(opts.runtime) + if opts.runtime == "local": + warnings.append("local runtime is a development fallback; " + "use container or cube in production") + session = SandboxSession(runtime, opts.skill_root, timeout_sec=opts.timeout_sec) + await session.open(f"cr_{task_id[:12]}") + try: + await session.put_diff(opts.diff_text) + scripts = [("parse_diff.py", "parse")] + list(opts.checkers) + for script, category in scripts: + decision = engine.check_script(script, [DIFF_WS_PATH]) + filter_events.append(decision) + await store.add_filter_event(task_id, decision.target, decision.decision, + decision.rule, decision.reason) + if decision.decision != "allow": + continue + outcome = await session.run_script(script) + engine.record_run(outcome.duration_ms / 1000.0) + sandbox_ms += outcome.duration_ms + sandbox_outcomes.append(outcome) + await store.add_sandbox_run( + task_id, script=script, category=category, status=outcome.status, + exit_code=outcome.exit_code, duration_ms=outcome.duration_ms, + timed_out=outcome.timed_out, stdout_summary=outcome.stdout[:4096], + stderr_summary=outcome.stderr[:4096], error_type=outcome.error_type) + if outcome.status != "ok": + key = outcome.error_type or outcome.status + error_distribution[key] = error_distribution.get(key, 0) + 1 + warnings.append(f"{script} did not complete ({outcome.status}); " + "coverage degraded") + continue + if script == "parse_diff.py": + try: + diff_summary = json.loads(outcome.stdout).get("summary", {}) + except (json.JSONDecodeError, ValueError): + warnings.append("parse_diff.py produced invalid JSON") + else: + raw_findings.extend(_parse_script_findings(outcome.stdout, warnings)) + finally: + await session.close() + + llm_findings: list[Finding] = [] + llm_summary = "" + llm_calls = 0 + if opts.enable_llm: + repository = create_default_skill_repository( + str(Path(opts.skill_root).parent), workspace_runtime=runtime) + gov_filter = GovernanceToolFilter(engine, on_event=filter_events.append) + agent = create_review_agent(repository, opts.dry_run, [gov_filter]) + llm_findings, llm_summary, llm_warnings = await run_llm_review( + agent, opts.diff_text, raw_findings) + warnings.extend(llm_warnings) + llm_calls = 1 + + kept, dropped = dedupe(raw_findings + llm_findings) + reported, needs_review = gate(kept) + await store.add_findings(task_id, reported, status="reported") + await store.add_findings(task_id, needs_review, status="needs_human_review") + await store.add_findings(task_id, dropped, status="deduped") + + intercepts = sum(1 for e in filter_events if e.decision != "allow") + metrics = { + "total_duration_ms": int((time.monotonic() - start) * 1000), + "sandbox_duration_ms": sandbox_ms, + "tool_calls": len(sandbox_outcomes) + llm_calls, + "intercepts": intercepts, + "findings_total": len(reported), + "severity_distribution": severity_distribution(reported), + "error_distribution": error_distribution, + } + await store.add_metrics(task_id, metrics) + + report = build_report( + task_id=task_id, input_ref=opts.input_ref, runtime=opts.runtime, + dry_run=opts.dry_run, diff_summary=diff_summary, reported=reported, + needs_review=needs_review, deduped_count=len(dropped), + filter_events=filter_events, sandbox_outcomes=sandbox_outcomes, + metrics=metrics, llm_summary=llm_summary, warnings=warnings) + report = json.loads(redact_text(json.dumps(report, indent=2, ensure_ascii=False, default=str))) + json_path, md_path = write_reports(report, opts.output_dir) + md_text = render_markdown(report) + await store.add_report(task_id, report, md_text) + await store.update_task(task_id, status="completed", + diff_summary=diff_summary, finished=True) + return ReviewResult(task_id=task_id, report=report, + json_path=json_path, md_path=md_path) + except Exception: + await store.update_task(task_id, status="failed", finished=True) + raise + finally: + await store.close() diff --git a/examples/skills_code_review_agent/review/redaction.py b/examples/skills_code_review_agent/review/redaction.py new file mode 100644 index 00000000..0a900992 --- /dev/null +++ b/examples/skills_code_review_agent/review/redaction.py @@ -0,0 +1,28 @@ +# 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 Apache-2.0. +"""Host-side redaction. Imports the skill's secret_patterns module so the +sandbox checker and host redaction always share one pattern list.""" +import importlib.util +from pathlib import Path + +_PATTERNS_PATH = (Path(__file__).resolve().parents[1] + / "skills" / "code-review" / "scripts" / "secret_patterns.py") + +_spec = importlib.util.spec_from_file_location("cr_secret_patterns", _PATTERNS_PATH) +_mod = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_mod) + + +def redact_text(text): + """Redact all secrets in *text* using the shared skill patterns.""" + if not text: + return text + return _mod.redact(text) + + +def contains_secret(text): + """Return True when *text* contains at least one secret match.""" + return bool(_mod.find_secrets(text or "")) diff --git a/examples/skills_code_review_agent/review/report.py b/examples/skills_code_review_agent/review/report.py new file mode 100644 index 00000000..8fac3ca9 --- /dev/null +++ b/examples/skills_code_review_agent/review/report.py @@ -0,0 +1,121 @@ +# 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 Apache-2.0. +"""Report assembly and rendering.""" +import json +import os +from datetime import datetime + +from .findings import severity_distribution +from .redaction import redact_text + +_BLOCKING = {"critical", "high"} + + +def _conclusion(reported, filter_events): + if any(f.severity in _BLOCKING for f in reported): + return "blocked" + intercepted = any(e.decision != "allow" for e in filter_events) + if reported or intercepted: + return "needs_attention" + return "pass" + + +def build_report(task_id, input_ref, runtime, dry_run, diff_summary, reported, + needs_review, deduped_count, filter_events, sandbox_outcomes, + metrics, llm_summary, warnings) -> dict: + """Assemble the full report dict (issue acceptance criterion #8).""" + return { + "task_id": task_id, + "generated_at": datetime.now().isoformat(timespec="seconds"), + "input": {"ref": input_ref, "runtime": runtime, "dry_run": dry_run, + "diff_summary": diff_summary}, + "conclusion": _conclusion(reported, filter_events), + "summary": { + "total_findings": len(reported), + "by_severity": severity_distribution(reported), + "needs_human_review_count": len(needs_review), + "deduplicated": deduped_count, + "intercepts": sum(1 for e in filter_events if e.decision != "allow"), + }, + "findings": [f.model_dump() for f in reported], + "needs_human_review": [f.model_dump() for f in needs_review], + "filter_intercepts": [ + {"target": e.target, "decision": e.decision, "rule": e.rule, "reason": e.reason} + for e in filter_events if e.decision != "allow"], + "sandbox_runs": [ + {"script": o.script, "status": o.status, "exit_code": o.exit_code, + "duration_ms": o.duration_ms, "timed_out": o.timed_out, + "error_type": o.error_type} for o in sandbox_outcomes], + "metrics": metrics, + "llm_summary": llm_summary, + "warnings": list(warnings), + } + + +def _md_findings_table(findings): + if not findings: + return "_none_\n" + lines = ["| Severity | Category | File | Line | Title | Confidence | Source |", + "|---|---|---|---|---|---|---|"] + for f in findings: + lines.append(f"| {f['severity']} | {f['category']} | {f['file']} | {f['line']} " + f"| {f['title']} | {f['confidence']} | {f['source']} |") + return "\n".join(lines) + "\n" + + +def render_markdown(report: dict) -> str: + """Render the human-readable markdown report.""" + s = report["summary"] + parts = [ + "# Code Review Report", + f"- Task: `{report['task_id']}`", + f"- Conclusion: **{report['conclusion']}**", + f"- Input: {report['input']['ref']} (runtime={report['input']['runtime']}, " + f"dry_run={report['input']['dry_run']})", + f"- Findings: {s['total_findings']} (by severity: {s['by_severity']}), " + f"needs human review: {s['needs_human_review_count']}, " + f"deduplicated: {s['deduplicated']}, intercepts: {s['intercepts']}", + "", + "## Findings", + _md_findings_table(report["findings"]), + "### Recommendations", + "\n".join(f"- `{f['file']}:{f['line']}` {f['recommendation']}" + for f in report["findings"]) or "_none_", + "", + "## Needs Human Review", + _md_findings_table(report["needs_human_review"]), + "## Filter Intercepts", + "\n".join(f"- [{e['decision']}] `{e['target']}` ({e['rule']}): {e['reason']}" + for e in report["filter_intercepts"]) or "_none_", + "", + "## Sandbox Runs", + "\n".join(f"- `{o['script']}`: {o['status']} (exit={o['exit_code']}, " + f"{o['duration_ms']}ms, timed_out={o['timed_out']})" + for o in report["sandbox_runs"]) or "_none_", + "", + "## Metrics", + "```json", + json.dumps(report["metrics"], indent=2), + "```", + "", + f"## LLM Summary\n{report['llm_summary'] or '_none_'}", + "", + "## Warnings", + "\n".join(f"- {w}" for w in report["warnings"]) or "_none_", + ] + return "\n".join(parts) + "\n" + + +def write_reports(report: dict, output_dir: str): + """Write review_report.json / review_report.md (final redaction pass).""" + os.makedirs(output_dir, exist_ok=True) + json_path = os.path.join(output_dir, "review_report.json") + md_path = os.path.join(output_dir, "review_report.md") + with open(json_path, "w", encoding="utf-8") as fh: + fh.write(redact_text(json.dumps(report, indent=2, ensure_ascii=False, default=str))) + with open(md_path, "w", encoding="utf-8") as fh: + fh.write(redact_text(render_markdown(report))) + return json_path, md_path diff --git a/examples/skills_code_review_agent/review/sandbox.py b/examples/skills_code_review_agent/review/sandbox.py new file mode 100644 index 00000000..5607ce8c --- /dev/null +++ b/examples/skills_code_review_agent/review/sandbox.py @@ -0,0 +1,150 @@ +# 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 Apache-2.0. +"""Sandbox execution of skill scripts via SDK workspace runtimes.""" +import os +import shutil +import sys +from dataclasses import dataclass + +from trpc_agent_sdk.code_executors import (BaseWorkspaceRuntime, WorkspacePutFileInfo, + WorkspaceRunProgramSpec, + WorkspaceStageOptions, + create_container_workspace_runtime, + create_local_workspace_runtime) + +SKILL_WS_DIR = "skills/code-review" +DIFF_WS_PATH = "work/inputs/changes.diff" +SANDBOX_ENV = {"PATH": "/usr/local/bin:/usr/bin:/bin", + "HOME": "/tmp", "LANG": "C.UTF-8"} + +RUNTIME_LOCAL = "local" +RUNTIME_CONTAINER = "container" +RUNTIME_CUBE = "cube" + + +async def create_runtime(kind: str): + """Create a workspace runtime. Container is the production default; + local is a development-only fallback; cube needs env credentials.""" + if kind == RUNTIME_LOCAL: + return create_local_workspace_runtime() + if kind == RUNTIME_CONTAINER: + return create_container_workspace_runtime() + if kind == RUNTIME_CUBE: + from trpc_agent_sdk.code_executors.cube import (CubeClientConfig, + CubeWorkspaceRuntimeConfig, + create_cube_sandbox_client, + create_cube_workspace_runtime) + if not os.getenv("CUBE_API_KEY") and not os.getenv("E2B_API_KEY"): + raise ValueError("cube runtime requires CUBE_API_KEY/E2B_API_KEY; " + "see examples/skills_with_cube") + cfg = CubeClientConfig( + execute_timeout=float(os.getenv("CUBE_EXECUTE_TIMEOUT", "60")), + idle_timeout=int(os.getenv("CUBE_IDLE_TIMEOUT", "600")), + auto_recover=True) + client = await create_cube_sandbox_client(cfg) + return create_cube_workspace_runtime(sandbox_client=client, + execute_timeout=cfg.execute_timeout, + workspace_cfg=CubeWorkspaceRuntimeConfig()) + raise ValueError(f"unknown runtime {kind!r}; expected local|container|cube") + + +@dataclass +class SandboxRunOutcome: + """Result of one sandboxed script execution.""" + + script: str + status: str # ok | failed | timeout | error + exit_code: int + duration_ms: int + timed_out: bool + stdout: str + stderr: str + error_type: str = "" + truncated: bool = False + + +class SandboxSession: + """One workspace: staged code-review skill + staged diff + script runs.""" + + def __init__(self, runtime: BaseWorkspaceRuntime, skill_root: str, timeout_sec: float = 60.0, + max_output_bytes: int = 262144): + self._runtime = runtime + self._skill_root = skill_root + self._timeout = timeout_sec + self._max_output = max_output_bytes + self._ws = None + self._exec_id = "" + + async def open(self, exec_id: str) -> None: + if self._ws is not None: + raise RuntimeError("SandboxSession is already open; call close() first") + self._exec_id = exec_id + manager = self._runtime.manager(None) + self._ws = await manager.create_workspace(exec_id, None) + fs = self._runtime.fs(None) + await fs.stage_directory(self._ws, self._skill_root, SKILL_WS_DIR, + WorkspaceStageOptions(mode="copy"), None) + + async def put_diff(self, diff_text: str) -> None: + fs = self._runtime.fs(None) + await fs.put_files(self._ws, [WorkspacePutFileInfo( + path=DIFF_WS_PATH, content=diff_text.encode("utf-8"), mode=0o644)], None) + + def _truncate(self, text: str): + if len(text.encode("utf-8", errors="replace")) <= self._max_output: + return text, False + return text.encode("utf-8", errors="replace")[:self._max_output].decode( + "utf-8", errors="replace"), True + + def _effective_env(self) -> dict: + """Whitelist stays PATH/HOME/LANG only; host python3 dir is appended as a + dev fallback for hosts without /usr/bin/python3 (e.g. Nix); harmless + nonexistent entry inside containers.""" + env = dict(SANDBOX_ENV) + python3_dir = os.path.dirname(shutil.which("python3") or sys.executable) + if python3_dir and python3_dir not in env["PATH"].split(":"): + env["PATH"] = f"{env['PATH']}:{python3_dir}" + return env + + async def run_script(self, script_name: str, + args: tuple = (DIFF_WS_PATH,)) -> SandboxRunOutcome: + """Run one skill script under `env -i` (environment whitelist).""" + env = self._effective_env() + env_args = [f"{k}={v}" for k, v in env.items()] + argv = ["-i", *env_args, "python3", + f"{SKILL_WS_DIR}/scripts/{script_name}", *args] + spec = WorkspaceRunProgramSpec(cmd="env", args=argv, env={}, cwd=".", + timeout=self._timeout) + try: + ret = await self._runtime.runner(None).run_program(self._ws, spec, None) + except Exception as ex: # noqa: BLE001 - sandbox failure must not crash the review + return SandboxRunOutcome(script=script_name, status="error", exit_code=-1, + duration_ms=0, timed_out=False, stdout="", + stderr=str(ex)[:1024], error_type=type(ex).__name__) + stdout, t1 = self._truncate(ret.stdout or "") + stderr, t2 = self._truncate(ret.stderr or "") + if ret.timed_out: + status = "timeout" + elif ret.exit_code == 0: + status = "ok" + else: + status = "failed" + return SandboxRunOutcome(script=script_name, status=status, + exit_code=ret.exit_code, + duration_ms=int(ret.duration * 1000), + timed_out=ret.timed_out, stdout=stdout, + stderr=stderr, + error_type="timeout" if ret.timed_out else "", + truncated=t1 or t2) + + async def close(self) -> None: + if self._ws is None: + return + try: + await self._runtime.manager(None).cleanup(self._exec_id, None) + except Exception: # noqa: BLE001 - best-effort cleanup + pass + self._ws = None 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 00000000..b06cfecd --- /dev/null +++ b/examples/skills_code_review_agent/run_agent.py @@ -0,0 +1,82 @@ +#!/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 Apache-2.0. +"""Automated code review agent CLI. + +Usage: + python run_agent.py review --fixture security_eval --runtime local --dry-run + python run_agent.py review --diff-file change.diff --runtime container + python run_agent.py show --task-id +""" +import argparse +import asyncio +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from agent.config import is_dry_run # noqa: E402 +from review.diff_input import load_diff # noqa: E402 +from review.pipeline import ReviewOptions, run_review # noqa: E402 +from storage.store import ReviewStore # noqa: E402 + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Automated code review agent") + sub = parser.add_subparsers(dest="command", required=True) + + review = sub.add_parser("review", help="run a code review") + review.add_argument("--diff-file", help="path to a unified diff / PR patch") + review.add_argument("--repo-path", help="git repo; reviews `git diff HEAD`") + review.add_argument("--fixture", help="bundled fixture name (e.g. security_eval)") + review.add_argument("--runtime", default="container", + choices=["local", "container", "cube"], + help="sandbox runtime (default: container; local is dev-only)") + review.add_argument("--dry-run", action="store_true", + help="use the deterministic fake model (no API key needed)") + review.add_argument("--db-url", default="sqlite:///code_review.db") + review.add_argument("--output-dir", default="out") + review.add_argument("--no-llm", action="store_true", help="skip the LLM step") + + show = sub.add_parser("show", help="print a stored review task by id") + show.add_argument("--task-id", required=True) + show.add_argument("--db-url", default="sqlite:///code_review.db") + return parser + + +async def main(argv=None) -> int: + args = _build_parser().parse_args(argv) + if args.command == "review": + diff_text, input_type, input_ref = load_diff( + diff_file=args.diff_file, repo_path=args.repo_path, fixture=args.fixture) + dry_run = is_dry_run(args.dry_run) + if dry_run and not args.dry_run: + print("no TRPC_AGENT_API_KEY configured; falling back to --dry-run mode") + result = await run_review(ReviewOptions( + diff_text=diff_text, input_type=input_type, input_ref=input_ref, + runtime=args.runtime, dry_run=dry_run, db_url=args.db_url, + output_dir=args.output_dir, enable_llm=not args.no_llm)) + print(f"task_id={result.task_id}") + print(f"conclusion={result.report['conclusion']}") + print(f"report_json={result.json_path}") + print(f"report_md={result.md_path}") + return 0 + + store = ReviewStore(db_url=args.db_url) + try: + bundle = await store.get_task_bundle(args.task_id) + finally: + await store.close() + if bundle["task"] is None: + print(f"task {args.task_id!r} not found", file=sys.stderr) + return 1 + print(json.dumps(bundle, indent=2, ensure_ascii=False, default=str)) + return 0 + + +if __name__ == "__main__": + sys.exit(asyncio.run(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 00000000..d1a659ad --- /dev/null +++ b/examples/skills_code_review_agent/sample_output/review_report.json @@ -0,0 +1,130 @@ +{ + "task_id": "03ed666b0d2245f4a68fb83e4987c3a1", + "generated_at": "2026-07-16T19:46:47", + "input": { + "ref": "security_eval.diff", + "runtime": "local", + "dry_run": true, + "diff_summary": { + "files_changed": 2, + "additions": 4, + "deletions": 0, + "files": [ + "app/handler.py", + "tests/test_handler.py" + ] + } + }, + "conclusion": "blocked", + "summary": { + "total_findings": 3, + "by_severity": { + "high": 3 + }, + "needs_human_review_count": 0, + "deduplicated": 0, + "intercepts": 0 + }, + "findings": [ + { + "severity": "high", + "category": "security", + "file": "app/handler.py", + "line": 11, + "title": "Use of eval() on dynamic data", + "evidence": "result = eval(data)", + "recommendation": "Avoid eval(); use ast.literal_eval or explicit parsing.", + "confidence": 0.9, + "source": "static" + }, + { + "severity": "high", + "category": "security", + "file": "app/handler.py", + "line": 12, + "title": "subprocess with shell=True", + "evidence": "subprocess.run(cmd, shell=True)", + "recommendation": "Pass an argument list and shell=False to avoid shell injection.", + "confidence": 0.85, + "source": "static" + }, + { + "severity": "high", + "category": "security", + "file": "app/handler.py", + "line": 13, + "title": "SQL built with f-string (possible injection)", + "evidence": "row = cursor.execute(f\"SELECT * FROM users WHERE name = '{name}'\")", + "recommendation": "Use parameterized queries (placeholders) instead of string interpolation.", + "confidence": 0.85, + "source": "static" + } + ], + "needs_human_review": [], + "filter_intercepts": [], + "sandbox_runs": [ + { + "script": "parse_diff.py", + "status": "ok", + "exit_code": 0, + "duration_ms": 23, + "timed_out": false, + "error_type": "" + }, + { + "script": "check_security.py", + "status": "ok", + "exit_code": 0, + "duration_ms": 25, + "timed_out": false, + "error_type": "" + }, + { + "script": "check_async_leak.py", + "status": "ok", + "exit_code": 0, + "duration_ms": 26, + "timed_out": false, + "error_type": "" + }, + { + "script": "check_db_lifecycle.py", + "status": "ok", + "exit_code": 0, + "duration_ms": 23, + "timed_out": false, + "error_type": "" + }, + { + "script": "check_tests_missing.py", + "status": "ok", + "exit_code": 0, + "duration_ms": 21, + "timed_out": false, + "error_type": "" + }, + { + "script": "check_secrets.py", + "status": "ok", + "exit_code": 0, + "duration_ms": 27, + "timed_out": false, + "error_type": "" + } + ], + "metrics": { + "total_duration_ms": 245, + "sandbox_duration_ms": 145, + "tool_calls": 7, + "intercepts": 0, + "findings_total": 3, + "severity_distribution": { + "high": 3 + }, + "error_distribution": {} + }, + "llm_summary": "Dry-run review complete. Static findings are authoritative.", + "warnings": [ + "local runtime is a development fallback; use container or cube in production" + ] +} \ No newline at end of file diff --git a/examples/skills_code_review_agent/sample_output/review_report.md b/examples/skills_code_review_agent/sample_output/review_report.md new file mode 100644 index 00000000..410864d8 --- /dev/null +++ b/examples/skills_code_review_agent/sample_output/review_report.md @@ -0,0 +1,52 @@ +# Code Review Report +- Task: `03ed666b0d2245f4a68fb83e4987c3a1` +- Conclusion: **blocked** +- Input: security_eval.diff (runtime=local, dry_run=True) +- Findings: 3 (by severity: {'high': 3}), needs human review: 0, deduplicated: 0, intercepts: 0 + +## Findings +| Severity | Category | File | Line | Title | Confidence | Source | +|---|---|---|---|---|---|---| +| high | security | app/handler.py | 11 | Use of eval() on dynamic data | 0.9 | static | +| high | security | app/handler.py | 12 | subprocess with shell=True | 0.85 | static | +| high | security | app/handler.py | 13 | SQL built with f-string (possible injection) | 0.85 | static | + +### Recommendations +- `app/handler.py:11` Avoid eval(); use ast.literal_eval or explicit parsing. +- `app/handler.py:12` Pass an argument list and shell=False to avoid shell injection. +- `app/handler.py:13` Use parameterized queries (placeholders) instead of string interpolation. + +## Needs Human Review +_none_ + +## Filter Intercepts +_none_ + +## Sandbox Runs +- `parse_diff.py`: ok (exit=0, 23ms, timed_out=False) +- `check_security.py`: ok (exit=0, 25ms, timed_out=False) +- `check_async_leak.py`: ok (exit=0, 26ms, timed_out=False) +- `check_db_lifecycle.py`: ok (exit=0, 23ms, timed_out=False) +- `check_tests_missing.py`: ok (exit=0, 21ms, timed_out=False) +- `check_secrets.py`: ok (exit=0, 27ms, timed_out=False) + +## Metrics +```json +{ + "total_duration_ms": 245, + "sandbox_duration_ms": 145, + "tool_calls": 7, + "intercepts": 0, + "findings_total": 3, + "severity_distribution": { + "high": 3 + }, + "error_distribution": {} +} +``` + +## LLM Summary +Dry-run review complete. Static findings are authoritative. + +## Warnings +- local runtime is a development fallback; use container or cube in production 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 00000000..b9011654 --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/SKILL.md @@ -0,0 +1,29 @@ +--- +name: code-review +description: Automated code review rules and scripts. Parses unified diffs and detects security risks, async/resource leaks, DB lifecycle problems, missing tests, and secret leaks. +--- +Overview + +This skill reviews a unified diff. Stage the diff at work/inputs/changes.diff, then run +the scripts below from the workspace root. Every script prints a JSON object to stdout. + +Scripts + +- scripts/parse_diff.py : prints {"summary": ..., "files": [...]} +- scripts/check_security.py : security findings (eval/exec, shell=True, pickle, yaml.load, SQL injection) +- scripts/check_async_leak.py : async/resource leak findings (unreferenced tasks, unmanaged sessions/files) +- scripts/check_db_lifecycle.py : DB connection/cursor/transaction lifecycle findings +- scripts/check_tests_missing.py : source changed without test changes +- scripts/check_secrets.py : hardcoded secrets (evidence pre-redacted) + +Rules documentation lives under references/rules/. + +Examples + +1) python3 skills/code-review/scripts/parse_diff.py work/inputs/changes.diff +2) python3 skills/code-review/scripts/check_security.py work/inputs/changes.diff + +Output contract + +Checker scripts print: {"findings": [{"severity", "category", "file", "line", +"title", "evidence", "recommendation", "confidence", "source"}]} diff --git a/examples/skills_code_review_agent/skills/code-review/references/rules/async_resource_leak.md b/examples/skills_code_review_agent/skills/code-review/references/rules/async_resource_leak.md new file mode 100644 index 00000000..1239d2be --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/references/rules/async_resource_leak.md @@ -0,0 +1,22 @@ +# Async Resource Leak Rule + +Detects resource management issues in async Python code. + +## Patterns + +| Pattern | Severity | Confidence | Guidance | +|---------|----------|------------|----------| +| `aiohttp.ClientSession()` without `async with` | high | 0.8 | Use `async with aiohttp.ClientSession() as session:` or ensure session.close() is awaited. | +| `asyncio.create_task()` without storing reference | medium | 0.7 | Store the task and await/cancel it; unreferenced tasks may be garbage collected mid-flight. | +| `open()` assigned without `with` context manager | medium | 0.7 | Use `with open(...) as f:` so the handle is always closed. | + +## Rationale + +Resources created inside async functions must be properly managed. Leaked sessions, +fire-and-forget tasks, and unclosed file handles can cause connection pool exhaustion, +memory leaks, and unpredictable behavior under load. + +## Remediation + +Wrap resources in context managers (`async with` / `with`) or ensure explicit cleanup +in `finally` blocks. diff --git a/examples/skills_code_review_agent/skills/code-review/references/rules/db_lifecycle.md b/examples/skills_code_review_agent/skills/code-review/references/rules/db_lifecycle.md new file mode 100644 index 00000000..d6ec462b --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/references/rules/db_lifecycle.md @@ -0,0 +1,22 @@ +# Database Lifecycle Rule + +Detects database connections, cursors, and transactions opened without proper lifecycle management. + +## Patterns + +| Pattern | Severity | Confidence | Guidance | +|---------|----------|------------|----------| +| `.connect()` without context manager or close() | high | 0.8 | Use `with ...connect(...) as conn:` or close the connection in a finally block. | +| `.cursor()` without context manager | low | 0.5 | Close the cursor or use a context manager. | +| `.commit()` without rollback handling | medium | 0.65 | Wrap the transaction in try/except and roll back on failure. | + +## Rationale + +Unclosed database connections leak file descriptors and connection pool slots. +Transactions committed without rollback handling can leave data in an inconsistent +state on error. + +## Remediation + +- Use context managers (`with conn:` / `with conn.cursor()`) wherever possible. +- Always wrap transactions in `try/except` and call `rollback()` on failure. diff --git a/examples/skills_code_review_agent/skills/code-review/references/rules/missing_test.md b/examples/skills_code_review_agent/skills/code-review/references/rules/missing_test.md new file mode 100644 index 00000000..08935e1b --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/references/rules/missing_test.md @@ -0,0 +1,24 @@ +# Missing Test Rule + +Detects source code changes that lack corresponding test file changes. + +## Trigger + +Fires when a diff contains added lines in `.py` source files but no changes in: +- Files under `tests/` directories +- Files whose name starts with `test_` or ends with `_test.py` + +## Findings + +| Condition | Severity | Confidence | Guidance | +|-----------|----------|------------|----------| +| Source .py changed, no test files touched | medium | 0.8 | Add or update unit tests covering the changed behavior. | + +## Rationale + +Every behavior change should include or reference a test. Without test coverage, +regressions go undetected. + +## Remediation + +Add or update unit tests that exercise the changed code paths. diff --git a/examples/skills_code_review_agent/skills/code-review/references/rules/secret_leak.md b/examples/skills_code_review_agent/skills/code-review/references/rules/secret_leak.md new file mode 100644 index 00000000..ab3c22dc --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/references/rules/secret_leak.md @@ -0,0 +1,30 @@ +# Secret Leak Rule + +Detects hardcoded secrets (API keys, passwords, AWS keys) in source code. + +## Detection + +Uses the `secret_patterns` module which checks for: +- OpenAI-style keys (`sk-*`) +- Generic API key assignments +- Password assignments +- AWS access key patterns (`AKIA*`) +- Private key headers (`-----BEGIN ... PRIVATE KEY-----`) + +## Findings + +| Condition | Severity | Confidence | Guidance | +|-----------|----------|------------|----------| +| Any recognized secret pattern in added source line | critical | 0.95 | Remove the secret, rotate it, and load it from environment variables or a secret manager. | + +## Evidence Redaction + +All evidence strings are redacted before output; raw secrets never appear in findings. +The evidence field will contain `***REDACTED-{type}` markers. + +## Remediation + +1. Remove the secret from the source immediately. +2. Rotate (revoke and reissue) the exposed credential. +3. Load secrets at runtime from environment variables, a `.env` file (never committed), + or a secrets manager. diff --git a/examples/skills_code_review_agent/skills/code-review/references/rules/security.md b/examples/skills_code_review_agent/skills/code-review/references/rules/security.md new file mode 100644 index 00000000..f6e6652f --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/references/rules/security.md @@ -0,0 +1,21 @@ +# Security Rule + +Detects dangerous patterns in added Python source lines. + +## Patterns + +| Pattern | Severity | Confidence | Guidance | +|---------|----------|------------|----------| +| `eval()` | high | 0.9 | Avoid eval(); use ast.literal_eval or explicit parsing. | +| `exec()` | high | 0.9 | Avoid exec(); restructure to avoid dynamic execution. | +| `shell=True` | high | 0.85 | Pass an argument list and shell=False to avoid shell injection. | +| `pickle.load/loads()` | high | 0.8 | Never unpickle untrusted input; use JSON or a safe serializer. | +| `yaml.load()` without SafeLoader | medium | 0.75 | Use yaml.safe_load or pass Loader=yaml.SafeLoader. | +| SQL via f-string: `.execute(f"...")` | high | 0.85 | Use parameterized queries (placeholders) instead of string interpolation. | +| SQL via concatenation: `.execute("..." + ...` | high | 0.8 | Use parameterized queries (placeholders) instead of concatenation. | +| `os.system()` | medium | 0.7 | Use subprocess.run with an argument list. | + +## Remediation + +Each finding includes a specific recommendation. All security findings should be resolved +before merging; high-severity findings must not be deferred. diff --git a/examples/skills_code_review_agent/skills/code-review/scripts/check_async_leak.py b/examples/skills_code_review_agent/skills/code-review/scripts/check_async_leak.py new file mode 100644 index 00000000..143b78a8 --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/scripts/check_async_leak.py @@ -0,0 +1,48 @@ +# 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 Apache-2.0. +"""Async/resource leak checker for added lines.""" +import re +import sys + +from checklib import emit, finding, iter_added_py_lines, load_files + +_SESSION_RE = re.compile(r"aiohttp\.ClientSession\s*\(") +_CREATE_TASK_RE = re.compile(r"^\s*asyncio\.create_task\s*\(") +_OPEN_ASSIGN_RE = re.compile(r"^\s*\w+\s*=\s*open\s*\(") + + +def main(): + files = load_files(sys.argv) + findings = [] + for path, line_no, text in iter_added_py_lines(files): + if _SESSION_RE.search(text) and "async with" not in text: + findings.append(finding( + "high", "async_resource_leak", path, line_no, + "ClientSession created without async context manager", + evidence=text.strip(), + recommendation="Use 'async with aiohttp.ClientSession() as session:' " + "or ensure session.close() is awaited.", + confidence=0.8)) + if _CREATE_TASK_RE.match(text): + findings.append(finding( + "medium", "async_resource_leak", path, line_no, + "Fire-and-forget asyncio.create_task without keeping a reference", + evidence=text.strip(), + recommendation="Store the task and await/cancel it; unreferenced " + "tasks may be garbage collected mid-flight.", + confidence=0.7)) + if _OPEN_ASSIGN_RE.match(text) and "with " not in text: + findings.append(finding( + "medium", "async_resource_leak", path, line_no, + "File opened without context manager", + evidence=text.strip(), + recommendation="Use 'with open(...) as f:' so the handle is always closed.", + confidence=0.7)) + emit(findings) + + +if __name__ == "__main__": + main() diff --git a/examples/skills_code_review_agent/skills/code-review/scripts/check_db_lifecycle.py b/examples/skills_code_review_agent/skills/code-review/scripts/check_db_lifecycle.py new file mode 100644 index 00000000..e1a707b3 --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/scripts/check_db_lifecycle.py @@ -0,0 +1,52 @@ +# 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 Apache-2.0. +"""Database connection / transaction lifecycle checker.""" +import re +import sys + +from checklib import emit, finding, iter_added_py_lines, load_files + +_CONNECT_RE = re.compile(r"^\s*\w+\s*=\s*\w[\w.]*\.connect\s*\(") +_CURSOR_RE = re.compile(r"^\s*\w+\s*=\s*\w+\.cursor\s*\(") +_COMMIT_RE = re.compile(r"\.commit\s*\(") + + +def main(): + files = load_files(sys.argv) + findings = [] + added_text_by_file = {} + for path, line_no, text in iter_added_py_lines(files): + added_text_by_file.setdefault(path, []).append(text) + if _CONNECT_RE.match(text) and "with " not in text: + findings.append(finding( + "high", "db_lifecycle", path, line_no, + "Connection opened without context manager or close()", + evidence=text.strip(), + recommendation="Use 'with ...connect(...) as conn:' or close the " + "connection in a finally block.", + confidence=0.8)) + if _CURSOR_RE.match(text) and "with " not in text: + findings.append(finding( + "low", "db_lifecycle", path, line_no, + "Cursor created without explicit lifecycle management", + evidence=text.strip(), + recommendation="Close the cursor or use a context manager.", + confidence=0.5)) + for path, line_no, text in iter_added_py_lines(files): + if _COMMIT_RE.search(text): + all_added = "\n".join(added_text_by_file.get(path, [])) + if "rollback" not in all_added: + findings.append(finding( + "medium", "db_lifecycle", path, line_no, + "commit() without visible rollback/error handling", + evidence=text.strip(), + recommendation="Wrap the transaction in try/except and roll back on failure.", + confidence=0.65)) + emit(findings) + + +if __name__ == "__main__": + main() diff --git a/examples/skills_code_review_agent/skills/code-review/scripts/check_secrets.py b/examples/skills_code_review_agent/skills/code-review/scripts/check_secrets.py new file mode 100644 index 00000000..ef660afa --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/scripts/check_secrets.py @@ -0,0 +1,29 @@ +# 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 Apache-2.0. +"""Hardcoded-secret checker. Evidence is redacted before it leaves this script.""" +import sys + +from checklib import emit, finding, iter_added_py_lines, load_files +from secret_patterns import find_secrets, redact + + +def main(): + files = load_files(sys.argv) + findings = [] + for path, line_no, text in iter_added_py_lines(files): + if find_secrets(text): + findings.append(finding( + "critical", "secret_leak", path, line_no, + "Hardcoded secret committed in source", + evidence=redact(text.strip()), + recommendation="Remove the secret, rotate it, and load it from " + "environment variables or a secret manager.", + confidence=0.95)) + emit(findings) + + +if __name__ == "__main__": + main() diff --git a/examples/skills_code_review_agent/skills/code-review/scripts/check_security.py b/examples/skills_code_review_agent/skills/code-review/scripts/check_security.py new file mode 100644 index 00000000..8f863453 --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/scripts/check_security.py @@ -0,0 +1,44 @@ +# 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 Apache-2.0. +"""Security checker: eval/exec, shell=True, pickle, unsafe yaml.load, SQL injection.""" +import re +import sys + +from checklib import emit, finding, iter_added_py_lines, load_files + +RULES = [ + (re.compile(r"\beval\s*\("), "high", "Use of eval() on dynamic data", + "Avoid eval(); use ast.literal_eval or explicit parsing.", 0.9), + (re.compile(r"\bexec\s*\("), "high", "Use of exec() on dynamic data", + "Avoid exec(); restructure the code to avoid dynamic execution.", 0.9), + (re.compile(r"shell\s*=\s*True"), "high", "subprocess with shell=True", + "Pass an argument list and shell=False to avoid shell injection.", 0.85), + (re.compile(r"pickle\.loads?\s*\("), "high", "Unpickling untrusted data", + "Never unpickle untrusted input; use JSON or a safe serializer.", 0.8), + (re.compile(r"yaml\.load\s*\((?!.*SafeLoader)"), "medium", "yaml.load without SafeLoader", + "Use yaml.safe_load or pass Loader=yaml.SafeLoader.", 0.75), + (re.compile(r"\.execute\s*\(\s*f[\"']"), "high", "SQL built with f-string (possible injection)", + "Use parameterized queries (placeholders) instead of string interpolation.", 0.85), + (re.compile(r"\.execute\s*\([^,)]*[\"']\s*\+"), "high", "SQL built with string concatenation", + "Use parameterized queries (placeholders) instead of concatenation.", 0.8), + (re.compile(r"os\.system\s*\("), "medium", "Use of os.system", + "Use subprocess.run with an argument list.", 0.7), +] + + +def main(): + files = load_files(sys.argv) + findings = [] + for path, line_no, text in iter_added_py_lines(files): + for pattern, severity, title, rec, conf in RULES: + if pattern.search(text): + findings.append(finding(severity, "security", path, line_no, title, + evidence=text.strip(), recommendation=rec, confidence=conf)) + emit(findings) + + +if __name__ == "__main__": + main() diff --git a/examples/skills_code_review_agent/skills/code-review/scripts/check_tests_missing.py b/examples/skills_code_review_agent/skills/code-review/scripts/check_tests_missing.py new file mode 100644 index 00000000..b30340bd --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/scripts/check_tests_missing.py @@ -0,0 +1,40 @@ +# 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 Apache-2.0. +"""Missing-test checker: source .py files changed but no test files changed.""" +import sys + +from checklib import emit, finding, load_files + + +def _is_test_path(path): + name = path.rsplit("/", 1)[-1] + return (path.startswith("tests/") or "/tests/" in path + or name.startswith("test_") or name.endswith("_test.py")) + + +def main(): + files = load_files(sys.argv) + changed_paths = [f["path"] or f["old_path"] for f in files] + tests_changed = any(_is_test_path(p) for p in changed_paths) + findings = [] + if not tests_changed: + for f in files: + path = f["path"] or f["old_path"] + if not path.endswith(".py") or _is_test_path(path): + continue + if not f["added_lines"]: + continue + findings.append(finding( + "medium", "missing_test", path, f["added_lines"][0]["line"], + "Source change without accompanying test change", + evidence="%d added line(s), no test files in this diff" % len(f["added_lines"]), + recommendation="Add or update unit tests covering the changed behavior.", + confidence=0.8)) + emit(findings) + + +if __name__ == "__main__": + main() diff --git a/examples/skills_code_review_agent/skills/code-review/scripts/checklib.py b/examples/skills_code_review_agent/skills/code-review/scripts/checklib.py new file mode 100644 index 00000000..7f3123cc --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/scripts/checklib.py @@ -0,0 +1,48 @@ +# 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 Apache-2.0. +"""Shared helpers for checker scripts.""" +import json + +from diffparse import parse_unified_diff + + +def load_files(argv): + """Parse the diff file given as argv[1]; exit(2) on usage error.""" + if len(argv) < 2: + print(json.dumps({"error": "usage: "})) + raise SystemExit(2) + with open(argv[1], encoding="utf-8", errors="replace") as fh: + return parse_unified_diff(fh.read()) + + +def finding(severity, category, file, line, title, evidence="", recommendation="", confidence=0.9): + """Build one finding dict (source is always 'static' for scripts).""" + return { + "severity": severity, + "category": category, + "file": file, + "line": line, + "title": title, + "evidence": evidence, + "recommendation": recommendation, + "confidence": confidence, + "source": "static", + } + + +def emit(findings): + """Print the findings JSON contract to stdout.""" + print(json.dumps({"findings": findings})) + + +def iter_added_py_lines(files): + """Yield (path, line_no, text) for added lines in Python files.""" + for f in files: + path = f["path"] or f["old_path"] + if not path.endswith(".py"): + continue + for added in f["added_lines"]: + yield path, added["line"], added["text"] diff --git a/examples/skills_code_review_agent/skills/code-review/scripts/diffparse.py b/examples/skills_code_review_agent/skills/code-review/scripts/diffparse.py new file mode 100644 index 00000000..28fce8c8 --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/scripts/diffparse.py @@ -0,0 +1,82 @@ +# 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 Apache-2.0. +"""Unified diff parser shared by all code-review checker scripts (stdlib only).""" +import re + +_HUNK_RE = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@") + + +def _strip_prefix(path): + """Strip git's a/ b/ prefixes.""" + if path.startswith(("a/", "b/")): + return path[2:] + return path + + +def parse_unified_diff(text): + """Parse a unified diff into a list of per-file dicts with new-file line numbers.""" + files = [] + current = None + old_no = new_no = 0 + for line in text.splitlines(): + if line.startswith("diff --git") or line.startswith("Index: "): + current = None + continue + if line.startswith("--- "): + raw_old = line[4:].split("\t")[0].strip() + current = { + "path": "", + "old_path": "" if raw_old == "/dev/null" else _strip_prefix(raw_old), + "is_new": raw_old == "/dev/null", + "is_deleted": False, + "hunks": [], + "added_lines": [], + "removed_lines": [], + } + files.append(current) + continue + if line.startswith("+++ "): + if current is None: + continue + raw_new = line[4:].split("\t")[0].strip() + if raw_new == "/dev/null": + current["is_deleted"] = True + current["path"] = current["old_path"] + else: + current["path"] = _strip_prefix(raw_new) + continue + m = _HUNK_RE.match(line) + if m and current is not None: + old_no, new_no = int(m.group(1)), int(m.group(3)) + current["hunks"].append({"old_start": old_no, "new_start": new_no, "lines": []}) + continue + if current is None or not current["hunks"]: + continue + hunk = current["hunks"][-1] + if line.startswith("+"): + hunk["lines"].append({"tag": "+", "new_lineno": new_no, "old_lineno": None, "text": line[1:]}) + current["added_lines"].append({"line": new_no, "text": line[1:]}) + new_no += 1 + elif line.startswith("-"): + hunk["lines"].append({"tag": "-", "new_lineno": None, "old_lineno": old_no, "text": line[1:]}) + current["removed_lines"].append({"line": old_no, "text": line[1:]}) + old_no += 1 + elif line.startswith(" ") or line == "": + hunk["lines"].append({"tag": " ", "new_lineno": new_no, "old_lineno": old_no, "text": line[1:]}) + old_no += 1 + new_no += 1 + # "\ No newline at end of file" markers are intentionally ignored. + return files + + +def summarize(files): + """Return a compact summary dict for a parsed diff.""" + return { + "files_changed": len(files), + "additions": sum(len(f["added_lines"]) for f in files), + "deletions": sum(len(f["removed_lines"]) for f in files), + "files": [f["path"] or f["old_path"] for f in files], + } diff --git a/examples/skills_code_review_agent/skills/code-review/scripts/parse_diff.py b/examples/skills_code_review_agent/skills/code-review/scripts/parse_diff.py new file mode 100644 index 00000000..3600b7fb --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/scripts/parse_diff.py @@ -0,0 +1,24 @@ +# 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 Apache-2.0. +"""CLI: parse a unified diff file and print a JSON summary to stdout.""" +import json +import sys + +from diffparse import parse_unified_diff, summarize + + +def main(): + if len(sys.argv) < 2: + print(json.dumps({"error": "usage: parse_diff.py "})) + return 2 + with open(sys.argv[1], encoding="utf-8", errors="replace") as fh: + files = parse_unified_diff(fh.read()) + print(json.dumps({"summary": summarize(files), "files": files})) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/examples/skills_code_review_agent/skills/code-review/scripts/secret_patterns.py b/examples/skills_code_review_agent/skills/code-review/scripts/secret_patterns.py new file mode 100644 index 00000000..72155f1b --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/scripts/secret_patterns.py @@ -0,0 +1,44 @@ +# 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 Apache-2.0. +"""Secret detection + redaction patterns. Single source of truth for +check_secrets.py (sandbox side) and review/redaction.py (host side).""" +import hashlib +import re + +SECRET_PATTERNS = [ + ("anthropic_key", re.compile(r"sk-ant-[A-Za-z0-9\-]{10,}")), + ("openai_key", re.compile(r"sk-[A-Za-z0-9]{20,}")), + ("aws_access_key", re.compile(r"AKIA[0-9A-Z]{16}")), + ("github_token", re.compile(r"gh[pousr]_[A-Za-z0-9]{20,}")), + ("slack_token", re.compile(r"xox[baprs]-[A-Za-z0-9\-]{10,}")), + ("jwt", re.compile(r"eyJ[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}(?:\.[A-Za-z0-9_\-]{4,})?")), + ("private_key", re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----")), + ("bearer_token", re.compile(r"[Bb]earer\s+[A-Za-z0-9._\-]{12,}")), + ("url_basic_auth", re.compile(r"[a-z][a-z0-9+.\-]*://[^/\s:@]+:[^@\s]+@")), + ("sensitive_assign", re.compile( + r"(?i)(password|passwd|secret|token|api_key|apikey|secret_token|db_password)" + r"\s*[:=]\s*[\"'][^\"']{6,}[\"']")), +] + + +def find_secrets(text): + """Return the list of raw secret substrings found in *text*.""" + found = [] + for _, pattern in SECRET_PATTERNS: + for m in pattern.finditer(text): + found.append(m.group(0)) + return found + + +def _fingerprint(value): + return hashlib.sha256(value.encode("utf-8")).hexdigest()[:8] + + +def redact(text): + """Replace every secret match with ***REDACTED-*** (stable per value).""" + for _, pattern in SECRET_PATTERNS: + text = pattern.sub(lambda m: "***REDACTED-%s***" % _fingerprint(m.group(0)), text) + return text diff --git a/examples/skills_code_review_agent/storage/__init__.py b/examples/skills_code_review_agent/storage/__init__.py new file mode 100644 index 00000000..fe78e593 --- /dev/null +++ b/examples/skills_code_review_agent/storage/__init__.py @@ -0,0 +1,6 @@ +# 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 Apache-2.0. +"""Persistence layer for the code-review example.""" diff --git a/examples/skills_code_review_agent/storage/models.py b/examples/skills_code_review_agent/storage/models.py new file mode 100644 index 00000000..698eb7f6 --- /dev/null +++ b/examples/skills_code_review_agent/storage/models.py @@ -0,0 +1,104 @@ +# 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 Apache-2.0. +"""SQLAlchemy declarative models for the code-review example.""" +import uuid +from datetime import datetime + +from sqlalchemy import JSON, Boolean, DateTime, Float, ForeignKey, Integer, String, Text +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + +def _uuid() -> str: + return uuid.uuid4().hex + + +class CrBase(DeclarativeBase): + """Dedicated base so only this example's tables are created.""" + + +class ReviewTaskRow(CrBase): + __tablename__ = "cr_review_tasks" + + id: Mapped[str] = mapped_column(String(64), primary_key=True, default=_uuid) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now) + finished_at: Mapped[datetime] = mapped_column(DateTime, nullable=True) + status: Mapped[str] = mapped_column(String(32), default="pending") + input_type: Mapped[str] = mapped_column(String(32), default="") + input_ref: Mapped[str] = mapped_column(String(512), default="") + runtime: Mapped[str] = mapped_column(String(32), default="local") + dry_run: Mapped[bool] = mapped_column(Boolean, default=True) + diff_summary: Mapped[dict] = mapped_column(JSON, nullable=True) + + +class SandboxRunRow(CrBase): + __tablename__ = "cr_sandbox_runs" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + task_id: Mapped[str] = mapped_column(String(64), ForeignKey("cr_review_tasks.id")) + script: Mapped[str] = mapped_column(String(128), default="") + category: Mapped[str] = mapped_column(String(64), default="") + status: Mapped[str] = mapped_column(String(32), default="") + exit_code: Mapped[int] = mapped_column(Integer, default=0) + duration_ms: Mapped[int] = mapped_column(Integer, default=0) + timed_out: Mapped[bool] = mapped_column(Boolean, default=False) + stdout_summary: Mapped[str] = mapped_column(Text, default="") + stderr_summary: Mapped[str] = mapped_column(Text, default="") + error_type: Mapped[str] = mapped_column(String(64), default="") + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now) + + +class FindingRow(CrBase): + __tablename__ = "cr_findings" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + task_id: Mapped[str] = mapped_column(String(64), ForeignKey("cr_review_tasks.id")) + severity: Mapped[str] = mapped_column(String(16), default="") + category: Mapped[str] = mapped_column(String(64), default="") + file: Mapped[str] = mapped_column(String(512), default="") + line: Mapped[int] = mapped_column(Integer, default=0) + title: Mapped[str] = mapped_column(String(256), default="") + evidence: Mapped[str] = mapped_column(Text, default="") + recommendation: Mapped[str] = mapped_column(Text, default="") + confidence: Mapped[float] = mapped_column(Float, default=1.0) + source: Mapped[str] = mapped_column(String(32), default="static") + status: Mapped[str] = mapped_column(String(32), default="reported") + dedup_key: Mapped[str] = mapped_column(String(640), default="") + + +class FilterEventRow(CrBase): + __tablename__ = "cr_filter_events" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + task_id: Mapped[str] = mapped_column(String(64), ForeignKey("cr_review_tasks.id")) + target: Mapped[str] = mapped_column(String(512), default="") + decision: Mapped[str] = mapped_column(String(32), default="") + rule: Mapped[str] = mapped_column(String(64), default="") + reason: Mapped[str] = mapped_column(Text, default="") + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now) + + +class MetricsRow(CrBase): + __tablename__ = "cr_metrics" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + task_id: Mapped[str] = mapped_column(String(64), ForeignKey("cr_review_tasks.id")) + total_duration_ms: Mapped[int] = mapped_column(Integer, default=0) + sandbox_duration_ms: Mapped[int] = mapped_column(Integer, default=0) + tool_calls: Mapped[int] = mapped_column(Integer, default=0) + intercepts: Mapped[int] = mapped_column(Integer, default=0) + findings_total: Mapped[int] = mapped_column(Integer, default=0) + severity_distribution: Mapped[dict] = mapped_column(JSON, nullable=True) + error_distribution: Mapped[dict] = mapped_column(JSON, nullable=True) + + +class ReportRow(CrBase): + __tablename__ = "cr_reports" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + task_id: Mapped[str] = mapped_column(String(64), ForeignKey("cr_review_tasks.id")) + report_json: Mapped[dict] = mapped_column(JSON, nullable=True) + report_md: Mapped[str] = mapped_column(Text, default="") + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now) diff --git a/examples/skills_code_review_agent/storage/store.py b/examples/skills_code_review_agent/storage/store.py new file mode 100644 index 00000000..78d76a9c --- /dev/null +++ b/examples/skills_code_review_agent/storage/store.py @@ -0,0 +1,124 @@ +# 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 Apache-2.0. +"""ReviewStore: persistence built on the SDK's SqlStorage (SQLite by default, +any SQLAlchemy db_url works).""" +import uuid +from datetime import datetime + +from trpc_agent_sdk.storage import SqlCondition, SqlKey, SqlStorage + +from review.redaction import redact_text + +from .models import (CrBase, FilterEventRow, FindingRow, MetricsRow, ReportRow, + ReviewTaskRow, SandboxRunRow) + + +def _row_to_dict(row): + if row is None: + return None + return {c.name: getattr(row, c.name) for c in row.__table__.columns} + + +class ReviewStore: + """CRUD facade over the code-review tables. All write paths redact secrets.""" + + def __init__(self, db_url: str = "sqlite:///code_review.db"): + self._storage = SqlStorage(is_async=False, db_url=db_url, metadata=CrBase.metadata) + + async def _add(self, row) -> None: + async with self._storage.create_db_session() as db: + await self._storage.add(db, row) + await self._storage.commit(db) + + async def create_task(self, input_type: str, input_ref: str, runtime: str, + dry_run: bool) -> str: + row = ReviewTaskRow(input_type=input_type, input_ref=input_ref, + runtime=runtime, dry_run=dry_run, status="running") + task_id = row.id or "" + if not task_id: + task_id = uuid.uuid4().hex + row.id = task_id + await self._add(row) + return task_id + + async def update_task(self, task_id: str, status: str = None, + diff_summary: dict = None, finished: bool = False) -> None: + async with self._storage.create_db_session() as db: + row = await self._storage.get(db, SqlKey(key=(task_id,), storage_cls=ReviewTaskRow)) + if row is None: + return + if status is not None: + row.status = status + if diff_summary is not None: + row.diff_summary = diff_summary + if finished: + row.finished_at = datetime.now() + await self._storage.commit(db) + + async def add_sandbox_run(self, task_id: str, *, script: str, category: str, + status: str, exit_code: int, duration_ms: int, + timed_out: bool, stdout_summary: str, + stderr_summary: str, error_type: str) -> None: + await self._add(SandboxRunRow( + task_id=task_id, script=script, category=category, status=status, + exit_code=exit_code, duration_ms=duration_ms, timed_out=timed_out, + stdout_summary=redact_text(stdout_summary), + stderr_summary=redact_text(stderr_summary), error_type=error_type)) + + async def add_findings(self, task_id: str, findings, status: str) -> None: + for f in findings: + await self._add(FindingRow( + task_id=task_id, severity=f.severity, category=f.category, + file=f.file, line=f.line, title=f.title, + evidence=redact_text(f.evidence), recommendation=f.recommendation, + confidence=f.confidence, source=f.source, status=status, + dedup_key=f.dedup_key)) + + async def add_filter_event(self, task_id: str, target: str, decision: str, + rule: str, reason: str) -> None: + await self._add(FilterEventRow(task_id=task_id, target=redact_text(target), + decision=decision, rule=rule, reason=reason)) + + async def add_metrics(self, task_id: str, metrics: dict) -> None: + await self._add(MetricsRow( + task_id=task_id, + total_duration_ms=metrics.get("total_duration_ms", 0), + sandbox_duration_ms=metrics.get("sandbox_duration_ms", 0), + tool_calls=metrics.get("tool_calls", 0), + intercepts=metrics.get("intercepts", 0), + findings_total=metrics.get("findings_total", 0), + severity_distribution=metrics.get("severity_distribution", {}), + error_distribution=metrics.get("error_distribution", {}))) + + async def add_report(self, task_id: str, report_json: dict, report_md: str) -> None: + await self._add(ReportRow(task_id=task_id, report_json=report_json, + report_md=redact_text(report_md))) + + async def _query_all(self, db, storage_cls, task_id): + rows = await self._storage.query( + db, SqlKey(key=(), storage_cls=storage_cls), + SqlCondition(filters=[storage_cls.task_id == task_id])) + return [_row_to_dict(r) for r in rows] + + async def get_task_bundle(self, task_id: str) -> dict: + async with self._storage.create_db_session() as db: + task = await self._storage.get(db, SqlKey(key=(task_id,), storage_cls=ReviewTaskRow)) + runs = await self._query_all(db, SandboxRunRow, task_id) + findings = await self._query_all(db, FindingRow, task_id) + events = await self._query_all(db, FilterEventRow, task_id) + metrics_rows = await self._query_all(db, MetricsRow, task_id) + report_rows = await self._query_all(db, ReportRow, task_id) + return { + "task": _row_to_dict(task), + "sandbox_runs": runs, + "findings": findings, + "filter_events": events, + "metrics": metrics_rows[0] if metrics_rows else None, + "report": report_rows[0] if report_rows else None, + } + + async def close(self) -> None: + await self._storage.close() diff --git a/examples/skills_code_review_agent/tests/conftest.py b/examples/skills_code_review_agent/tests/conftest.py new file mode 100644 index 00000000..9950708d --- /dev/null +++ b/examples/skills_code_review_agent/tests/conftest.py @@ -0,0 +1,15 @@ +# 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 Apache-2.0. +"""Test path setup for the skills_code_review_agent example.""" +import sys +from pathlib import Path + +EXAMPLE_ROOT = Path(__file__).resolve().parents[1] +SCRIPTS_DIR = EXAMPLE_ROOT / "skills" / "code-review" / "scripts" + +for p in (str(EXAMPLE_ROOT), str(SCRIPTS_DIR)): + if p not in sys.path: + sys.path.insert(0, p) diff --git a/examples/skills_code_review_agent/tests/test_checkers.py b/examples/skills_code_review_agent/tests/test_checkers.py new file mode 100644 index 00000000..56c425b2 --- /dev/null +++ b/examples/skills_code_review_agent/tests/test_checkers.py @@ -0,0 +1,73 @@ +# 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 Apache-2.0. +"""Tests for the sandbox checker scripts (run host-side as plain subprocesses).""" +import json +import subprocess +import sys +from pathlib import Path + +EXAMPLE_ROOT = Path(__file__).resolve().parents[1] +FIXTURES = EXAMPLE_ROOT / "fixtures" +SCRIPTS = EXAMPLE_ROOT / "skills" / "code-review" / "scripts" + + +def run_checker(script, fixture): + out = subprocess.run( + [sys.executable, str(SCRIPTS / script), str(FIXTURES / fixture)], + capture_output=True, text=True, check=True) + return json.loads(out.stdout)["findings"] + + +def test_security_checker_detects_eval_shell_sql(): + findings = run_checker("check_security.py", "security_eval.diff") + titles = " | ".join(f["title"] for f in findings) + assert any(f["category"] == "security" for f in findings) + assert "eval" in titles.lower() + assert any(f["line"] == 11 for f in findings) + assert all(f["source"] == "static" for f in findings) + + +def test_security_checker_clean_diff_is_quiet(): + assert run_checker("check_security.py", "clean.diff") == [] + + +def test_async_leak_checker(): + findings = run_checker("check_async_leak.py", "async_leak.diff") + assert any(f["category"] == "async_resource_leak" for f in findings) + assert len(findings) >= 2 + + +def test_db_lifecycle_checker(): + findings = run_checker("check_db_lifecycle.py", "db_lifecycle.diff") + assert any(f["category"] == "db_lifecycle" for f in findings) + assert any("connect" in f["title"].lower() for f in findings) + + +def test_tests_missing_checker_fires_without_test_changes(): + findings = run_checker("check_tests_missing.py", "missing_test.diff") + assert len(findings) == 1 + assert findings[0]["category"] == "missing_test" + assert findings[0]["file"] == "app/service.py" + + +def test_tests_missing_checker_quiet_when_tests_changed(): + assert run_checker("check_tests_missing.py", "clean.diff") == [] + + +def test_secrets_checker_redacts_evidence(): + findings = run_checker("check_secrets.py", "secret_leak.diff") + assert any(f["category"] == "secret_leak" for f in findings) + for f in findings: + assert "sk-fakefakefakefakefakefake123456" not in f["evidence"] + assert "AKIA0123456789ABCDEF" not in f["evidence"] + assert "***REDACTED-" in f["evidence"] + + +def test_duplicate_fixture_produces_two_security_findings_same_line(): + findings = run_checker("check_security.py", "duplicate_finding.diff") + keys = [(f["file"], f["line"], f["category"]) for f in findings] + assert len(keys) == 2 + assert keys[0] == keys[1] diff --git a/examples/skills_code_review_agent/tests/test_cli.py b/examples/skills_code_review_agent/tests/test_cli.py new file mode 100644 index 00000000..5ef7e836 --- /dev/null +++ b/examples/skills_code_review_agent/tests/test_cli.py @@ -0,0 +1,32 @@ +# 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 Apache-2.0. +"""CLI smoke tests (invoke main() in-process).""" +import json + +import run_agent + + +async def test_cli_review_and_show(tmp_path, capsys): + db_url = f"sqlite:///{tmp_path}/cr.db" + code = await run_agent.main([ + "review", "--fixture", "security_eval", "--runtime", "local", "--dry-run", + "--db-url", db_url, "--output-dir", str(tmp_path / "out")]) + assert code == 0 + out = capsys.readouterr().out + assert "task_id=" in out + task_id = out.split("task_id=")[1].split()[0].strip() + + code = await run_agent.main(["show", "--task-id", task_id, "--db-url", db_url]) + assert code == 0 + bundle = json.loads(capsys.readouterr().out) + assert bundle["task"]["id"] == task_id + assert bundle["findings"] + + +async def test_cli_show_unknown_task(tmp_path, capsys): + db_url = f"sqlite:///{tmp_path}/cr.db" + code = await run_agent.main(["show", "--task-id", "missing", "--db-url", db_url]) + assert code == 1 diff --git a/examples/skills_code_review_agent/tests/test_container_runtime.py b/examples/skills_code_review_agent/tests/test_container_runtime.py new file mode 100644 index 00000000..0789ee4a --- /dev/null +++ b/examples/skills_code_review_agent/tests/test_container_runtime.py @@ -0,0 +1,30 @@ +# 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 Apache-2.0. +"""Opt-in container-runtime e2e test. Enable with CR_CONTAINER_TESTS=1.""" +import os +import shutil +from pathlib import Path + +import pytest + +from review.pipeline import ReviewOptions, run_review + +EXAMPLE_ROOT = Path(__file__).resolve().parents[1] + +pytestmark = pytest.mark.skipif( + not (shutil.which("docker") and os.getenv("CR_CONTAINER_TESTS") == "1"), + reason="docker not available or CR_CONTAINER_TESTS != 1") + + +async def test_container_review_security_fixture(tmp_path): + result = await run_review(ReviewOptions( + diff_text=(EXAMPLE_ROOT / "fixtures" / "security_eval.diff").read_text(), + input_type="fixture", input_ref="security_eval.diff", + runtime="container", dry_run=True, + db_url=f"sqlite:///{tmp_path}/cr.db", + output_dir=str(tmp_path / "out"))) + assert result.report["conclusion"] == "blocked" + assert any(f["category"] == "security" for f in result.report["findings"]) diff --git a/examples/skills_code_review_agent/tests/test_diffparse.py b/examples/skills_code_review_agent/tests/test_diffparse.py new file mode 100644 index 00000000..bda8fa2a --- /dev/null +++ b/examples/skills_code_review_agent/tests/test_diffparse.py @@ -0,0 +1,50 @@ +# 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 Apache-2.0. +"""Tests for the unified diff parser.""" +import json +import subprocess +import sys +from pathlib import Path + +EXAMPLE_ROOT = Path(__file__).resolve().parents[1] +FIXTURES = EXAMPLE_ROOT / "fixtures" +SCRIPTS = EXAMPLE_ROOT / "skills" / "code-review" / "scripts" + + +def _clean_text(): + return (FIXTURES / "clean.diff").read_text(encoding="utf-8") + + +def test_parse_files_and_paths(): + from diffparse import parse_unified_diff + files = parse_unified_diff(_clean_text()) + assert [f["path"] for f in files] == ["app/util.py", "tests/test_util.py"] + + +def test_added_line_numbers(): + from diffparse import parse_unified_diff + files = parse_unified_diff(_clean_text()) + added = files[0]["added_lines"] + assert added[0]["line"] == 2 + assert "add two numbers" in added[0]["text"] + assert added[1]["line"] == 3 + + +def test_summarize(): + from diffparse import parse_unified_diff, summarize + s = summarize(parse_unified_diff(_clean_text())) + assert s["files_changed"] == 2 + assert s["additions"] == 3 + assert s["deletions"] == 0 + + +def test_parse_diff_cli(): + out = subprocess.run( + [sys.executable, str(SCRIPTS / "parse_diff.py"), str(FIXTURES / "clean.diff")], + capture_output=True, text=True, check=True) + payload = json.loads(out.stdout) + assert payload["summary"]["files_changed"] == 2 + assert payload["files"][0]["path"] == "app/util.py" diff --git a/examples/skills_code_review_agent/tests/test_findings.py b/examples/skills_code_review_agent/tests/test_findings.py new file mode 100644 index 00000000..62266007 --- /dev/null +++ b/examples/skills_code_review_agent/tests/test_findings.py @@ -0,0 +1,45 @@ +# 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 Apache-2.0. +"""Tests for the Finding model, dedup and confidence gating.""" +from review.findings import Finding, dedupe, gate, severity_distribution + + +def make(severity="high", category="security", file="a.py", line=1, + confidence=0.9, source="static", title="t"): + return Finding(severity=severity, category=category, file=file, line=line, + title=title, confidence=confidence, source=source) + + +def test_dedup_same_file_line_category(): + kept, dropped = dedupe([make(title="eval"), make(title="exec", confidence=0.8)]) + assert len(kept) == 1 and len(dropped) == 1 + assert kept[0].confidence == 0.9 + + +def test_dedup_merges_sources(): + kept, _ = dedupe([make(source="static"), make(source="llm", confidence=0.7)]) + assert kept[0].source == "static+llm" + + +def test_dedup_keeps_highest_severity(): + kept, _ = dedupe([make(severity="medium"), make(severity="critical", confidence=0.5)]) + assert kept[0].severity == "critical" + + +def test_no_dedup_across_lines(): + kept, dropped = dedupe([make(line=1), make(line=2)]) + assert len(kept) == 2 and dropped == [] + + +def test_gate_splits_low_confidence(): + reported, needs = gate([make(confidence=0.9), make(line=2, confidence=0.5)]) + assert len(reported) == 1 and len(needs) == 1 + assert needs[0].confidence == 0.5 + + +def test_severity_distribution(): + dist = severity_distribution([make(), make(line=2, severity="low")]) + assert dist == {"high": 1, "low": 1} diff --git a/examples/skills_code_review_agent/tests/test_governance.py b/examples/skills_code_review_agent/tests/test_governance.py new file mode 100644 index 00000000..502e96d7 --- /dev/null +++ b/examples/skills_code_review_agent/tests/test_governance.py @@ -0,0 +1,78 @@ +# 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 Apache-2.0. +"""Tests for governance decisions and the tool filter.""" +from trpc_agent_sdk.abc import FilterResult + +from review.governance import GovernanceEngine +from filters_cr.governance_filter import GovernanceToolFilter + + +def test_allowlisted_script_allowed(): + eng = GovernanceEngine() + d = eng.check_script("check_security.py", ["work/inputs/changes.diff"]) + assert d.decision == "allow" + + +def test_unknown_script_denied(): + d = GovernanceEngine().check_script("steal_data.py", ["work/inputs/changes.diff"]) + assert d.decision == "deny" + assert d.rule == "script_allowlist" + + +def test_forbidden_path_denied(): + eng = GovernanceEngine() + assert eng.check_script("check_security.py", ["/etc/passwd"]).decision == "deny" + assert eng.check_script("check_security.py", ["../../secrets"]).decision == "deny" + + +def test_budget_exceeded_denied(): + eng = GovernanceEngine(max_runs=1) + eng.record_run(0.1) + d = eng.check_script("check_security.py", ["work/inputs/changes.diff"]) + assert d.decision == "deny" + assert d.rule == "budget_exceeded" + + +def test_command_network_tool_denied(): + d = GovernanceEngine().check_command("curl http://evil.example.com") + assert d.decision == "deny" + assert d.rule == "network_policy" + + +def test_command_risk_token_needs_review(): + d = GovernanceEngine().check_command("sudo rm -rf /") + assert d.decision == "needs_human_review" + + +def test_command_allowlisted_python_script_allowed(): + d = GovernanceEngine().check_command( + "python3 skills/code-review/scripts/check_security.py work/inputs/changes.diff") + assert d.decision == "allow" + + +def test_command_unknown_executable_needs_review(): + d = GovernanceEngine().check_command("make all") + assert d.decision == "needs_human_review" + + +async def test_filter_blocks_denied_command(): + events = [] + filt = GovernanceToolFilter(GovernanceEngine(), on_event=events.append) + rsp = FilterResult() + await filt._before(None, {"skill": "code-review", "command": "curl http://x"}, rsp) + assert rsp.is_continue is False + assert "blocked" in str(rsp.rsp) + assert events and events[0].decision == "deny" + + +async def test_filter_allows_good_command(): + filt = GovernanceToolFilter(GovernanceEngine()) + rsp = FilterResult() + await filt._before(None, { + "skill": "code-review", + "command": "python3 skills/code-review/scripts/parse_diff.py work/inputs/changes.diff", + }, rsp) + assert rsp.is_continue is True diff --git a/examples/skills_code_review_agent/tests/test_llm_review.py b/examples/skills_code_review_agent/tests/test_llm_review.py new file mode 100644 index 00000000..595bfbeb --- /dev/null +++ b/examples/skills_code_review_agent/tests/test_llm_review.py @@ -0,0 +1,59 @@ +# 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 Apache-2.0. +"""Tests for the fake model and the LLM review step.""" +from pathlib import Path + +from trpc_agent_sdk.skills import create_default_skill_repository + +from agent.agent import create_review_agent +from agent.fake_model import FakeReviewModel +from review.findings import Finding +from review.llm_review import parse_llm_output, run_llm_review + +EXAMPLE_ROOT = Path(__file__).resolve().parents[1] +SKILLS_DIR = EXAMPLE_ROOT / "skills" + + +def test_parse_llm_output_plain_json(): + findings, summary = parse_llm_output( + '{"summary": "ok", "findings": [{"severity": "high", "category": "security",' + ' "file": "a.py", "line": 3, "title": "x", "confidence": 0.8}]}') + assert summary == "ok" + assert findings[0]["line"] == 3 + + +def test_parse_llm_output_fenced_json(): + text = 'Here you go:\n```json\n{"summary": "s", "findings": []}\n```\n' + findings, summary = parse_llm_output(text) + assert summary == "s" and findings == [] + + +def test_parse_llm_output_fenced_nested_json(): + text = ('```json\n{"summary": "s", "findings": [{"severity": "high", "category": "security",' + ' "file": "a.py", "line": 3, "title": "x", "confidence": 0.9}]}\n```') + findings, summary = parse_llm_output(text) + assert summary == "s" + assert findings[0]["severity"] == "high" + + +def test_parse_llm_output_garbage_returns_empty(): + findings, summary = parse_llm_output("I could not review this.") + assert findings == [] and summary == "" + + +def test_fake_model_supported_models(): + assert FakeReviewModel.supported_models() == [r"fake-review-.*"] + + +async def test_run_llm_review_with_fake_model(): + repository = create_default_skill_repository(str(SKILLS_DIR)) + agent = create_review_agent(repository, dry_run=True, tool_filters=[]) + static = [Finding(severity="high", category="security", file="a.py", line=1, + title="eval", confidence=0.9)] + llm_findings, summary, warnings = await run_llm_review(agent, "diff text", static) + assert llm_findings == [] + assert "Dry-run review complete" in summary + assert warnings == [] diff --git a/examples/skills_code_review_agent/tests/test_pipeline.py b/examples/skills_code_review_agent/tests/test_pipeline.py new file mode 100644 index 00000000..9c9e8aed --- /dev/null +++ b/examples/skills_code_review_agent/tests/test_pipeline.py @@ -0,0 +1,121 @@ +# 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 Apache-2.0. +"""End-to-end pipeline tests over the 8 fixture scenarios (local runtime, dry-run).""" +import json +import shutil +import time +from pathlib import Path + +from review.pipeline import CHECKERS, ReviewOptions, run_review +from storage.store import ReviewStore + +EXAMPLE_ROOT = Path(__file__).resolve().parents[1] +FIXTURES = EXAMPLE_ROOT / "fixtures" + + +def _opts(tmp_path, fixture, **kw): + return ReviewOptions( + diff_text=(FIXTURES / fixture).read_text(), + input_type="fixture", input_ref=fixture, + runtime="local", dry_run=True, + db_url=f"sqlite:///{tmp_path}/cr.db", + output_dir=str(tmp_path / "out"), **kw) + + +async def _bundle(tmp_path, task_id): + store = ReviewStore(db_url=f"sqlite:///{tmp_path}/cr.db") + bundle = await store.get_task_bundle(task_id) + await store.close() + return bundle + + +async def test_clean_diff_passes(tmp_path): + result = await run_review(_opts(tmp_path, "clean.diff")) + assert result.report["conclusion"] == "pass" + assert result.report["findings"] == [] + assert Path(result.json_path).exists() and Path(result.md_path).exists() + bundle = await _bundle(tmp_path, result.task_id) + assert bundle["task"]["status"] == "completed" + assert len(bundle["sandbox_runs"]) == len(CHECKERS) + 1 # + parse_diff + + +async def test_security_diff_blocked(tmp_path): + result = await run_review(_opts(tmp_path, "security_eval.diff")) + assert result.report["conclusion"] == "blocked" + cats = {f["category"] for f in result.report["findings"]} + assert "security" in cats + + +async def test_async_leak_detected(tmp_path): + result = await run_review(_opts(tmp_path, "async_leak.diff")) + assert any(f["category"] == "async_resource_leak" for f in result.report["findings"]) + + +async def test_db_lifecycle_detected(tmp_path): + result = await run_review(_opts(tmp_path, "db_lifecycle.diff")) + assert any(f["category"] == "db_lifecycle" for f in result.report["findings"]) + assert any(f["confidence"] < 0.6 for f in result.report["needs_human_review"]) + + +async def test_missing_test_detected(tmp_path): + result = await run_review(_opts(tmp_path, "missing_test.diff")) + assert any(f["category"] == "missing_test" for f in result.report["findings"]) + + +async def test_duplicate_finding_deduplicated(tmp_path): + result = await run_review(_opts(tmp_path, "duplicate_finding.diff")) + security = [f for f in result.report["findings"] if f["category"] == "security"] + assert len(security) == 1 + assert result.report["summary"]["deduplicated"] >= 1 + bundle = await _bundle(tmp_path, result.task_id) + assert any(f["status"] == "deduped" for f in bundle["findings"]) + + +async def test_sandbox_failure_does_not_crash(tmp_path): + skill_copy = tmp_path / "code-review" + shutil.copytree(EXAMPLE_ROOT / "skills" / "code-review", skill_copy) + (skill_copy / "scripts" / "check_broken.py").write_text("import sys\nsys.exit(3)\n") + checkers = list(CHECKERS) + [("check_broken.py", "broken")] + allowed = tuple(s for s, _ in checkers) + ("parse_diff.py",) + result = await run_review(_opts(tmp_path, "sandbox_failure.diff", + skill_root=str(skill_copy), checkers=checkers, + allowed_scripts=allowed)) + bundle = await _bundle(tmp_path, result.task_id) + failed = [r for r in bundle["sandbox_runs"] if r["status"] == "failed"] + assert failed and failed[0]["script"] == "check_broken.py" + assert bundle["task"]["status"] == "completed" + + +async def test_secret_leak_redacted_everywhere(tmp_path): + result = await run_review(_opts(tmp_path, "secret_leak.diff")) + assert any(f["category"] == "secret_leak" for f in result.report["findings"]) + secret = "sk-fakefakefakefakefakefake123456" + all_text = Path(result.json_path).read_text() + Path(result.md_path).read_text() + assert secret not in all_text + bundle = await _bundle(tmp_path, result.task_id) + assert secret not in json.dumps(bundle, default=str) + + +async def test_denied_checker_never_reaches_sandbox(tmp_path): + checkers = list(CHECKERS) + [("evil.py", "evil")] + result = await run_review(_opts(tmp_path, "clean.diff", checkers=checkers)) + bundle = await _bundle(tmp_path, result.task_id) + assert all(r["script"] != "evil.py" for r in bundle["sandbox_runs"]) + denies = [e for e in bundle["filter_events"] if e["decision"] == "deny"] + assert any("evil.py" in e["target"] for e in denies) + assert result.report["conclusion"] in ("needs_attention", "pass") + + +async def test_metrics_recorded_and_fast(tmp_path): + start = time.monotonic() + result = await run_review(_opts(tmp_path, "security_eval.diff")) + elapsed = time.monotonic() - start + assert elapsed < 120 + m = result.report["metrics"] + assert m["tool_calls"] >= len(CHECKERS) + 1 + assert m["total_duration_ms"] > 0 + bundle = await _bundle(tmp_path, result.task_id) + assert bundle["metrics"]["findings_total"] == len(result.report["findings"]) diff --git a/examples/skills_code_review_agent/tests/test_redaction.py b/examples/skills_code_review_agent/tests/test_redaction.py new file mode 100644 index 00000000..9deb3bfb --- /dev/null +++ b/examples/skills_code_review_agent/tests/test_redaction.py @@ -0,0 +1,56 @@ +# 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 Apache-2.0. +"""Tests for secret detection and redaction (>=95% detection target).""" +SAMPLE_SECRETS = [ + 'API_KEY = "sk-abcdefghijklmnopqrstuvwxyz123456"', + 'key = "sk-ant-api03-abcdefghijklmnopqrst"', + "aws AKIAIOSFODNN7EXAMPLE", + 'token = "ghp_abcdefghijklmnopqrstuvwxyz0123456789"', + "Authorization: Bearer abc123def456ghi789jkl", + "jwt eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.abc123def456", + "-----BEGIN RSA PRIVATE KEY-----", + 'password = "hunter2secret"', + 'PASSWD: "topsecretvalue"', + 'secret = "do-not-leak-me"', + 'api_key="deadbeefcafe1234"', + "https://user:p4ssw0rd@db.example.com/prod", + 'TOKEN = "9f8e7d6c5b4a39281706f5e4d3c2b1a0"', + 'apikey: "0123456789abcdefffff"', + "Bearer xoxb-1234567890-abcdefghij", + 'DB_PASSWORD = "prod-db-pass-2026"', + 'passwd = "another$ecret!"', + "AKIA0123456789ABCDEF", + 'secret_token = "veryhiddenvalue1"', + 'ghs_abcdefghijklmnopqrstuvwxyz0123456789', +] + + +def test_detection_rate_at_least_95_percent(): + from review.redaction import contains_secret + detected = sum(1 for s in SAMPLE_SECRETS if contains_secret(s)) + assert detected / len(SAMPLE_SECRETS) >= 0.95 + + +def test_redaction_removes_secret_values(): + from review.redaction import redact_text + for sample in SAMPLE_SECRETS: + out = redact_text(sample) + assert "***REDACTED-" in out or sample == out, sample + assert "hunter2secret" not in redact_text('password = "hunter2secret"') + assert "AKIAIOSFODNN7EXAMPLE" not in redact_text("aws AKIAIOSFODNN7EXAMPLE") + + +def test_redaction_is_stable_fingerprint(): + from review.redaction import redact_text + a = redact_text("Bearer abc123def456ghi789jkl") + b = redact_text("Bearer abc123def456ghi789jkl") + assert a == b + + +def test_plain_text_untouched(): + from review.redaction import redact_text + text = "def add(a, b):\n return a + b" + assert redact_text(text) == text diff --git a/examples/skills_code_review_agent/tests/test_report.py b/examples/skills_code_review_agent/tests/test_report.py new file mode 100644 index 00000000..604cdcb7 --- /dev/null +++ b/examples/skills_code_review_agent/tests/test_report.py @@ -0,0 +1,74 @@ +# 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 Apache-2.0. +"""Tests for report building and rendering.""" +import json +from pathlib import Path + +from review.findings import Finding +from review.governance import GovernanceDecision +from review.report import build_report, render_markdown, write_reports +from review.sandbox import SandboxRunOutcome + + +def _report(reported=None, events=None): + return build_report( + task_id="t1", input_ref="x.diff", runtime="local", dry_run=True, + diff_summary={"files_changed": 1}, + reported=reported or [], + needs_review=[Finding(severity="low", category="db_lifecycle", file="b.py", + line=2, title="cursor", confidence=0.5)], + deduped_count=1, + filter_events=events or [GovernanceDecision("curl x", "deny", "network_policy", "no net")], + sandbox_outcomes=[SandboxRunOutcome(script="check_security.py", status="ok", + exit_code=0, duration_ms=10, timed_out=False, + stdout="{}", stderr="")], + metrics={"total_duration_ms": 100, "sandbox_duration_ms": 50, "tool_calls": 6, + "intercepts": 1, "findings_total": 1, + "severity_distribution": {"high": 1}, "error_distribution": {}}, + llm_summary="fine", warnings=["w1"]) + + +def test_conclusion_blocked_on_high(): + rep = _report(reported=[Finding(severity="high", category="security", file="a.py", + line=1, title="eval", confidence=0.9)]) + assert rep["conclusion"] == "blocked" + + +def test_conclusion_needs_attention_on_intercepts_only(): + rep = _report() + assert rep["conclusion"] == "needs_attention" + + +def test_conclusion_pass_when_clean(): + rep = _report(events=[GovernanceDecision("ok", "allow")]) + assert rep["conclusion"] == "pass" + + +def test_report_sections_present(): + rep = _report() + for key in ("summary", "findings", "needs_human_review", "filter_intercepts", + "sandbox_runs", "metrics", "llm_summary", "warnings"): + assert key in rep + assert rep["summary"]["needs_human_review_count"] == 1 + assert rep["summary"]["deduplicated"] == 1 + + +def test_markdown_contains_sections(): + md = render_markdown(_report()) + for heading in ("# Code Review Report", "## Findings", "## Needs Human Review", + "## Filter Intercepts", "## Sandbox Runs", "## Metrics"): + assert heading in md + + +def test_write_reports_redacts(tmp_path): + rep = _report(reported=[Finding( + severity="critical", category="secret_leak", file="s.py", line=1, + title="secret", evidence='k = "sk-abcdefghijklmnopqrstuvwxyz123456"', + confidence=0.95)]) + json_path, md_path = write_reports(rep, str(tmp_path)) + text = Path(json_path).read_text() + Path(md_path).read_text() + assert "sk-abcdefghijklmnopqrstuvwxyz123456" not in text + assert json.loads(Path(json_path).read_text())["task_id"] == "t1" diff --git a/examples/skills_code_review_agent/tests/test_sandbox.py b/examples/skills_code_review_agent/tests/test_sandbox.py new file mode 100644 index 00000000..385164a4 --- /dev/null +++ b/examples/skills_code_review_agent/tests/test_sandbox.py @@ -0,0 +1,90 @@ +# 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 Apache-2.0. +"""Tests for SandboxSession using the local workspace runtime.""" +import os +from pathlib import Path + +from review.sandbox import DIFF_WS_PATH, SandboxSession, create_runtime # noqa: F401 + +EXAMPLE_ROOT = Path(__file__).resolve().parents[1] +SKILL_ROOT = EXAMPLE_ROOT / "skills" / "code-review" +FIXTURES = EXAMPLE_ROOT / "fixtures" + + +def _make_tmp_skill(tmp_path, script_name, body): + skill = tmp_path / "code-review" + (skill / "scripts").mkdir(parents=True) + (skill / "SKILL.md").write_text("---\nname: code-review\ndescription: t\n---\nbody\n") + (skill / "scripts" / script_name).write_text(body) + return skill + + +async def _open_session(skill_root, **kw): + runtime = await create_runtime("local") + session = SandboxSession(runtime, str(skill_root), **kw) + await session.open("cr_test_" + os.urandom(4).hex()) + return session + + +async def test_run_parse_diff_in_sandbox(): + session = await _open_session(SKILL_ROOT) + try: + await session.put_diff((FIXTURES / "clean.diff").read_text()) + outcome = await session.run_script("parse_diff.py") + assert outcome.status == "ok", outcome.stderr + assert '"files_changed": 2' in outcome.stdout + finally: + await session.close() + + +async def test_env_whitelist_blocks_host_env(monkeypatch, tmp_path): + monkeypatch.setenv("CR_LEAKED_SECRET", "should-not-be-visible") + skill = _make_tmp_skill(tmp_path, "print_env.py", + "import json, os\nprint(json.dumps(dict(os.environ)))\n") + session = await _open_session(skill) + try: + await session.put_diff("") + outcome = await session.run_script("print_env.py", args=()) + assert outcome.status == "ok", outcome.stderr + assert "CR_LEAKED_SECRET" not in outcome.stdout + finally: + await session.close() + + +async def test_timeout_is_enforced(tmp_path): + skill = _make_tmp_skill(tmp_path, "sleepy.py", "import time\ntime.sleep(30)\n") + session = await _open_session(skill, timeout_sec=2.0) + try: + await session.put_diff("") + outcome = await session.run_script("sleepy.py", args=()) + assert outcome.timed_out is True + assert outcome.status == "timeout" + finally: + await session.close() + + +async def test_output_size_cap(tmp_path): + skill = _make_tmp_skill(tmp_path, "noisy.py", "print('x' * 1000000)\n") + session = await _open_session(skill, max_output_bytes=1024) + try: + await session.put_diff("") + outcome = await session.run_script("noisy.py", args=()) + assert len(outcome.stdout) <= 1024 + assert outcome.truncated is True + finally: + await session.close() + + +async def test_failing_script_reports_failed(tmp_path): + skill = _make_tmp_skill(tmp_path, "broken.py", "import sys\nsys.exit(3)\n") + session = await _open_session(skill) + try: + await session.put_diff("") + outcome = await session.run_script("broken.py", args=()) + assert outcome.status == "failed" + assert outcome.exit_code == 3 + finally: + await session.close() diff --git a/examples/skills_code_review_agent/tests/test_store.py b/examples/skills_code_review_agent/tests/test_store.py new file mode 100644 index 00000000..058bb28d --- /dev/null +++ b/examples/skills_code_review_agent/tests/test_store.py @@ -0,0 +1,80 @@ +# 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 Apache-2.0. +"""Tests for the SQLite-backed ReviewStore.""" +from review.findings import Finding +from storage.store import ReviewStore + + +def _new_store(tmp_path): + return ReviewStore(db_url=f"sqlite:///{tmp_path}/cr.db") + + +async def test_task_roundtrip(tmp_path): + store = _new_store(tmp_path) + task_id = await store.create_task("fixture", "clean.diff", "local", True) + assert task_id + await store.update_task(task_id, status="completed", + diff_summary={"files_changed": 1}, finished=True) + bundle = await store.get_task_bundle(task_id) + assert bundle["task"]["status"] == "completed" + assert bundle["task"]["diff_summary"]["files_changed"] == 1 + assert bundle["task"]["dry_run"] is True + await store.close() + + +async def test_bundle_collects_all_tables(tmp_path): + store = _new_store(tmp_path) + task_id = await store.create_task("fixture", "x.diff", "local", True) + await store.add_sandbox_run(task_id, script="check_security.py", category="security", + status="ok", exit_code=0, duration_ms=12, timed_out=False, + stdout_summary="{}", stderr_summary="", error_type="") + await store.add_findings(task_id, [Finding( + severity="critical", category="secret_leak", file="a.py", line=3, + title="secret", evidence='key = "sk-abcdefghijklmnopqrstuvwxyz123456"', + confidence=0.95)], status="reported") + await store.add_filter_event(task_id, "rm -rf /", "deny", "risk_command", "destructive command") + await store.add_metrics(task_id, {"total_duration_ms": 1000, "sandbox_duration_ms": 500, + "tool_calls": 6, "intercepts": 1, "findings_total": 1, + "severity_distribution": {"critical": 1}, + "error_distribution": {}}) + await store.add_report(task_id, {"conclusion": "blocked"}, "# report") + bundle = await store.get_task_bundle(task_id) + assert len(bundle["sandbox_runs"]) == 1 + assert len(bundle["findings"]) == 1 + assert len(bundle["filter_events"]) == 1 + assert bundle["metrics"]["tool_calls"] == 6 + assert bundle["report"]["report_json"]["conclusion"] == "blocked" + await store.close() + + +async def test_store_redacts_evidence_and_output(tmp_path): + store = _new_store(tmp_path) + task_id = await store.create_task("fixture", "x.diff", "local", True) + secret = "sk-abcdefghijklmnopqrstuvwxyz123456" + await store.add_findings(task_id, [Finding( + severity="critical", category="secret_leak", file="a.py", line=3, + title="secret", evidence=f'key = "{secret}"', confidence=0.95)], status="reported") + await store.add_sandbox_run(task_id, script="s.py", category="c", status="ok", + exit_code=0, duration_ms=1, timed_out=False, + stdout_summary=f"leaked {secret}", + stderr_summary=f"stderr leaked {secret}", error_type="") + await store.add_filter_event(task_id, target=f"/path/{secret}/config", + decision="deny", rule="risk_secret", reason="file contains secret") + await store.add_report(task_id, {"conclusion": "blocked"}, f"# Report\nSecret was: {secret}") + bundle = await store.get_task_bundle(task_id) + assert secret not in bundle["findings"][0]["evidence"] + assert secret not in bundle["sandbox_runs"][0]["stdout_summary"] + assert secret not in bundle["sandbox_runs"][0]["stderr_summary"] + assert secret not in bundle["filter_events"][0]["target"] + assert secret not in bundle["report"]["report_md"] + await store.close() + + +async def test_unknown_task_returns_none_task(tmp_path): + store = _new_store(tmp_path) + bundle = await store.get_task_bundle("nope") + assert bundle["task"] is None + await store.close()