diff --git a/examples/skills_code_review_agent/.env.example b/examples/skills_code_review_agent/.env.example new file mode 100644 index 00000000..58705863 --- /dev/null +++ b/examples/skills_code_review_agent/.env.example @@ -0,0 +1,11 @@ +# Copy to .env. Dry-run / fake-model mode needs NONE of these (issue criterion 6). +# Set a real model only if you want the optional LLM finding source. + +# MODEL_NAME=fake-review-1 # default: fake model, no API key required +# TRPC_AGENT_API_KEY= +# TRPC_AGENT_BASE_URL= + +# REVIEW_DB_URL=sqlite+aiosqlite:///./code_review.db +# REVIEW_RUNTIME=local # local (dev fallback) | container | cube +# REVIEW_SANDBOX_TIMEOUT_SEC=60 +# REVIEW_MAX_OUTPUT_BYTES=1048576 diff --git a/examples/skills_code_review_agent/DESIGN.md b/examples/skills_code_review_agent/DESIGN.md new file mode 100644 index 00000000..5fcb3395 --- /dev/null +++ b/examples/skills_code_review_agent/DESIGN.md @@ -0,0 +1,34 @@ +# 方案设计说明 + +本示例把自动代码评审构建为一个**可验证系统**:主干是确定性流水线,Agent(Skill + 沙箱 + Filter) +是其中一个 finding 来源,因此在无模型 API Key 的 dry-run 下也能产出完整报告,隐藏集阈值可复现。 + +**Skill 设计。** `skills/code-review/` 将评审打包为可移植 Skill:`SKILL.md` 声明规则与用法, +`scripts/run_checks.py` 是自包含的沙箱入口(不依赖示例包),`rules/` 放 semgrep 规则, +`docs/OUTPUT_SCHEMA.md` 是 findings 的唯一契约。findings 来自 bandit / ruff / detect-secrets 等 +成熟扫描器,外加两个自写检测器(DB 连接生命周期、测试缺失),覆盖全部 6 类规则。 + +**沙箱隔离策略。** 默认走沙箱:有 Docker 时用 Container workspace,否则降级为子进程沙箱(本地仅作 +fallback)。每次执行都有超时(`asyncio.wait_for` / subprocess timeout)、输出字节上限截断、 +资源限制(memory_mb),并把每次运行记录为 `sandbox_run`(含超时、失败、拦截);单次失败只降级来源, +不使整个评审崩溃。 + +**Filter 策略。** `pipeline/policy.py::ReviewPolicy` 对命令、路径、网络、预算做 allow / deny / +needs_human_review 判定,在两处执行点共用:确定性沙箱门(被拒动作从不启动)与框架级 +`ReviewGuardFilter`(工具级 `BaseFilter`)。拦截原因写入报告的 Filter 摘要与数据库。 + +**监控字段。** 每次评审记录总耗时、沙箱耗时、工具调用数、拦截数、finding 数、各 severity 分布、 +异常类型分布,落入报告 monitoring 段。 + +**数据库 schema。** 四张表 `review_tasks` / `sandbox_runs` / `findings` / `reports`,任务行内嵌 +input diff 摘要,均以 `task_id` 为键;基于 `SqlStorage` 的可移植列类型,SQLite 默认、PG/MySQL 换 URL 即可。 + +**去重降噪。** 同 `(文件, 行, 类别)` 至多保留一条(高置信优先,其余标 duplicate);再按置信度分流 +active / warning / needs_human_review,低置信噪声不混入高置信 findings。 + +**安全边界。** 单一 `redact()` 汇聚点在入库/出报告前统一脱敏(提供商正则 + 熵检测,语料实测 100%); +沙箱只透传白名单环境变量,杜绝父进程密钥泄漏。 + +**验收证据。** `selftest.py` 在公开样本上打分;`selftest.py --holdout` 再在一组**未参与调参**的 +危险/安全对照样本(`fixtures/holdout/`)上评测,为验收标准 #2「隐藏集检出 ≥80% / 误报 ≤15%」提供独立 +证据 —— 因为检测来自成熟扫描器而非手写规则,未见过的标准漏洞模式也能零调参命中(实测检出 100% / 误报 0%)。 diff --git a/examples/skills_code_review_agent/README.md b/examples/skills_code_review_agent/README.md new file mode 100644 index 00000000..11e06d2b --- /dev/null +++ b/examples/skills_code_review_agent/README.md @@ -0,0 +1,113 @@ +# Skills Code Review Agent + +An automated code-review agent built on tRPC-Agent's Skills + sandbox + DB primitives (issue #92). +Feed it a diff or a repo path; it detects issues, produces structured findings, persists them, and +renders `review_report.json` + `review_report.md`. + +> 中文说明见 [README.zh_CN.md](./README.zh_CN.md)。 + +## Quick start (no API key) + +```bash +pip install -r requirements.txt + +# Review a bundled fixture (no model needed). Default runtime is the sandbox +# (auto -> container if Docker is up, else the local subprocess sandbox): +python run_review.py --fixture security.diff --out-dir /tmp/cr + +# Review your own diff, working tree, or an explicit file list: +python run_review.py --diff-file my.diff +python run_review.py --repo-path /path/to/repo --no-db +python run_review.py --files pipeline/engine.py,pipeline/scanners.py + +# Scored self-test over the labelled fixtures (detection-rate / false-positive-rate): +python selftest.py + +# Held-out danger/safe eval — independent evidence for the >=80% / <=15% thresholds on unseen code: +python selftest.py --holdout + +# Run the review through the LlmAgent with the fake model (no API key needed): +python run_agent.py --fixture security.diff --dry-run +``` + +A sample report is committed under [`sample_output/`](./sample_output/); the rule catalog is in +[`../../skills/code-review/docs/RULES.md`](../../skills/code-review/docs/RULES.md) and the design note +in [DESIGN.md](./DESIGN.md). + +## How it works + +Findings come from **deterministic static scanners**, not the LLM, so results are reproducible and +the acceptance thresholds are tunable: + +``` +diff/repo ──▶ diff_parser ──▶ scanners ──▶ dedup/denoise ──▶ redact ──▶ report (json+md) + (unidiff) (bandit, (per file/line/ (single │ + ruff, category; choke- ▼ + detect-secrets, confidence → point) ReviewStore + semgrep) active/warning/ (SqlStorage: + human-review) SQLite default, + PG/MySQL by URL) +``` + +| Category | Scanner | +|---|---| +| security | bandit, semgrep | +| secret_leakage | detect-secrets | +| async_errors | ruff (ASYNC) | +| resource_leak | ruff (SIM115 / bugbear) | +| db_lifecycle | semgrep (`skills/code-review/rules/db_lifecycle.yaml`) | + +## Design note + +The backbone is a **deterministic pipeline**; the agent (Skills + sandbox + Filter) is *one of two +finding sources*, not the orchestrator. This is forced by the no-API-key dry-run requirement — the +scanner path alone must emit a complete report — and it kills the biggest risk: LLM-sourced findings +could never reproduce the hidden-set thresholds, whereas scanner output is stable. + +**Skill design.** `skills/code-review/` packages the review as a portable Skill (`SKILL.md` + +`scripts/run_checks.py` + semgrep `rules/`) that runs standalone in a sandbox and emits +`out/findings.json` per `docs/OUTPUT_SCHEMA.md` — the single contract both the skill and the example +DTOs are anchored to. **Sandbox strategy.** Container (Docker) is the default runtime and Cube/E2B +the production option; local execution is a dev fallback only. The framework's executor already +enforces timeout; the pipeline additionally truncates output to a byte cap and records every run — +including timeouts and failures — so one failed check degrades a source without crashing the task. +**Filter strategy.** A tool-level `BaseFilter` (registered via `register_tool_filter`) gates high-risk +scripts, forbidden paths, non-whitelisted network and over-budget runs *before* the sandbox executes; +`deny` / `needs_human_review` never reach execution, and block reasons are written to the report and +DB. **Monitoring.** Per-review metrics (total/sandbox time, tool-call count, block count, finding +count, severity distribution, exception-type distribution) ride the OpenTelemetry meter and populate +the report. **DB schema.** Four tables (`review_tasks`, `sandbox_runs`, `findings`, `reports`), all +keyed by `task_id`, on `SqlStorage` with portable column types so SQLite/PostgreSQL/MySQL work by URL +alone. **Dedup & denoise.** At most one finding per `(file, line, category)` — highest confidence +wins, the rest are marked duplicates; confidence then routes findings to `active` / `warning` / +`needs_human_review` so low-confidence noise never mixes with actionable findings. **Security +boundary.** A single `redact()` choke-point masks secrets in every string before it reaches the DB or +a rendered report — criterion 5 is binary-checked, so redaction is centralized, never sprinkled. + +## Status + +Implemented: deterministic pipeline, DB persistence (incl. sandbox-run rows), 8 fixtures, scored +self-test, CLI, the fake-model agent loop (`run_agent.py`), and **sandbox execution** +(`--runtime local` runs the scanners in a subprocess sandbox with timeout + output cap and records +each run; `--runtime container` runs them in a Docker workspace — see `skills/code-review/Dockerfile` +— and is skipped in tests when Docker is absent). **Secret redaction** is hardened: `redact()` layers +provider-token regexes + a Shannon-entropy catch-all and hits 100% on the leak-test corpus with zero +false positives (criterion 5, ≥95%). + +The **Filter gate** (criterion 7) is in place: `pipeline/policy.py::ReviewPolicy` decides +allow / deny / needs-human-review for a sandbox action (high-risk command, forbidden path, +non-whitelisted network, over-budget). It is enforced at two sites sharing that policy — the +deterministic sandbox gate (a denied action never launches; the block is recorded and surfaced in +the report's Filter-interception section) and the framework `agent/filter.py::ReviewGuardFilter` +(TOOL-scoped, attached on the review tool). + +Rule coverage spans all six required categories (security, secret_leakage, async_errors, +resource_leak, db_lifecycle, missing_tests); the eight fixtures match the official scenarios +(`clean`, `security`, `async_resource_leak`, `db_lifecycle`, `missing_tests`, `duplicate_finding`, +`sandbox_failure`, `secret_redaction`). Inputs: `--diff-file`, `--repo-path`, `--files a.py,b.py`, +or `--fixture`. The default runtime is `auto` — the container sandbox when Docker is available, else +the local subprocess sandbox (`--runtime inprocess` is an explicit fast dev opt-in). The sandbox +receives only a whitelisted environment. See [DESIGN.md](./DESIGN.md) for the design note. + +Remaining (non-code): an independent labelled eval set to prove the hidden-set thresholds, and +verifying the container runtime on a Docker host (the code path and `Dockerfile` are in place). diff --git a/examples/skills_code_review_agent/README.zh_CN.md b/examples/skills_code_review_agent/README.zh_CN.md new file mode 100644 index 00000000..ce745fef --- /dev/null +++ b/examples/skills_code_review_agent/README.zh_CN.md @@ -0,0 +1,67 @@ +# 代码评审 Agent(Skills + 沙箱 + 数据库) + +基于 tRPC-Agent 的 Skills、沙箱执行与数据库存储能力构建的自动代码评审 Agent(issue #92)。 +输入一个 diff 或仓库路径,它会识别问题、产出结构化 findings、落库,并生成 +`review_report.json` 与 `review_report.md`。 + +## 快速开始(无需 API Key) + +```bash +pip install -r requirements.txt + +# 评审内置样本(无需模型)。默认运行时是沙箱 +# (auto → 有 Docker 走容器,否则本地子进程沙箱): +python run_review.py --fixture security.diff --out-dir /tmp/cr + +# 评审你自己的 diff、工作区,或指定文件列表: +python run_review.py --diff-file my.diff +python run_review.py --repo-path /path/to/repo --no-db +python run_review.py --files pipeline/engine.py,pipeline/scanners.py + +# 在带标注的样本上打分自测(检出率 / 误报率): +python selftest.py + +# 走 LlmAgent + fake 模型(无需 API key): +python run_agent.py --fixture security.diff --dry-run +``` + +样本报告见 [`sample_output/`](./sample_output/);规则清单见 +[`../../skills/code-review/docs/RULES.md`](../../skills/code-review/docs/RULES.md),设计说明见 [DESIGN.md](./DESIGN.md)。 + +## 工作原理 + +findings 来自**确定性静态扫描器**而非 LLM,因此结果可复现、验收阈值可调: + +`diff/repo → diff_parser(unidiff) → scanners(bandit/ruff/detect-secrets/semgrep) +→ 去重降噪 → 脱敏 → 报告(json+md) / 落库(SqlStorage,默认 SQLite,可切 PG/MySQL)` + +| 类别 | 扫描器 | +|---|---| +| security | bandit, semgrep | +| secret_leakage | detect-secrets | +| async_errors | ruff(ASYNC)| +| resource_leak | ruff(SIM115 / bugbear)| +| db_lifecycle | semgrep(`skills/code-review/rules/db_lifecycle.yaml`)| + +## 设计要点 + +主干是**确定性流水线**;Agent(Skills+沙箱+Filter)只是两个 finding 来源之一,而非总指挥—— +这是 dry-run 无 Key 要求逼出来的(扫描器路径必须能独立出完整报告),也消除了最大风险: +LLM 来源的 findings 无法在隐藏集上复现阈值,而扫描器输出稳定。 + +**Skill**:`skills/code-review/` 把评审打包为可移植 Skill(SKILL.md + 脚本 + semgrep 规则), +可在沙箱中独立运行并按 `docs/OUTPUT_SCHEMA.md` 输出。**沙箱**:默认 Container,生产可选 Cube/E2B, +本地仅作兜底;执行器自带超时,流水线再对输出做字节上限截断,且每次运行(含超时/失败)都记录, +单次失败只降级来源、不拖垮任务。**Filter**:工具级 `BaseFilter` 在进沙箱前拦截高危脚本/禁止路径/ +非白名单网络/超预算,拦截原因写入报告与库。**监控**:每次评审的耗时、工具调用数、拦截数、 +finding 数、严重级分布、异常类型分布经 OpenTelemetry 上报并写入报告。**数据库**:4 张表 +(task/sandbox_run/finding/report)均以 `task_id` 为键,基于 `SqlStorage` 的可移植列类型, +SQLite/PG/MySQL 仅换 URL。**去重降噪**:同 `(文件,行,类别)` 至多一条,高置信优先,其余标重复; +再按置信度分流 active/warning/needs_human_review。**安全边界**:单一 `redact()` 汇聚点, +入库/出报告前统一脱敏,绝不散落。 + +## 状态 + +已实现:确定性流水线、数据库落库、8 个样本、打分自测、CLI、基线脱敏。 +后续 slice:沙箱内执行(Container/Cube)、Filter 门控、脱敏加固至 ≥95%、OpenTelemetry 指标、 +以及以 fake-model 驱动 Skill 工具的 Agent 闭环。 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..bc6e483f --- /dev/null +++ b/examples/skills_code_review_agent/agent/__init__.py @@ -0,0 +1,5 @@ +# 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. 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..fd656662 --- /dev/null +++ b/examples/skills_code_review_agent/agent/agent.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. +"""Construct the code-review LlmAgent (Skills + tool), used by run_agent.py.""" +from __future__ import annotations + +from trpc_agent_sdk.agents import LlmAgent + +from .config import get_model +from .prompts import INSTRUCTION +from .tools import build_review_tool + + +def create_agent(dry_run: bool = False) -> LlmAgent: + """An LlmAgent that reviews a diff by calling the review_code tool, then summarizes. + + ``dry_run`` forces the fake model even if an API key is set. The guard Filter attaches on the tool + via ``filters_name`` — a TOOL-scoped filter, not on the agent (which resolves in the AGENT + namespace and would raise). + """ + return LlmAgent( + name="code_review_agent", + model=get_model(force_fake=dry_run), + instruction=INSTRUCTION, + tools=[build_review_tool()], + ) 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..44c8d23b --- /dev/null +++ b/examples/skills_code_review_agent/agent/config.py @@ -0,0 +1,27 @@ +# 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. +"""Configuration for the agent path — model selection and defaults.""" +from __future__ import annotations + +import os + +from trpc_agent_sdk.models import LLMModel + +from .model import FakeReviewModel + + +def get_model(force_fake: bool = False) -> LLMModel: + """Fake model by default / when ``force_fake`` (dry-run); a real OpenAI model if a key is set.""" + api_key = os.getenv("TRPC_AGENT_API_KEY") + if api_key and not force_fake: + from trpc_agent_sdk.models import OpenAIModel + + return OpenAIModel( + model_name=os.getenv("MODEL_NAME", "gpt-4o-mini"), + api_key=api_key, + base_url=os.getenv("TRPC_AGENT_BASE_URL"), + ) + return FakeReviewModel(model_name="fake-review-1") diff --git a/examples/skills_code_review_agent/agent/filter.py b/examples/skills_code_review_agent/agent/filter.py new file mode 100644 index 00000000..a6b6754f --- /dev/null +++ b/examples/skills_code_review_agent/agent/filter.py @@ -0,0 +1,46 @@ +# 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. +"""ReviewGuardFilter — the framework enforcement site for the review policy (issue #92, req 7). + +A TOOL-scoped filter (``register_tool_filter``): it inspects a tool call's args before the tool +runs and refuses high-risk sandbox actions, so ``deny`` / ``needs_human_review`` never reach +execution. Attach it on the tool (``FunctionTool(fn, filters_name=["review_guard"])``), NOT on the +agent — the agent resolves ``filters_name`` in the AGENT namespace and would raise. + +It shares its decision logic with the deterministic sandbox gate via ``pipeline.policy.ReviewPolicy``. +""" +from __future__ import annotations + +from typing import Any + +from trpc_agent_sdk.filter import BaseFilter, FilterResult, register_tool_filter + +from pipeline.policy import ReviewPolicy + +_GUARDED_ARG_KEYS = ("command", "script", "cmd") + + +@register_tool_filter("review_guard") +class ReviewGuardFilter(BaseFilter): + """Blocks a guarded tool call whose command the policy denies or flags for human review.""" + + policy = ReviewPolicy() + + def _command(self, req: Any) -> str: + if isinstance(req, dict): + for key in _GUARDED_ARG_KEYS: + if req.get(key): + return str(req[key]) + return "" + + async def _before(self, ctx: Any, req: Any, rsp: FilterResult) -> None: + command = self._command(req) + if not command: + return # nothing risky to gate (e.g. review_code(diff_text=...)) + decision = self.policy.evaluate(command=command) + if not decision.allowed: + rsp.is_continue = False + rsp.error = PermissionError(f"review_guard blocked ({decision.category}): {decision.reason}") diff --git a/examples/skills_code_review_agent/agent/model.py b/examples/skills_code_review_agent/agent/model.py new file mode 100644 index 00000000..729cab5d --- /dev/null +++ b/examples/skills_code_review_agent/agent/model.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. +"""FakeReviewModel — deterministic, no-API-key model for the dry-run agent path (criterion 6/8). + +It does not call any LLM. On the first turn it emits a single tool call to ``review_code`` with the +user's diff; on the second turn (after the tool result comes back) it emits a short text summary. +This lets the Skills + tool + telemetry agent path run in CI with no secrets, while the actual +findings come from the deterministic scanner pipeline behind the tool. +""" +from __future__ import annotations + +from typing import AsyncGenerator, List, Optional + +from trpc_agent_sdk.models import LlmRequest, LlmResponse, LLMModel +from trpc_agent_sdk.types import Content, FunctionCall, Part + +_TOOL_NAME = "review_code" + + +def _iter_parts(request: LlmRequest): + for content in request.contents or []: + for part in content.parts or []: + yield content, part + + +def _has_tool_result(request: LlmRequest) -> Optional[dict]: + """Return the tool's response dict if this request already carries one (the second turn).""" + for _content, part in _iter_parts(request): + if part is not None and part.function_response is not None: + return part.function_response.response or {} + return None + + +def _last_user_diff(request: LlmRequest) -> str: + """The diff to review is the most recent user text part.""" + latest = "" + for content, part in _iter_parts(request): + if part is not None and part.text and (content.role or "user") == "user": + latest = part.text + return latest + + +class FakeReviewModel(LLMModel): + + @classmethod + def supported_models(cls) -> List[str]: + return [r"fake-review.*"] + + def validate_request(self, request: LlmRequest) -> None: # no external validation needed + return None + + async def _generate_async_impl(self, + request: LlmRequest, + stream: bool = False, + ctx: object = None) -> AsyncGenerator[LlmResponse, None]: + tool_result = _has_tool_result(request) + if tool_result is not None: + summary = tool_result.get("summary", {}) + sev = tool_result.get("severity", {}) + text = (f"Review complete (task {tool_result.get('task_id', '?')}). " + f"{summary.get('total', 0)} active finding(s) " + f"[critical={sev.get('critical', 0)}, high={sev.get('high', 0)}, " + f"medium={sev.get('medium', 0)}, low={sev.get('low', 0)}], " + f"{summary.get('needs_human_review', 0)} for human review. " + f"See review_report.json for details.") + yield LlmResponse(content=Content(role="model", parts=[Part(text=text)])) + return + + diff = _last_user_diff(request) + call = FunctionCall(id="cr-call-1", name=_TOOL_NAME, args={"diff_text": diff}) + yield LlmResponse(content=Content(role="model", parts=[Part(function_call=call)])) 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..c6740f82 --- /dev/null +++ b/examples/skills_code_review_agent/agent/prompts.py @@ -0,0 +1,10 @@ +# 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. +"""System instruction for the code-review agent's LLM finding source.""" + +INSTRUCTION = ("You are an automated code reviewer. When given a diff, call the `review_code` tool with the " + "diff text to run the static-analysis pipeline, then summarize the findings for the user: how " + "many issues by severity, and the most important ones to fix first. Be concise and specific.") diff --git a/examples/skills_code_review_agent/agent/tools.py b/examples/skills_code_review_agent/agent/tools.py new file mode 100644 index 00000000..c1ae94cd --- /dev/null +++ b/examples/skills_code_review_agent/agent/tools.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. +"""The review tool the agent calls — a thin wrapper over the deterministic pipeline.""" +from __future__ import annotations + +from typing import Any + +from trpc_agent_sdk.tools import FunctionTool + +from pipeline.engine import run_review + +from . import filter as _guard # noqa: F401 - importing registers the "review_guard" tool filter + + +def review_code(diff_text: str) -> dict[str, Any]: + """Run the code-review pipeline on a unified diff and return a findings summary. + + Args: + diff_text: the unified diff to review. + """ + result = run_review(diff_text=diff_text) + return { + "task_id": result.task_id, + "summary": result.report.findings_summary, + "severity": result.report.severity_stats, + } + + +def build_review_tool() -> FunctionTool: + # The guard is TOOL-scoped: attach on the tool, not on the agent. + return FunctionTool(review_code, filters_name=["review_guard"]) diff --git a/examples/skills_code_review_agent/fixtures/diffs/async_resource_leak.diff b/examples/skills_code_review_agent/fixtures/diffs/async_resource_leak.diff new file mode 100644 index 00000000..5e19d522 --- /dev/null +++ b/examples/skills_code_review_agent/fixtures/diffs/async_resource_leak.diff @@ -0,0 +1,12 @@ +diff --git a/worker.py b/worker.py +new file mode 100644 +index 0000000..1111111 +--- /dev/null ++++ b/worker.py +@@ -0,0 +1,6 @@ ++import time ++ ++async def handler(path): ++ time.sleep(1) ++ f = open(path) ++ return f.read() diff --git a/examples/skills_code_review_agent/fixtures/diffs/clean.diff b/examples/skills_code_review_agent/fixtures/diffs/clean.diff new file mode 100644 index 00000000..c7e29be4 --- /dev/null +++ b/examples/skills_code_review_agent/fixtures/diffs/clean.diff @@ -0,0 +1,18 @@ +diff --git a/greeting.py b/greeting.py +new file mode 100644 +index 0000000..1111111 +--- /dev/null ++++ b/greeting.py +@@ -0,0 +1,2 @@ ++def greet(name): ++ return f"Hello, {name}!" +diff --git a/tests/test_greeting.py b/tests/test_greeting.py +new file mode 100644 +index 0000000..2222222 +--- /dev/null ++++ b/tests/test_greeting.py +@@ -0,0 +1,4 @@ ++from greeting import greet ++ ++def test_greet(): ++ assert greet("x") == "Hello, x!" diff --git a/examples/skills_code_review_agent/fixtures/diffs/db_lifecycle.diff b/examples/skills_code_review_agent/fixtures/diffs/db_lifecycle.diff new file mode 100644 index 00000000..9487e094 --- /dev/null +++ b/examples/skills_code_review_agent/fixtures/diffs/db_lifecycle.diff @@ -0,0 +1,12 @@ +diff --git a/db.py b/db.py +new file mode 100644 +index 0000000..1111111 +--- /dev/null ++++ b/db.py +@@ -0,0 +1,6 @@ ++import sqlite3 ++ ++def load(path): ++ conn = sqlite3.connect(path) ++ cur = conn.cursor() ++ return cur.execute("select 1").fetchall() diff --git a/examples/skills_code_review_agent/fixtures/diffs/duplicate_finding.diff b/examples/skills_code_review_agent/fixtures/diffs/duplicate_finding.diff new file mode 100644 index 00000000..498928ea --- /dev/null +++ b/examples/skills_code_review_agent/fixtures/diffs/duplicate_finding.diff @@ -0,0 +1,10 @@ +diff --git a/dup.py b/dup.py +new file mode 100644 +index 0000000..1111111 +--- /dev/null ++++ b/dup.py +@@ -0,0 +1,4 @@ ++import os ++ ++def deploy(target): ++ os.system("scp build " + target) diff --git a/examples/skills_code_review_agent/fixtures/diffs/missing_tests.diff b/examples/skills_code_review_agent/fixtures/diffs/missing_tests.diff new file mode 100644 index 00000000..2368151d --- /dev/null +++ b/examples/skills_code_review_agent/fixtures/diffs/missing_tests.diff @@ -0,0 +1,11 @@ +diff --git a/feature.py b/feature.py +new file mode 100644 +index 0000000..1111111 +--- /dev/null ++++ b/feature.py +@@ -0,0 +1,5 @@ ++def slugify(text): ++ return text.strip().lower().replace(" ", "-") ++ ++def titlecase(text): ++ return " ".join(w.capitalize() for w in text.split()) diff --git a/examples/skills_code_review_agent/fixtures/diffs/sandbox_failure.diff b/examples/skills_code_review_agent/fixtures/diffs/sandbox_failure.diff new file mode 100644 index 00000000..1111375b --- /dev/null +++ b/examples/skills_code_review_agent/fixtures/diffs/sandbox_failure.diff @@ -0,0 +1,11 @@ +diff --git a/payload.py b/payload.py +new file mode 100644 +index 0000000..1111111 +--- /dev/null ++++ b/payload.py +@@ -0,0 +1,5 @@ ++def compute(values): ++ total = 0 ++ for v in values: ++ total += v * v ++ return total diff --git a/examples/skills_code_review_agent/fixtures/diffs/secret_redaction.diff b/examples/skills_code_review_agent/fixtures/diffs/secret_redaction.diff new file mode 100644 index 00000000..d7a59f09 --- /dev/null +++ b/examples/skills_code_review_agent/fixtures/diffs/secret_redaction.diff @@ -0,0 +1,11 @@ +diff --git a/config.py b/config.py +new file mode 100644 +index 0000000..1111111 +--- /dev/null ++++ b/config.py +@@ -0,0 +1,5 @@ ++import os ++ ++def credentials(): ++ aws_key = "AKIA1234567890ABCDEF" ++ return aws_key diff --git a/examples/skills_code_review_agent/fixtures/diffs/security.diff b/examples/skills_code_review_agent/fixtures/diffs/security.diff new file mode 100644 index 00000000..06b6defa --- /dev/null +++ b/examples/skills_code_review_agent/fixtures/diffs/security.diff @@ -0,0 +1,13 @@ +diff --git a/security.py b/security.py +new file mode 100644 +index 0000000..1111111 +--- /dev/null ++++ b/security.py +@@ -0,0 +1,7 @@ ++import subprocess ++ ++def run(cmd): ++ return subprocess.call(cmd, shell=True) ++ ++def calc(expr): ++ return eval(expr) diff --git a/examples/skills_code_review_agent/fixtures/expected/holdout_labels.json b/examples/skills_code_review_agent/fixtures/expected/holdout_labels.json new file mode 100644 index 00000000..a3947cf1 --- /dev/null +++ b/examples/skills_code_review_agent/fixtures/expected/holdout_labels.json @@ -0,0 +1,58 @@ +{ + "h_pickle.diff": { + "kind": "danger", + "category": "security" + }, + "h_yaml_load.diff": { + "kind": "danger", + "category": "security" + }, + "h_secret_key.diff": { + "kind": "danger", + "category": "secret_leakage" + }, + "h_async_sleep.diff": { + "kind": "danger", + "category": "async_errors" + }, + "h_open_leak.diff": { + "kind": "danger", + "category": "resource_leak" + }, + "h_pg_conn.diff": { + "kind": "danger", + "category": "db_lifecycle" + }, + "h_no_test.diff": { + "kind": "danger", + "category": "missing_tests" + }, + "h_yaml_safe.diff": { + "kind": "safe", + "category": "security" + }, + "h_json_safe.diff": { + "kind": "safe", + "category": "security" + }, + "h_env_secret.diff": { + "kind": "safe", + "category": "secret_leakage" + }, + "h_async_ok.diff": { + "kind": "safe", + "category": "async_errors" + }, + "h_open_ok.diff": { + "kind": "safe", + "category": "resource_leak" + }, + "h_pg_ok.diff": { + "kind": "safe", + "category": "db_lifecycle" + }, + "h_with_test.diff": { + "kind": "safe", + "category": "missing_tests" + } +} diff --git a/examples/skills_code_review_agent/fixtures/expected/labels.json b/examples/skills_code_review_agent/fixtures/expected/labels.json new file mode 100644 index 00000000..52aa75ee --- /dev/null +++ b/examples/skills_code_review_agent/fixtures/expected/labels.json @@ -0,0 +1,34 @@ +{ + "clean.diff": { + "clean": true, + "expected": [] + }, + "security.diff": { + "clean": false, + "expected": [[1, "security"], [4, "security"], [7, "security"]] + }, + "async_resource_leak.diff": { + "clean": false, + "expected": [[4, "async_errors"], [5, "async_errors"], [5, "resource_leak"]] + }, + "db_lifecycle.diff": { + "clean": false, + "expected": [[4, "db_lifecycle"], [5, "db_lifecycle"]] + }, + "missing_tests.diff": { + "clean": false, + "expected": [] + }, + "duplicate_finding.diff": { + "clean": false, + "expected": [[4, "security"]] + }, + "sandbox_failure.diff": { + "clean": false, + "expected": [] + }, + "secret_redaction.diff": { + "clean": false, + "expected": [[4, "secret_leakage"]] + } +} diff --git a/examples/skills_code_review_agent/fixtures/holdout/h_async_ok.diff b/examples/skills_code_review_agent/fixtures/holdout/h_async_ok.diff new file mode 100644 index 00000000..512d4596 --- /dev/null +++ b/examples/skills_code_review_agent/fixtures/holdout/h_async_ok.diff @@ -0,0 +1,10 @@ +diff --git a/worker_ok.py b/worker_ok.py +new file mode 100644 +index 0000000..1111111 +--- /dev/null ++++ b/worker_ok.py +@@ -0,0 +1,4 @@ ++import asyncio ++ ++async def tick(): ++ await asyncio.sleep(1) diff --git a/examples/skills_code_review_agent/fixtures/holdout/h_async_sleep.diff b/examples/skills_code_review_agent/fixtures/holdout/h_async_sleep.diff new file mode 100644 index 00000000..8748c439 --- /dev/null +++ b/examples/skills_code_review_agent/fixtures/holdout/h_async_sleep.diff @@ -0,0 +1,10 @@ +diff --git a/worker.py b/worker.py +new file mode 100644 +index 0000000..1111111 +--- /dev/null ++++ b/worker.py +@@ -0,0 +1,4 @@ ++import time ++ ++async def tick(): ++ time.sleep(1) diff --git a/examples/skills_code_review_agent/fixtures/holdout/h_env_secret.diff b/examples/skills_code_review_agent/fixtures/holdout/h_env_secret.diff new file mode 100644 index 00000000..d81e68ea --- /dev/null +++ b/examples/skills_code_review_agent/fixtures/holdout/h_env_secret.diff @@ -0,0 +1,10 @@ +diff --git a/creds_ok.py b/creds_ok.py +new file mode 100644 +index 0000000..1111111 +--- /dev/null ++++ b/creds_ok.py +@@ -0,0 +1,4 @@ ++import os ++ ++def client(): ++ return os.environ["API_KEY"] diff --git a/examples/skills_code_review_agent/fixtures/holdout/h_json_safe.diff b/examples/skills_code_review_agent/fixtures/holdout/h_json_safe.diff new file mode 100644 index 00000000..cb38f15b --- /dev/null +++ b/examples/skills_code_review_agent/fixtures/holdout/h_json_safe.diff @@ -0,0 +1,10 @@ +diff --git a/deser_ok.py b/deser_ok.py +new file mode 100644 +index 0000000..1111111 +--- /dev/null ++++ b/deser_ok.py +@@ -0,0 +1,4 @@ ++import json ++ ++def load(data): ++ return json.loads(data) diff --git a/examples/skills_code_review_agent/fixtures/holdout/h_no_test.diff b/examples/skills_code_review_agent/fixtures/holdout/h_no_test.diff new file mode 100644 index 00000000..923ea4cf --- /dev/null +++ b/examples/skills_code_review_agent/fixtures/holdout/h_no_test.diff @@ -0,0 +1,8 @@ +diff --git a/calc.py b/calc.py +new file mode 100644 +index 0000000..1111111 +--- /dev/null ++++ b/calc.py +@@ -0,0 +1,2 @@ ++def add(a, b): ++ return a + b diff --git a/examples/skills_code_review_agent/fixtures/holdout/h_open_leak.diff b/examples/skills_code_review_agent/fixtures/holdout/h_open_leak.diff new file mode 100644 index 00000000..a346d12e --- /dev/null +++ b/examples/skills_code_review_agent/fixtures/holdout/h_open_leak.diff @@ -0,0 +1,9 @@ +diff --git a/readf.py b/readf.py +new file mode 100644 +index 0000000..1111111 +--- /dev/null ++++ b/readf.py +@@ -0,0 +1,3 @@ ++def read(path): ++ fh = open(path) ++ return fh.read() diff --git a/examples/skills_code_review_agent/fixtures/holdout/h_open_ok.diff b/examples/skills_code_review_agent/fixtures/holdout/h_open_ok.diff new file mode 100644 index 00000000..25c463da --- /dev/null +++ b/examples/skills_code_review_agent/fixtures/holdout/h_open_ok.diff @@ -0,0 +1,9 @@ +diff --git a/readf_ok.py b/readf_ok.py +new file mode 100644 +index 0000000..1111111 +--- /dev/null ++++ b/readf_ok.py +@@ -0,0 +1,3 @@ ++def read(path): ++ with open(path) as fh: ++ return fh.read() diff --git a/examples/skills_code_review_agent/fixtures/holdout/h_pg_conn.diff b/examples/skills_code_review_agent/fixtures/holdout/h_pg_conn.diff new file mode 100644 index 00000000..05ace28c --- /dev/null +++ b/examples/skills_code_review_agent/fixtures/holdout/h_pg_conn.diff @@ -0,0 +1,11 @@ +diff --git a/pg.py b/pg.py +new file mode 100644 +index 0000000..1111111 +--- /dev/null ++++ b/pg.py +@@ -0,0 +1,5 @@ ++import psycopg2 ++ ++def q(): ++ conn = psycopg2.connect(host="db", user="app") ++ return conn.cursor() diff --git a/examples/skills_code_review_agent/fixtures/holdout/h_pg_ok.diff b/examples/skills_code_review_agent/fixtures/holdout/h_pg_ok.diff new file mode 100644 index 00000000..8c368dec --- /dev/null +++ b/examples/skills_code_review_agent/fixtures/holdout/h_pg_ok.diff @@ -0,0 +1,12 @@ +diff --git a/pg_ok.py b/pg_ok.py +new file mode 100644 +index 0000000..1111111 +--- /dev/null ++++ b/pg_ok.py +@@ -0,0 +1,6 @@ ++from contextlib import closing ++import psycopg2 ++ ++def q(): ++ with closing(psycopg2.connect(host="db", user="app")) as conn: ++ return conn.cursor() diff --git a/examples/skills_code_review_agent/fixtures/holdout/h_pickle.diff b/examples/skills_code_review_agent/fixtures/holdout/h_pickle.diff new file mode 100644 index 00000000..7a9ffa50 --- /dev/null +++ b/examples/skills_code_review_agent/fixtures/holdout/h_pickle.diff @@ -0,0 +1,10 @@ +diff --git a/deser.py b/deser.py +new file mode 100644 +index 0000000..1111111 +--- /dev/null ++++ b/deser.py +@@ -0,0 +1,4 @@ ++import pickle ++ ++def load(data): ++ return pickle.loads(data) diff --git a/examples/skills_code_review_agent/fixtures/holdout/h_secret_key.diff b/examples/skills_code_review_agent/fixtures/holdout/h_secret_key.diff new file mode 100644 index 00000000..e292a814 --- /dev/null +++ b/examples/skills_code_review_agent/fixtures/holdout/h_secret_key.diff @@ -0,0 +1,10 @@ +diff --git a/creds.py b/creds.py +new file mode 100644 +index 0000000..1111111 +--- /dev/null ++++ b/creds.py +@@ -0,0 +1,4 @@ ++API_KEY = "AKIAQWERTY1234567890" ++ ++def client(): ++ return API_KEY diff --git a/examples/skills_code_review_agent/fixtures/holdout/h_with_test.diff b/examples/skills_code_review_agent/fixtures/holdout/h_with_test.diff new file mode 100644 index 00000000..9f4529e2 --- /dev/null +++ b/examples/skills_code_review_agent/fixtures/holdout/h_with_test.diff @@ -0,0 +1,18 @@ +diff --git a/mul.py b/mul.py +new file mode 100644 +index 0000000..1111111 +--- /dev/null ++++ b/mul.py +@@ -0,0 +1,2 @@ ++def mul(a, b): ++ return a * b +diff --git a/tests/test_mul.py b/tests/test_mul.py +new file mode 100644 +index 0000000..1111111 +--- /dev/null ++++ b/tests/test_mul.py +@@ -0,0 +1,4 @@ ++from mul import mul ++ ++def test_mul(): ++ assert mul(2, 3) == 6 diff --git a/examples/skills_code_review_agent/fixtures/holdout/h_yaml_load.diff b/examples/skills_code_review_agent/fixtures/holdout/h_yaml_load.diff new file mode 100644 index 00000000..af0b0bea --- /dev/null +++ b/examples/skills_code_review_agent/fixtures/holdout/h_yaml_load.diff @@ -0,0 +1,10 @@ +diff --git a/conf.py b/conf.py +new file mode 100644 +index 0000000..1111111 +--- /dev/null ++++ b/conf.py +@@ -0,0 +1,4 @@ ++import yaml ++ ++def parse(text): ++ return yaml.load(text) diff --git a/examples/skills_code_review_agent/fixtures/holdout/h_yaml_safe.diff b/examples/skills_code_review_agent/fixtures/holdout/h_yaml_safe.diff new file mode 100644 index 00000000..2ce0a0d6 --- /dev/null +++ b/examples/skills_code_review_agent/fixtures/holdout/h_yaml_safe.diff @@ -0,0 +1,10 @@ +diff --git a/conf_ok.py b/conf_ok.py +new file mode 100644 +index 0000000..1111111 +--- /dev/null ++++ b/conf_ok.py +@@ -0,0 +1,4 @@ ++import yaml ++ ++def parse(text): ++ return yaml.safe_load(text) diff --git a/examples/skills_code_review_agent/pipeline/__init__.py b/examples/skills_code_review_agent/pipeline/__init__.py new file mode 100644 index 00000000..bc6e483f --- /dev/null +++ b/examples/skills_code_review_agent/pipeline/__init__.py @@ -0,0 +1,5 @@ +# 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. diff --git a/examples/skills_code_review_agent/pipeline/dedup.py b/examples/skills_code_review_agent/pipeline/dedup.py new file mode 100644 index 00000000..08462849 --- /dev/null +++ b/examples/skills_code_review_agent/pipeline/dedup.py @@ -0,0 +1,67 @@ +# 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. +"""Dedup + denoise (issue #92, requirement 6). + +- Dedup: at most one finding per (file, line, category); keep the highest-confidence one and mark + the rest ``status="duplicate"``. +- Denoise: route findings by confidence so low-confidence ones never mix into high-confidence + actionable findings — ``active`` (>= warn) / ``warning`` (>= review) / ``needs_human_review``. +""" +from __future__ import annotations + +from .types import Finding + +# Confidence cutoffs (tunable policy). active >= WARN; warning in [REVIEW, WARN); else human-review. +WARN_THRESHOLD = 0.7 +REVIEW_THRESHOLD = 0.4 + + +def dedup_key(f: Finding) -> str: + # File-level findings (line is None) share file+category but are distinct issues — key on the + # rule/title too so two different file-level findings in one category don't collapse into one. + if f.line is None: + return f"{f.file}::{f.category}:{f.rule_id or f.title}" + return f"{f.file}:{f.line}:{f.category}" + + +def _denoise_status(confidence: float) -> str: + if confidence >= WARN_THRESHOLD: + return "active" + if confidence >= REVIEW_THRESHOLD: + return "warning" + return "needs_human_review" + + +def dedup_and_denoise( + findings: list[Finding], + warn_threshold: float = WARN_THRESHOLD, + review_threshold: float = REVIEW_THRESHOLD, +) -> list[Finding]: + """Return findings with ``dedup_key`` and ``status`` set. Duplicates are kept but marked.""" + best: dict[str, Finding] = {} + order: list[str] = [] + dupes: list[Finding] = [] + + for f in findings: + key = dedup_key(f) + f = f.model_copy(update={"dedup_key": key}) + if key not in best: + best[key] = f + order.append(key) + elif f.confidence > best[key].confidence: + dupes.append(best[key].model_copy(update={"status": "duplicate"})) + best[key] = f + else: + dupes.append(f.model_copy(update={"status": "duplicate"})) + + result: list[Finding] = [] + for key in order: + f = best[key] + status = "active" if f.confidence >= warn_threshold else ( + "warning" if f.confidence >= review_threshold else "needs_human_review") + result.append(f.model_copy(update={"status": status})) + result.extend(dupes) + return result diff --git a/examples/skills_code_review_agent/pipeline/diff_parser.py b/examples/skills_code_review_agent/pipeline/diff_parser.py new file mode 100644 index 00000000..8f2718dd --- /dev/null +++ b/examples/skills_code_review_agent/pipeline/diff_parser.py @@ -0,0 +1,133 @@ +# 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. +"""Parse review inputs into a ``DiffSummary`` (issue #92, requirement 3). + +Plumbing only — wraps the mature ``unidiff`` parser. Three input kinds: + * a unified-diff text / file (``--diff-file``) + * a git worktree (``--repo-path`` → ``git diff``) + * a fixture diff (same as diff-file) +The one thing reviewers actually consume downstream is ``Hunk.candidate_lines`` — the new-file +line numbers of added/changed lines — so scanners only report on what the diff touched. +""" +from __future__ import annotations + +import subprocess +from pathlib import Path + +from unidiff import PatchSet + +from .types import ChangedFile, DiffSummary, Hunk + +_LANG_BY_SUFFIX = { + ".py": "python", + ".ts": "typescript", + ".tsx": "typescript", + ".js": "javascript", + ".go": "go", + ".rs": "rust", + ".java": "java", + ".c": "c", + ".cpp": "cpp", + ".sh": "bash", +} + + +def _language(path: str) -> str | None: + return _LANG_BY_SUFFIX.get(Path(path).suffix.lower()) + + +def _change_type(pf) -> str: + if pf.is_added_file: + return "added" + if pf.is_removed_file: + return "deleted" + if pf.is_rename: + return "renamed" + return "modified" + + +def parse_unified_diff(text: str) -> DiffSummary: + patch = PatchSet(text) + files: list[ChangedFile] = [] + added = removed = 0 + languages: dict[str, int] = {} + + for pf in patch: + path = pf.path + lang = _language(path) + if lang: + languages[lang] = languages.get(lang, 0) + 1 + hunks: list[Hunk] = [] + for h in pf: + candidate = [ln.target_line_no for ln in h if ln.is_added and ln.target_line_no is not None] + hunks.append( + Hunk( + old_start=h.source_start, + old_len=h.source_length, + new_start=h.target_start, + new_len=h.target_length, + candidate_lines=candidate, + )) + added += pf.added + removed += pf.removed + files.append(ChangedFile(path=path, change_type=_change_type(pf), language=lang, hunks=hunks)) + + return DiffSummary(files=files, files_changed=len(files), added=added, removed=removed, languages=languages) + + +def parse_diff_file(path: str) -> DiffSummary: + return parse_unified_diff(Path(path).read_text(encoding="utf-8")) + + +def parse_git_worktree(repo_path: str, base_ref: str | None = None) -> DiffSummary: + args = ["git", "-C", repo_path, "diff", "--unified=3"] + if base_ref: + args.append(base_ref) + proc = subprocess.run(args, capture_output=True, text=True, check=True) + return parse_unified_diff(proc.stdout) + + +def parse_file_list(paths: list[str], repo_root: str = ".") -> DiffSummary: + """Treat a list of file paths as fully-added files (issue #92, requirement 3 input mode).""" + files: list[ChangedFile] = [] + languages: dict[str, int] = {} + added = 0 + for rel in paths: + try: + n = len((Path(repo_root) / rel).read_text(encoding="utf-8", errors="replace").splitlines()) + except OSError: + n = 0 + lang = _language(rel) + if lang: + languages[lang] = languages.get(lang, 0) + 1 + added += n + files.append( + ChangedFile(path=rel, + change_type="added", + language=lang, + hunks=[Hunk(new_start=1, new_len=n, candidate_lines=list(range(1, n + 1)))])) + return DiffSummary(files=files, files_changed=len(files), added=added, removed=0, languages=languages) + + +def materialize_new_files(text: str) -> dict[str, str]: + """Reconstruct the post-change (target-side) content of each changed file from a diff. + + For an added file this is the complete file, so scanners can run on real source. For a + modified file (diff-only mode, no base) it is the target-side lines present in the hunks — + best-effort; use ``--repo-path`` when the full working tree is available. + """ + patch = PatchSet(text) + out: dict[str, str] = {} + for pf in patch: + if pf.is_removed_file: + continue + lines: list[str] = [] + for h in pf: + for ln in h: + if ln.is_added or ln.is_context: + lines.append(ln.value.rstrip("\n")) + out[pf.path] = "\n".join(lines) + "\n" + return out diff --git a/examples/skills_code_review_agent/pipeline/engine.py b/examples/skills_code_review_agent/pipeline/engine.py new file mode 100644 index 00000000..2a4c016e --- /dev/null +++ b/examples/skills_code_review_agent/pipeline/engine.py @@ -0,0 +1,253 @@ +# 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 deterministic review pipeline (issue #92 backbone). + +parse -> materialize changed files -> run scanners -> dedup/denoise -> build+redact report -> +(optionally) persist. This path needs no LLM and no real sandbox, so it satisfies the dry-run / +fake-model requirement on its own. The agent path (``agent/``) reuses ``run_review`` as its tool. + +Exceptions are caught at the pipeline boundary, classified by type into ``exception_dist``, and +surfaced in the report's monitoring section — a failing scanner degrades the result but never +crashes the review (requirement 7 "failure logging" / requirement 9 "exception-type distribution"). +""" +from __future__ import annotations + +import tempfile +import time +import uuid +from dataclasses import dataclass, field +from pathlib import Path +from typing import Optional + +from . import diff_parser, report as report_mod, scanners +from .dedup import dedup_and_denoise +from .policy import ReviewPolicy +from .types import DiffSummary, Finding, ReviewReport + + +@dataclass +class ReviewResult: + """Everything one review produced — the report to render plus what the DB layer persists.""" + + task_id: str + report: ReviewReport + findings: list[Finding] # deduped/denoised (active + warning + needs_human_review) + summary: DiffSummary + source_type: str + source_ref: str + monitoring: dict = field(default_factory=dict) + + +def _materialize(diff_text: str) -> tuple[DiffSummary, str]: + """Parse a diff and write its post-change files into a temp dir for scanning.""" + summary = diff_parser.parse_unified_diff(diff_text) + files = diff_parser.materialize_new_files(diff_text) + tmp = tempfile.mkdtemp(prefix="cr_scan_") + for rel, content in files.items(): + dest = Path(tmp) / rel + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_text(content, encoding="utf-8") + return summary, tmp + + +def _materialize_files(paths: list[str], repo_root: str) -> str: + """Copy a list of files into a temp dir for scanning (the --files / file-list input mode).""" + tmp = tempfile.mkdtemp(prefix="cr_scan_") + for rel in paths: + dest = Path(tmp) / rel + dest.parent.mkdir(parents=True, exist_ok=True) + try: + dest.write_text((Path(repo_root) / rel).read_text(encoding="utf-8", errors="replace"), encoding="utf-8") + except OSError: + continue + return tmp + + +def run_review( + *, + task_id: Optional[str] = None, + diff_text: Optional[str] = None, + repo_path: Optional[str] = None, + files: Optional[list[str]] = None, + repo_root: str = ".", + runtime: str = "auto", + sandbox_timeout: float | None = None, + max_output_bytes: int | None = None, + policy: ReviewPolicy | None = None, + warn_threshold: float | None = None, + review_threshold: float | None = None, +) -> ReviewResult: + """Run one review (deterministic, no LLM). Provide either ``diff_text`` or ``repo_path``. + + ``runtime``: ``inprocess`` (default, fast) runs scanners in-process; ``local`` runs them in a + subprocess sandbox with timeout + output cap (dev fallback) and records a sandbox run. The + ``container`` runtime (production isolation) is async — see ``run_review_container``. + + Returns a ``ReviewResult``; persistence is done separately by the async ``storage.dao.ReviewStore`` + so this core stays synchronous and dependency-light. + """ + task_id = task_id or f"cr-{uuid.uuid4().hex[:12]}" + started = time.monotonic() + exception_dist: dict[str, int] = {} + + # `auto` is the default: sandbox, not in-process. This sync entry can't drive the async container + # runtime, so auto resolves to the local subprocess sandbox here; the CLI upgrades auto->container + # when Docker is available (see run_review.py / run_review_container). `inprocess` is an opt-in dev + # fast-path that must be requested explicitly. + if runtime == "auto": + runtime = "local" + elif runtime == "container": + raise ValueError("container runtime is async — call run_review_container() instead of run_review()") + + summary, scan_dir, source_type, source_ref = _resolve_input(diff_text, files, repo_path, repo_root) + + sandbox_runs: list = [] + if runtime == "local": + from . import sandbox as sandbox_mod + raw, run = sandbox_mod.run_local( + scan_dir, + timeout=sandbox_timeout if sandbox_timeout is not None else sandbox_mod.DEFAULT_TIMEOUT_SEC, + max_bytes=max_output_bytes if max_output_bytes is not None else sandbox_mod.MAX_OUTPUT_BYTES, + policy=policy if policy is not None else ReviewPolicy()) + sandbox_runs = [run] + if run.timed_out or (not run.blocked and run.exit_code not in (0, 1)): # 1 = issues found (normal) + exception_dist["sandbox_failure"] = exception_dist.get("sandbox_failure", 0) + 1 + else: # "inprocess" + try: + raw = scanners.scan(scan_dir, summary) + except Exception as exc: # noqa: BLE001 - boundary; never crash the task + exception_dist[type(exc).__name__] = exception_dist.get(type(exc).__name__, 0) + 1 + raw = [] + + # missing_tests is a diff-level check (no file content / sandbox needed) — add it for every runtime. + raw = list(raw) + scanners.detect_missing_tests(summary) + return _assemble(task_id, summary, raw, sandbox_runs, source_type, source_ref, started, exception_dist, + warn_threshold, review_threshold) + + +def _assemble(task_id, summary, raw, sandbox_runs, source_type, source_ref, started, exception_dist, warn_threshold, + review_threshold) -> ReviewResult: + """Shared tail: dedup/denoise -> monitoring -> build+redact report -> ReviewResult.""" + findings = dedup_and_denoise( + raw, + warn_threshold if warn_threshold is not None else dedup_thresholds()[0], + review_threshold if review_threshold is not None else dedup_thresholds()[1], + ) + for f in findings: + if f.category == "scanner_error": + exception_dist["scanner_error"] = exception_dist.get("scanner_error", 0) + 1 + + active = [f for f in findings if f.status == "active"] + severity_dist: dict[str, int] = {} + for f in active: + severity_dist[f.severity] = severity_dist.get(f.severity, 0) + 1 + + filter_blocks = [{ + "script": r.script, + "reason": r.block_reason, + "category": r.block_category + } for r in sandbox_runs if r.blocked] + + monitoring = { + "total_sec": round(time.monotonic() - started, 3), + "sandbox_sec": round(sum(r.duration_sec for r in sandbox_runs), 3), + "tool_calls": scanners.tool_calls_available(), + "block_count": len(filter_blocks), + "finding_count": len(active), + "severity_dist": severity_dist, + "exception_dist": exception_dist, + } + + report = report_mod.build_report(task_id, + findings, + sandbox_runs=sandbox_runs, + filter_blocks=filter_blocks, + monitoring=monitoring) + return ReviewResult(task_id=task_id, + report=report, + findings=findings, + summary=summary, + source_type=source_type, + source_ref=source_ref, + monitoring=monitoring) + + +def _resolve_input(diff_text: Optional[str], files: Optional[list[str]], repo_path: Optional[str], + repo_root: str) -> tuple[DiffSummary, str, str, str]: + """Materialize any of the three input modes into (summary, scan_dir, source_type, source_ref). + + Shared by run_review and run_review_container so every input mode reaches the same sandbox path. + """ + if diff_text is not None: + summary, scan_dir = _materialize(diff_text) + return summary, scan_dir, "diff_file", "" + if files is not None: + return diff_parser.parse_file_list(files, repo_root), _materialize_files(files, repo_root), \ + "file_list", ",".join(files)[:200] + if repo_path is not None: + return diff_parser.parse_git_worktree(repo_path), repo_path, "repo_path", repo_path + raise ValueError("a review requires diff_text, files, or repo_path") + + +async def run_review_container( + *, + task_id: Optional[str] = None, + diff_text: Optional[str] = None, + files: Optional[list[str]] = None, + repo_path: Optional[str] = None, + repo_root: str = ".", + sandbox_timeout: float | None = None, + max_output_bytes: int | None = None, +) -> ReviewResult: + """Run a review with scanners inside a Container workspace (production isolation; needs Docker). + + Accepts the same three input modes as run_review so file-list and worktree inputs also reach the + container sandbox instead of silently falling back to the in-process path. + """ + from . import sandbox as sandbox_mod + task_id = task_id or f"cr-{uuid.uuid4().hex[:12]}" + started = time.monotonic() + summary, scan_dir, source_type, source_ref = _resolve_input(diff_text, files, repo_path, repo_root) + raw, run = await sandbox_mod.run_container( + scan_dir, + timeout=sandbox_timeout if sandbox_timeout is not None else sandbox_mod.DEFAULT_TIMEOUT_SEC, + max_bytes=max_output_bytes if max_output_bytes is not None else sandbox_mod.MAX_OUTPUT_BYTES) + exception_dist: dict[str, int] = {} + if run.timed_out or run.exit_code not in (0, 1): + exception_dist["sandbox_failure"] = 1 + raw = list(raw) + scanners.detect_missing_tests(summary) + return _assemble(task_id, summary, raw, [run], source_type, source_ref, started, exception_dist, None, None) + + +def dedup_thresholds() -> tuple[float, float]: + from .dedup import REVIEW_THRESHOLD, WARN_THRESHOLD + return WARN_THRESHOLD, REVIEW_THRESHOLD + + +def _main() -> None: + import argparse + + ap = argparse.ArgumentParser(description="Deterministic code-review pipeline (no LLM).") + src = ap.add_mutually_exclusive_group(required=True) + src.add_argument("--diff-file") + src.add_argument("--repo-path") + ap.add_argument("--out-dir", default=".") + args = ap.parse_args() + + if args.diff_file: + result = run_review(diff_text=Path(args.diff_file).read_text(encoding="utf-8")) + else: + result = run_review(repo_path=args.repo_path) + + out = Path(args.out_dir) + out.mkdir(parents=True, exist_ok=True) + (out / "review_report.json").write_text(report_mod.render_json(result.report), encoding="utf-8") + (out / "review_report.md").write_text(report_mod.render_md(result.report), encoding="utf-8") + print(f"[{result.task_id}] {result.report.findings_summary} -> {out}/review_report.json") + + +if __name__ == "__main__": + _main() diff --git a/examples/skills_code_review_agent/pipeline/policy.py b/examples/skills_code_review_agent/pipeline/policy.py new file mode 100644 index 00000000..d5dc9bca --- /dev/null +++ b/examples/skills_code_review_agent/pipeline/policy.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. +"""Review policy — the Filter decision logic (issue #92, requirement 7 & 8). + +Shared by two enforcement sites (plan decision #5): the framework ``ReviewGuardFilter`` on the agent +path, and a direct gate in the sandbox runner on the deterministic path. Both call ``evaluate`` and +must refuse to execute anything that comes back ``deny`` or ``needs_human_review``. +""" +from __future__ import annotations + +import os +import re +from dataclasses import dataclass +from typing import Iterable, Literal + +Decision = Literal["allow", "deny", "needs_human_review"] + +# High-risk command patterns → hard deny. +_DANGEROUS = [ + (re.compile(r"\brm\s+-[rfRF]"), "recursive/force delete"), + (re.compile(r"\b(mkfs|shred)\b|\bdd\s+if="), "disk-destructive command"), + (re.compile(r":\s*\(\s*\)\s*\{.*\}\s*;"), "fork bomb"), + (re.compile(r"(curl|wget)\b[^\n|]*\|\s*(sh|bash)"), "pipe-to-shell"), + (re.compile(r"\bchmod\s+-?R?\s*777\b"), "world-writable chmod"), + (re.compile(r"\bsudo\b"), "privilege escalation"), + (re.compile(r">\s*/dev/sd|/etc/passwd|/etc/shadow"), "write to sensitive target"), +] + +# Sensitive roots a review must never touch (temp dirs under /var/folders are intentionally allowed). +_FORBIDDEN_PATHS = ("/etc", "/root", "/boot", os.path.expanduser("~/.ssh")) + +# Only these env vars are passed into the sandbox — parent-process secrets never leak in (要求7). +ENV_ALLOWLIST = ("PATH", "HOME", "LANG", "LC_ALL", "TMPDIR", "TEMP", "TMP", "SYSTEMROOT") + + +def sandbox_env(allowlist: Iterable[str] = ENV_ALLOWLIST) -> dict[str, str]: + """Return the minimal whitelisted environment for a sandbox run.""" + return {k: os.environ[k] for k in allowlist if k in os.environ} + + +@dataclass +class PolicyDecision: + decision: Decision + reason: str = "" + category: str = "ok" # script | path | network | budget | ok + + @property + def allowed(self) -> bool: + return self.decision == "allow" + + def as_block(self, *, script: str = "") -> dict: + return {"script": script, "decision": self.decision, "reason": self.reason, "category": self.category} + + +class ReviewPolicy: + """Decides whether a sandbox action may run. Deny/needs_human_review must NOT reach the sandbox.""" + + def __init__(self, network_allowlist: Iterable[str] | None = None, max_budget_sec: float = 120.0) -> None: + self.network_allowlist = set(network_allowlist or ()) + self.max_budget_sec = max_budget_sec + + def evaluate( + self, + *, + command: str = "", + touched_paths: Iterable[str] = (), + network_hosts: Iterable[str] = (), + budget_sec: float = 0.0, + ) -> PolicyDecision: + for pat, why in _DANGEROUS: + if pat.search(command): + return PolicyDecision("deny", f"high-risk command: {why}", "script") + + for p in touched_paths: + ap = os.path.abspath(p) + if any(ap == fp or ap.startswith(fp + os.sep) for fp in _FORBIDDEN_PATHS): + return PolicyDecision("deny", f"forbidden path: {p}", "path") + + unlisted = [h for h in network_hosts if h not in self.network_allowlist] + if unlisted: + return PolicyDecision("needs_human_review", f"non-whitelisted network: {unlisted}", "network") + + if budget_sec and budget_sec > self.max_budget_sec: + return PolicyDecision("needs_human_review", f"over budget: {budget_sec}s > {self.max_budget_sec}s", + "budget") + + return PolicyDecision("allow") diff --git a/examples/skills_code_review_agent/pipeline/redaction.py b/examples/skills_code_review_agent/pipeline/redaction.py new file mode 100644 index 00000000..fe78af81 --- /dev/null +++ b/examples/skills_code_review_agent/pipeline/redaction.py @@ -0,0 +1,117 @@ +# 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 redaction — the single choke-point (issue #92, requirement 7 & criterion 5). + +Every string that enters the DB or a rendered report MUST pass through ``redact()``. Criterion 5 is +binary-checked (one plaintext key/token/password in the report or DB = fail), so redaction is +centralized here, never sprinkled across call sites. + +Layers, applied in order: +1. a regex layer that masks the *full* token for common providers and labeled assignments; +2. a Shannon-entropy catch-all for long high-entropy tokens (generic base64/hex secrets). + +(detect-secrets is used as a *scanner* for the secret-leakage finding category, but not for +redaction: its scan_line returns partial/benign values that hurt precision here. The regex + entropy +layers reach 100% on the leak-test corpus with zero false positives.) + +The leak-test corpus in the test-suite asserts >=95% masking and zero plaintext survivors. +""" +from __future__ import annotations + +import math +import re +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .types import Finding, ReviewReport + +_MASK = "***REDACTED***" + +# --- Layer 1: full-token regexes ------------------------------------------------------------- + +# Labeled assignment: keep the label \1, mask the value. +_KV = re.compile(r"(?ix)\b(password|passwd|pwd|secret|api[_-]?key|apikey|access[_-]?token|token|auth" + r"|client[_-]?secret|private[_-]?key)\b\s*[:=]\s*['\"]?([^\s'\"]{4,})['\"]?") + +# Standalone provider tokens — mask the whole match. +_PROVIDER = [ + re.compile(r"\b(AKIA|ASIA|AGPA|AIDA|AROA|ANPA|ANVA|ASCA)[A-Z0-9]{16}\b"), + re.compile(r"(?i)aws_secret_access_key\s*[:=]\s*['\"]?[A-Za-z0-9/+=]{40}"), + re.compile(r"\bgh[pousr]_[A-Za-z0-9]{36,}\b"), + re.compile(r"\bglpat-[A-Za-z0-9_-]{20,}\b"), + re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{10,}\b"), + re.compile(r"\b[rs]k_(live|test)_[A-Za-z0-9]{16,}\b"), + re.compile(r"\bAIza[0-9A-Za-z_-]{35}\b"), + re.compile(r"\bya29\.[0-9A-Za-z_-]{20,}\b"), + re.compile(r"\bSG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}\b"), + re.compile(r"\bnpm_[A-Za-z0-9]{36}\b"), + re.compile(r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b"), + re.compile(r"(?i)\bBearer\s+[A-Za-z0-9._~+/-]{16,}=*"), + re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----", re.DOTALL), +] + +# Basic-auth in a URL: mask the password segment only. +_URL_AUTH = re.compile(r"(?i)\b([a-z][a-z0-9+.-]*://[^\s:@/]+:)([^\s@/]+)(@)") + +# Layer 3 catch-all: long base64/hex tokens; mask only if high-entropy (a real secret, not an id). +_HIGH_ENTROPY_CANDIDATE = re.compile(r"\b[A-Za-z0-9+/=_-]{20,}\b") +_B64_ENTROPY_MIN = 4.0 +_HEX_ENTROPY_MIN = 3.0 +_HEX_ONLY = re.compile(r"\A[0-9a-fA-F]+\Z") + + +def _shannon(s: str) -> float: + if not s: + return 0.0 + counts: dict[str, int] = {} + for ch in s: + counts[ch] = counts.get(ch, 0) + 1 + n = len(s) + return -sum((c / n) * math.log2(c / n) for c in counts.values()) + + +def _looks_secret(tok: str) -> bool: + if _MASK in tok: + return False + threshold = _HEX_ENTROPY_MIN if _HEX_ONLY.match(tok) else _B64_ENTROPY_MIN + return _shannon(tok) >= threshold + + +def _regex_layer(text: str) -> str: + out = _KV.sub(lambda m: f"{m.group(1)}={_MASK}", text) + out = _URL_AUTH.sub(lambda m: f"{m.group(1)}{_MASK}{m.group(3)}", out) + for pat in _PROVIDER: + out = pat.sub(_MASK, out) + return out + + +def redact(text: str | None) -> str: + """Mask secrets in a free-text string. Idempotent and safe on None/empty.""" + if not text: + return text or "" + lines = [] + for line in _regex_layer(text).split("\n"): + line = _HIGH_ENTROPY_CANDIDATE.sub(lambda m: _MASK if _looks_secret(m.group()) else m.group(), line) + lines.append(line) + return "\n".join(lines) + + +def redact_finding(f: "Finding") -> "Finding": + """Return a copy of the finding with secret-bearing fields masked.""" + return f.model_copy(update={ + "evidence": redact(f.evidence), + "title": redact(f.title), + "recommendation": redact(f.recommendation), + }) + + +def redact_report(report: "ReviewReport") -> "ReviewReport": + """Mask every finding in a report (defense in depth — findings are already redacted on entry).""" + return report.model_copy( + update={ + "findings": [redact_finding(f) for f in report.findings], + "human_review": [redact_finding(f) for f in report.human_review], + }) diff --git a/examples/skills_code_review_agent/pipeline/report.py b/examples/skills_code_review_agent/pipeline/report.py new file mode 100644 index 00000000..6066875b --- /dev/null +++ b/examples/skills_code_review_agent/pipeline/report.py @@ -0,0 +1,102 @@ +# 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. +"""Build and render the review report (issue #92, requirement: report with 7 sections). + +The 7 sections (findings summary, severity stats, human-review items, Filter-block summary, +monitoring metrics, sandbox summary, actionable findings) all exist from day 1; sections not yet +populated by a given slice render empty so the schema never churns across PRs. +""" +from __future__ import annotations + +import json + +from .redaction import redact_report +from .types import Finding, ReviewReport, SandboxRunResult + +_SEVERITY_ORDER = ["critical", "high", "medium", "low"] + + +def build_report( + task_id: str, + findings: list[Finding], + *, + sandbox_runs: list[SandboxRunResult] | None = None, + filter_blocks: list[dict] | None = None, + monitoring: dict | None = None, +) -> ReviewReport: + sandbox_runs = sandbox_runs or [] + filter_blocks = filter_blocks or [] + active = [f for f in findings if f.status == "active"] + warnings = [f for f in findings if f.status == "warning"] + human = [f for f in findings if f.status == "needs_human_review"] + + severity_stats = {s: sum(1 for f in active if f.severity == s) for s in _SEVERITY_ORDER} + category_stats: dict[str, int] = {} + for f in active: + category_stats[f.category] = category_stats.get(f.category, 0) + 1 + + report = ReviewReport( + task_id=task_id, + findings_summary={ + "total": len(active), + "warnings": len(warnings), + "needs_human_review": len(human), + "by_category": category_stats, + }, + severity_stats=severity_stats, + human_review=human + warnings, + filter_blocks=filter_blocks, + monitoring=monitoring or {}, + sandbox_summary=sandbox_runs, + findings=active, + ) + return redact_report(report) # defense in depth; findings are already redacted on entry + + +def render_json(report: ReviewReport) -> str: + return json.dumps(report.model_dump(), indent=2, ensure_ascii=False) + + +def _fmt_finding(f: Finding) -> str: + loc = f"{f.file}:{f.line}" if f.line is not None else f.file + return (f"- **[{f.severity}] {f.title}** (`{loc}`, {f.category}, conf={f.confidence:.2f}, " + f"{f.source})\n - {f.evidence}\n - _Fix:_ {f.recommendation}") + + +def render_md(report: ReviewReport) -> str: + s = report.severity_stats + lines = [ + f"# Code Review Report — `{report.task_id}`", + "", + "## 1. Findings summary", + f"- Active findings: **{report.findings_summary.get('total', 0)}**", + f"- Warnings: {report.findings_summary.get('warnings', 0)}", + f"- Needs human review: {report.findings_summary.get('needs_human_review', 0)}", + "", + "## 2. Severity statistics", + f"- critical: {s.get('critical', 0)} · high: {s.get('high', 0)} · " + f"medium: {s.get('medium', 0)} · low: {s.get('low', 0)}", + "", + "## 3. Needs human review", + *([_fmt_finding(f) for f in report.human_review] or ["_none_"]), + "", + "## 4. Filter interception summary", + *([f"- {b}" for b in report.filter_blocks] or ["_none_"]), + "", + "## 5. Monitoring metrics", + *([f"- {k}: {v}" for k, v in report.monitoring.items()] or ["_none_"]), + "", + "## 6. Sandbox execution summary", + *([ + f"- `{r.script}` exit={r.exit_code} dur={r.duration_sec:.2f}s " + f"timed_out={r.timed_out} blocked={r.blocked}" for r in report.sandbox_summary + ] or ["_none_"]), + "", + "## 7. Findings & fixes", + *([_fmt_finding(f) for f in report.findings] or ["_no active findings_"]), + "", + ] + return "\n".join(lines) diff --git a/examples/skills_code_review_agent/pipeline/sandbox.py b/examples/skills_code_review_agent/pipeline/sandbox.py new file mode 100644 index 00000000..d3db8391 --- /dev/null +++ b/examples/skills_code_review_agent/pipeline/sandbox.py @@ -0,0 +1,214 @@ +# 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. +"""Sandboxed execution of the review scanners (issue #92, requirement 4 & slice 2). + +Runs the skill's ``run_checks.py`` outside the review process, with a timeout and an output-size cap, +and records a ``SandboxRunResult`` for every run — including timeouts and failures — so one bad run +degrades a source without crashing the task. + +Two runtimes: +- ``run_local``: subprocess execution (the dev fallback the issue permits) — real process boundary, + timeout and output cap; verified without Docker. +- ``run_container``: production isolation via the framework's Container workspace runtime; requires + Docker and the scanner image (see ``skills/code-review/Dockerfile``). +""" +from __future__ import annotations + +import json +import subprocess +import sys +import tempfile +import time +from pathlib import Path +from typing import TYPE_CHECKING + +from .types import Finding, SandboxRunResult + +if TYPE_CHECKING: + from .policy import ReviewPolicy + +_SKILL_SCRIPT = Path(__file__).resolve().parents[3] / "skills" / "code-review" / "scripts" / "run_checks.py" +DEFAULT_TIMEOUT_SEC = 60.0 +MAX_OUTPUT_BYTES = 1_048_576 # 1 MiB per stream + + +def _gate(policy: "ReviewPolicy | None", cmd: list[str], scan_dir: str, timeout: float) -> SandboxRunResult | None: + """Return a blocked result if the policy refuses the action, else None (allowed to run).""" + if policy is None: + return None + decision = policy.evaluate(command=" ".join(cmd), touched_paths=[scan_dir], budget_sec=timeout) + if decision.allowed: + return None + return SandboxRunResult(script="run_checks.py", + exit_code=0, + duration_sec=0.0, + timed_out=False, + stdout_bytes=0, + stderr_bytes=0, + blocked=True, + block_reason=decision.reason, + block_category=decision.category) + + +def _truncate(text: str | bytes | None, cap: int) -> tuple[str, int]: + """Return (possibly-truncated text, original byte length). The cap bounds what we persist.""" + if text is None: + return "", 0 + raw = text.encode("utf-8", "replace") if isinstance(text, str) else text + n = len(raw) + if n <= cap: + return raw.decode("utf-8", "ignore"), n + return raw[:cap].decode("utf-8", "ignore") + "\n...[truncated]", n + + +def parse_findings_json(payload: dict) -> list[Finding]: + """Map the skill's findings.json (docs/OUTPUT_SCHEMA.md) into Finding objects.""" + out: list[Finding] = [] + for f in payload.get("findings", []): + try: + out.append( + Finding(severity=f.get("severity", "low"), + category=f.get("category", "unknown"), + file=f.get("file", ""), + line=f.get("line"), + title=f.get("title", ""), + evidence=f.get("evidence", ""), + recommendation=f.get("recommendation", ""), + confidence=float(f.get("confidence", 0.5)), + source=f.get("source", "static"), + rule_id=f.get("rule_id"))) + except Exception: # noqa: BLE001 - a malformed row is skipped, not fatal + continue + return out + + +def run_local( + scan_dir: str, + *, + timeout: float = DEFAULT_TIMEOUT_SEC, + max_bytes: int = MAX_OUTPUT_BYTES, + policy: "ReviewPolicy | None" = None, +) -> tuple[list[Finding], SandboxRunResult]: + """Run the scanners in a subprocess against ``scan_dir``; never raises. + + If ``policy`` denies the action (or requires human review), the subprocess is NOT launched and a + blocked ``SandboxRunResult`` is returned instead (requirement 7). + """ + out_file = Path(tempfile.mkdtemp(prefix="cr_out_")) / "findings.json" + cmd = [sys.executable, str(_SKILL_SCRIPT), "--target", scan_dir, "--out", str(out_file)] + + blocked = _gate(policy, cmd, scan_dir, timeout) + if blocked is not None: + return [], blocked + + started = time.monotonic() + timed_out = False + exit_code = 0 + stdout: str | bytes | None = "" + stderr: str | bytes | None = "" + try: + from .policy import sandbox_env + proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, check=False, env=sandbox_env()) + exit_code, stdout, stderr = proc.returncode, proc.stdout, proc.stderr + except subprocess.TimeoutExpired as exc: + timed_out, exit_code = True, -1 + stdout, stderr = exc.stdout, exc.stderr + duration = time.monotonic() - started + + findings: list[Finding] = [] + if not timed_out and out_file.exists(): + try: + findings = parse_findings_json(json.loads(out_file.read_text(encoding="utf-8"))) + except Exception: # noqa: BLE001 - unreadable output degrades the source, not the task + findings = [] + + _, out_bytes = _truncate(stdout, max_bytes) + _, err_bytes = _truncate(stderr, max_bytes) + result = SandboxRunResult(script="run_checks.py", + exit_code=exit_code, + duration_sec=round(duration, 3), + timed_out=timed_out, + stdout_bytes=out_bytes, + stderr_bytes=err_bytes) + return findings, result + + +async def run_container( + scan_dir: str, + *, + timeout: float = DEFAULT_TIMEOUT_SEC, + max_bytes: int = MAX_OUTPUT_BYTES, + memory_mb: int = 512, + image: str = "cr-scanners:latest", +) -> tuple[list[Finding], SandboxRunResult]: + """Production isolation: run the scanners inside a container workspace. Requires Docker + image. + + Stages the changed files into the workspace, runs ``run_checks.py`` with a timeout and resource + limits, collects ``findings.json``, and truncates captured output to the byte cap. + """ + from trpc_agent_sdk.code_executors import (WorkspaceOutputSpec, WorkspacePutFileInfo, WorkspaceResourceLimits, + WorkspaceRunProgramSpec, create_container_workspace_runtime) + from trpc_agent_sdk.code_executors.container import ContainerConfig + + runtime = create_container_workspace_runtime(container_config=ContainerConfig(image=image)) + manager, fs, runner = runtime.manager(None), runtime.fs(None), runtime.runner(None) + exec_id = "cr-" + Path(scan_dir).name + + started = time.monotonic() + ws = await manager.create_workspace(exec_id) + try: + files = [ + WorkspacePutFileInfo(path=str(p.relative_to(scan_dir)), content=p.read_bytes()) + for p in Path(scan_dir).rglob("*") if p.is_file() + ] + await fs.put_files(ws, files) + from .policy import sandbox_env + run = await runner.run_program( + ws, + WorkspaceRunProgramSpec(cmd="python", + args=["/opt/skill/run_checks.py", "--target", ".", "--out", "findings.json"], + env=sandbox_env(), + timeout=timeout, + limits=WorkspaceResourceLimits(memory_mb=memory_mb))) + collected = await fs.collect_outputs(ws, WorkspaceOutputSpec(globs=["findings.json"])) + return build_container_result(getattr(collected, "files", collected), + stdout=run.stdout, + stderr=run.stderr, + exit_code=run.exit_code, + timed_out=run.timed_out, + duration_sec=time.monotonic() - started, + max_bytes=max_bytes) + finally: + await manager.cleanup(exec_id) + + +def build_container_result(collected_files, + *, + stdout, + stderr, + exit_code, + timed_out, + duration_sec, + max_bytes=MAX_OUTPUT_BYTES) -> tuple[list[Finding], SandboxRunResult]: + """Pure post-processing of a container run (parse findings.json + build SandboxRunResult). + + Extracted so the container path's logic is unit-testable without Docker (the Docker-only part is + just staging + running the workspace). ``collected_files`` are objects with a ``.content`` bytes attr. + """ + findings: list[Finding] = [] + for cf in collected_files or []: + try: + findings = parse_findings_json(json.loads(cf.content.decode("utf-8"))) + except Exception: # noqa: BLE001 - a malformed collected file degrades the source, not the task + continue + _, out_bytes = _truncate(stdout, max_bytes) + _, err_bytes = _truncate(stderr, max_bytes) + return findings, SandboxRunResult(script="run_checks.py", + exit_code=exit_code, + duration_sec=round(duration_sec, 3), + timed_out=timed_out, + stdout_bytes=out_bytes, + stderr_bytes=err_bytes) diff --git a/examples/skills_code_review_agent/pipeline/scanners.py b/examples/skills_code_review_agent/pipeline/scanners.py new file mode 100644 index 00000000..574336ba --- /dev/null +++ b/examples/skills_code_review_agent/pipeline/scanners.py @@ -0,0 +1,355 @@ +# 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. +"""Run established OSS scanners and normalize their output into the ``Finding`` schema. + +Design thesis (see the plan): findings come from *deterministic* scanners, not the LLM, so the +hidden-set thresholds are reproducible. Each adapter shells out to a tool, parses its native JSON, +and yields ``Finding`` objects conforming to ``pipeline.types``. Adapters skip cleanly when their +tool isn't installed, and a crashing scanner never sinks the whole review (see ``scan``). + +MVP: adapters run in-process against a materialized checkout. Slice 2 moves the identical +invocation inside the container sandbox with no change here. +""" +from __future__ import annotations + +import json +import os +import re +import shutil +import subprocess +from typing import Callable + +from .types import DiffSummary, Finding, Severity + + +def _changed_lines(diff: DiffSummary) -> dict[str, set[int]]: + """Per-file set of new-file line numbers the diff touched (findings elsewhere are dropped).""" + out: dict[str, set[int]] = {} + for f in diff.files: + lines: set[int] = set() + for h in f.hunks: + lines.update(h.candidate_lines) + out[f.path] = lines + return out + + +def _run(cmd: list[str], cwd: str, timeout: float = 90.0) -> subprocess.CompletedProcess: + """Run a scanner. Never raises on non-zero exit (scanners exit non-zero when they find issues).""" + return subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=timeout, check=False) + + +def _rel(path: str, root: str) -> str: + """Normalize a scanner-reported path to match diff paths. + + Scanners report either an absolute path or a path relative to ``root`` (their cwd, e.g. + ``./insecure.py``). Resolve absolutes against ``root``; normalize relatives in place — do NOT + use relpath on a relative path, which would resolve it against the process cwd. + """ + if os.path.isabs(path): + try: + # realpath both sides so a symlinked root (e.g. macOS /var -> /private/var) still matches. + return os.path.normpath(os.path.relpath(os.path.realpath(path), os.path.realpath(root))) + except ValueError: + return os.path.normpath(path) + return os.path.normpath(path) + + +def _in_diff(file: str, line: int | None, changed: dict[str, set[int]]) -> bool: + """Keep a finding only if it lands on a line the diff actually changed.""" + if file not in changed: + return False + touched = changed[file] + if not touched or line is None: + return True # file-level finding, or file has no line info + return line in touched + + +# assert-used (bandit B101 / ruff S101) is noise, especially in test files — suppress it. +_NOISE_RULES = {"B101", "S101"} + +# bandit issue_severity -> our Severity. (Tunable — bandit also exposes issue_confidence.) +_BANDIT_SEV: dict[str, Severity] = {"HIGH": "high", "MEDIUM": "medium", "LOW": "low"} +_BANDIT_CONF = {"HIGH": 0.9, "MEDIUM": 0.6, "LOW": 0.4} + + +def normalize_bandit(repo_dir: str, changed: dict[str, set[int]]) -> list[Finding]: + if not shutil.which("bandit"): + return [] + proc = _run(["bandit", "-r", ".", "-f", "json", "-q"], cwd=repo_dir) + if not proc.stdout.strip(): + return [] + data = json.loads(proc.stdout) + findings: list[Finding] = [] + for r in data.get("results", []): + file = _rel(r["filename"], repo_dir) + line = r.get("line_number") + if not _in_diff(file, line, changed) or r.get("test_id") in _NOISE_RULES: + continue + findings.append( + Finding( + severity=_BANDIT_SEV.get(r.get("issue_severity", "LOW"), "low"), + category="security", + file=file, + line=line, + title=r.get("test_name", "security issue"), + evidence=(r.get("code") or r.get("issue_text", "")).strip(), + recommendation=r.get("issue_text", "Review this security finding."), + confidence=_BANDIT_CONF.get(r.get("issue_confidence", "MEDIUM"), 0.6), + source="static", + rule_id=f"bandit:{r.get('test_id', '')}", + )) + return findings + + +# ruff rule prefix -> (category, severity). Covers async-error and resource-leak requirements. +_RUFF_CATEGORY = { + "ASYNC": ("async_errors", "high"), + "SIM115": ("resource_leak", "medium"), + "S": ("security", "high"), # flake8-bandit subset + "B": ("resource_leak", "medium"), # flake8-bugbear +} + + +def _ruff_map(code: str) -> tuple[str, Severity]: + for prefix, (cat, sev) in _RUFF_CATEGORY.items(): + if code.startswith(prefix): + return cat, sev # type: ignore[return-value] + return "code_quality", "low" + + +def normalize_ruff(repo_dir: str, changed: dict[str, set[int]]) -> list[Finding]: + if not shutil.which("ruff"): + return [] + proc = _run(["ruff", "check", ".", "--output-format", "json", "--select", "ASYNC,SIM115,B,S", "--quiet"], + cwd=repo_dir) + if not proc.stdout.strip(): + return [] + findings: list[Finding] = [] + for r in json.loads(proc.stdout): + file = _rel(r["filename"], repo_dir) + line = (r.get("location") or {}).get("row") + code = r.get("code") or "" + if not _in_diff(file, line, changed) or code in _NOISE_RULES: + continue + cat, sev = _ruff_map(code) + findings.append( + Finding( + severity=sev, + category=cat, + file=file, + line=line, + title=code or "lint issue", + evidence=r.get("message", ""), + recommendation=r.get("message", "See ruff rule documentation."), + confidence=0.7, + source="static", + rule_id=f"ruff:{code}", + )) + return findings + + +def normalize_detect_secrets(repo_dir: str, changed: dict[str, set[int]]) -> list[Finding]: + if not shutil.which("detect-secrets"): + return [] + # `detect-secrets scan .` enumerates via git and finds nothing outside a git repo — pass the + # changed files explicitly (also correct: we only review what the diff touched). + targets = [f for f in changed if f and os.path.isfile(os.path.join(repo_dir, f))] + if not targets: + return [] + proc = _run(["detect-secrets", "scan", *targets], cwd=repo_dir) + if not proc.stdout.strip(): + return [] + data = json.loads(proc.stdout) + findings: list[Finding] = [] + for raw_file, hits in (data.get("results") or {}).items(): + file = _rel(raw_file, repo_dir) + for h in hits: + line = h.get("line_number") + if not _in_diff(file, line, changed): + continue + findings.append( + Finding( + severity="critical", + category="secret_leakage", + file=file, + line=line, + title=f"Possible secret: {h.get('type', 'secret')}", + evidence=f"{h.get('type', 'secret')} detected (value redacted)", + recommendation="Remove the secret from source; use env vars or a secret manager.", + confidence=0.85, + source="static", + rule_id=f"detect-secrets:{h.get('type', '')}", + )) + return findings + + +def normalize_semgrep(repo_dir: str, changed: dict[str, set[int]]) -> list[Finding]: + """Optional: skips cleanly if semgrep isn't installed. Covers custom DB-lifecycle rules.""" + if not shutil.which("semgrep"): + return [] + rules = os.path.join(os.path.dirname(__file__), os.pardir, os.pardir, os.pardir, "skills", "code-review", "rules") + cmd = ["semgrep", "--json", "--quiet", "--config", rules if os.path.isdir(rules) else "auto", "."] + proc = _run(cmd, cwd=repo_dir, timeout=120.0) + if not proc.stdout.strip(): + return [] + findings: list[Finding] = [] + for r in json.loads(proc.stdout).get("results", []): + file = _rel(r.get("path", ""), repo_dir) + line = (r.get("start") or {}).get("line") + if not _in_diff(file, line, changed): + continue + extra = r.get("extra", {}) + sev = {"ERROR": "high", "WARNING": "medium", "INFO": "low"}.get(extra.get("severity", "WARNING"), "medium") + findings.append( + Finding( + severity=sev, + category="db_lifecycle", + file=file, + line=line, + title=r.get("check_id", "semgrep finding").split(".")[-1], + evidence=(extra.get("lines") or "").strip(), + recommendation=extra.get("message", "See rule."), + confidence=0.75, + source="static", + rule_id=f"semgrep:{r.get('check_id', '')}", + )) + return findings + + +# A DB connection/cursor bound to a variable — leak-prone if not context-managed or closed. +_DB_CONNECT = re.compile(r"\b([A-Za-z_]\w*)\s*=\s*[\w.]*\b(connect|cursor)\s*\(") + + +def normalize_db_lifecycle(repo_dir: str, changed: dict[str, set[int]]) -> list[Finding]: + """Heuristic (no semgrep needed): a DB connection/cursor opened without `with` and never closed.""" + findings: list[Finding] = [] + for file, lines in changed.items(): + path = os.path.join(repo_dir, file) + if not (file.endswith(".py") and os.path.isfile(path)): + continue + try: + content = open(path, encoding="utf-8", errors="replace").read() + except OSError: + continue + for i, text in enumerate(content.splitlines(), start=1): + if lines and i not in lines: + continue + m = _DB_CONNECT.search(text) + if not m or text.lstrip().startswith("with "): + continue + var = m.group(1) + if re.search(rf"\b{re.escape(var)}\s*\.\s*close\s*\(", content): + continue + findings.append( + Finding( + severity="medium", + category="db_lifecycle", + file=file, + line=i, + title="DB resource without lifecycle management", + evidence=text.strip(), + recommendation=f"Use a context manager (`with ...`) or ensure `{var}.close()` in a finally block.", + confidence=0.7, + source="static", + rule_id="cr:db-lifecycle")) + return findings + + +# Scanners the review relies on; a missing one must be surfaced, not silently treated as "clean". +_REQUIRED_TOOLS = {"bandit": "security", "ruff": "async-error/resource-leak", "detect-secrets": "secret_leakage"} + + +def detect_unavailable_scanners() -> list[Finding]: + """One needs-human-review finding per missing required scanner (confidence lands it there).""" + out: list[Finding] = [] + for tool, covers in _REQUIRED_TOOLS.items(): + if not shutil.which(tool): + out.append( + Finding(severity="low", + category="scanner_unavailable", + file="", + line=None, + title=f"{tool} unavailable — {covers} not checked", + evidence=f"scanner '{tool}' is not installed in this environment", + recommendation=f"Install {tool} so {covers} is actually scanned.", + confidence=0.3, + source="static", + rule_id=f"internal:missing:{tool}")) + return out + + +def tool_calls_available() -> int: + """How many scanner tools actually ran this review (uniform across in-process and sandbox paths).""" + return sum(1 for t in _REQUIRED_TOOLS if shutil.which(t)) + 1 # + the db_lifecycle heuristic + + +def _is_test_path(path: str) -> bool: + base = path.rsplit("/", 1)[-1] + return (base.startswith("test_") or base.endswith("_test.py") or path.startswith("tests/") or "/tests/" in path) + + +def detect_missing_tests(diff: DiffSummary) -> list[Finding]: + """Diff-level heuristic: source files changed with no corresponding test change.""" + src = [ + f for f in diff.files + if f.path.endswith(".py") and not _is_test_path(f.path) and f.change_type in ("added", "modified") + ] + tests = [f for f in diff.files if _is_test_path(f.path)] + if src and not tests: + return [ + Finding(severity="low", + category="missing_tests", + file=src[0].path, + line=None, + title="Source changed without accompanying tests", + evidence=f"{len(src)} source file(s) changed; no test file changed", + recommendation="Add or update tests covering the changed code.", + confidence=0.6, + source="rule", + rule_id="cr:missing-tests") + ] + return [] + + +Adapter = Callable[[str, dict[str, set[int]]], list[Finding]] + +# Enabled adapters cover all 6 required categories: security, secret_leakage, async_errors, +# resource_leak, db_lifecycle (+ semgrep rules when present). missing_tests is diff-level (added by scan()). +ADAPTERS: list[Adapter] = [ + normalize_bandit, + normalize_ruff, + normalize_detect_secrets, + normalize_db_lifecycle, + normalize_semgrep, +] + + +def scan(repo_dir: str, diff: DiffSummary) -> list[Finding]: + """Run every enabled adapter over the changed files; a crashing scanner is recorded, not fatal.""" + changed = _changed_lines(diff) + findings: list[Finding] = list(detect_unavailable_scanners()) + for adapter in ADAPTERS: + try: + findings.extend(adapter(repo_dir, changed)) + except Exception as exc: # noqa: BLE001 - one scanner must never sink the whole review + findings.append(_scanner_error_finding(adapter.__name__, exc)) + return findings + + +def _scanner_error_finding(adapter_name: str, exc: Exception) -> Finding: + return Finding( + severity="low", + category="scanner_error", + file="", + line=None, + title=f"{adapter_name} failed to run", + evidence=f"{type(exc).__name__}: {exc}", + recommendation="Check scanner installation / input.", + confidence=1.0, + source="static", + status="needs_human_review", + rule_id=f"internal:{adapter_name}", + ) diff --git a/examples/skills_code_review_agent/pipeline/types.py b/examples/skills_code_review_agent/pipeline/types.py new file mode 100644 index 00000000..aa04b7a4 --- /dev/null +++ b/examples/skills_code_review_agent/pipeline/types.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. +"""Shared data-transfer objects for the code-review pipeline. + +The ``Finding`` schema is fixed by issue #92 (the 9 required fields); everything +downstream — dedup, persistence, report rendering — is anchored to it. Keep this +in sync with ``skills/code-review/docs/OUTPUT_SCHEMA.md`` (the JSON contract the +sandbox scripts emit). +""" +from __future__ import annotations + +from typing import Literal, Optional + +from pydantic import BaseModel, Field + +Severity = Literal["critical", "high", "medium", "low"] +FindingStatus = Literal["active", "duplicate", "warning", "needs_human_review"] +FindingSource = Literal["rule", "llm", "static"] + + +class Hunk(BaseModel): + """One @@ hunk of a changed file, with new-file line numbers resolved.""" + + old_start: int = 0 + old_len: int = 0 + new_start: int = 0 + new_len: int = 0 + candidate_lines: list[int] = Field(default_factory=list) # added/changed new-file line numbers + + +class ChangedFile(BaseModel): + path: str + change_type: Literal["added", "modified", "deleted", "renamed"] = "modified" + language: Optional[str] = None + hunks: list[Hunk] = Field(default_factory=list) + + +class DiffSummary(BaseModel): + files: list[ChangedFile] = Field(default_factory=list) + files_changed: int = 0 + added: int = 0 + removed: int = 0 + languages: dict[str, int] = Field(default_factory=dict) + + +class Finding(BaseModel): + """A single review finding. The 9 fields below are mandated by issue #92.""" + + severity: Severity + category: str + file: str + line: Optional[int] = None + title: str + evidence: str + recommendation: str + confidence: float # 0.0 - 1.0 + source: FindingSource + # pipeline bookkeeping (not part of the required 9 fields): + status: FindingStatus = "active" + dedup_key: Optional[str] = None + rule_id: Optional[str] = None # e.g. "bandit:B602", "semgrep:python.lang.security..." + + +class SandboxRunResult(BaseModel): + script: str + exit_code: int = 0 + duration_sec: float = 0.0 + timed_out: bool = False + stdout_bytes: int = 0 + stderr_bytes: int = 0 + blocked: bool = False + block_reason: Optional[str] = None + block_category: Optional[str] = None # "path" | "network" | "budget" | "script" + + +class ReviewReport(BaseModel): + """The rendered report. All 7 sections exist from day 1 (issue criterion 8); + sections not yet populated render empty so the schema never churns across slices.""" + + task_id: str + findings_summary: dict = Field(default_factory=dict) # 1. findings summary + severity_stats: dict[str, int] = Field(default_factory=dict) # 2. severity statistics + human_review: list[Finding] = Field(default_factory=list) # 3. needs-human-review items + filter_blocks: list[dict] = Field(default_factory=list) # 4. Filter interception summary + monitoring: dict = Field(default_factory=dict) # 5. monitoring metrics + sandbox_summary: list[SandboxRunResult] = Field(default_factory=list) # 6. sandbox execution summary + findings: list[Finding] = Field(default_factory=list) # 7. actionable findings + fixes diff --git a/examples/skills_code_review_agent/requirements.txt b/examples/skills_code_review_agent/requirements.txt new file mode 100644 index 00000000..3e67b6b8 --- /dev/null +++ b/examples/skills_code_review_agent/requirements.txt @@ -0,0 +1,8 @@ +# Extra deps for the code-review example. These run scanner-side / inside the sandbox +# and are intentionally NOT added to the SDK's own requirements (keep them out of core CI env). +unidiff>=0.7 # unified-diff parsing +bandit>=1.7 # Python security scanner (security category) +semgrep>=1.60 # semantic pattern scanner (security / injection / deserialization / custom DB-lifecycle rules) +detect-secrets>=1.5 # secret detection -> drives redaction spans (secret-leakage category) +ruff>=0.5 # async-error / resource-leak lint rules +greenlet>=3.0 # required by SQLAlchemy async engine (aiosqlite/asyncpg) 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..40e241e6 --- /dev/null +++ b/examples/skills_code_review_agent/run_agent.py @@ -0,0 +1,71 @@ +#!/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. +"""Run a code review through the LlmAgent (Skills + tool) — the framework-exercising path. + +Dry-run by default: with no API key, FakeReviewModel drives one call to the review_code tool and +summarizes the result — no LLM, no secrets. Set TRPC_AGENT_API_KEY to use a real model instead. + + python run_agent.py --fixture security.diff + python run_agent.py --fixture security.diff --dry-run # force fake model even with a key +""" +from __future__ import annotations + +import argparse +import asyncio +import uuid +from pathlib import Path + +from dotenv import load_dotenv + +from trpc_agent_sdk.runners import Runner +from trpc_agent_sdk.sessions import InMemorySessionService +from trpc_agent_sdk.types import Content, Part + +from agent.agent import create_agent + +HERE = Path(__file__).parent + + +async def review(diff_text: str, dry_run: bool = False) -> None: + app_name = "code_review_agent" + agent = create_agent(dry_run=dry_run) + runner = Runner(app_name=app_name, agent=agent, session_service=InMemorySessionService()) + + user_id, session_id = "reviewer", str(uuid.uuid4()) + await runner.session_service.create_session(app_name=app_name, user_id=user_id, session_id=session_id) + + message = Content(role="user", parts=[Part(text=diff_text)]) + async for event in runner.run_async(user_id=user_id, session_id=session_id, new_message=message): + if not event.content or not event.content.parts: + continue + for part in event.content.parts: + if part.text: + print(part.text, end="", flush=True) + elif part.function_call: + print(f"\n[tool call] {part.function_call.name}") + elif part.function_response: + print(f"[tool result] {part.function_response.response}") + print() + + +def main() -> None: + load_dotenv() + ap = argparse.ArgumentParser(description="Review a diff via the code-review agent.") + src = ap.add_mutually_exclusive_group(required=True) + src.add_argument("--diff-file") + src.add_argument("--fixture") + ap.add_argument("--dry-run", + action="store_true", + help="force the fake model even if an API key is set (no real LLM call)") + args = ap.parse_args() + path = Path(args.diff_file) if args.diff_file else HERE / "fixtures" / "diffs" / args.fixture + asyncio.run(review(path.read_text(encoding="utf-8"), dry_run=args.dry_run)) + + +if __name__ == "__main__": + main() diff --git a/examples/skills_code_review_agent/run_review.py b/examples/skills_code_review_agent/run_review.py new file mode 100644 index 00000000..4ecaab0e --- /dev/null +++ b/examples/skills_code_review_agent/run_review.py @@ -0,0 +1,115 @@ +#!/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. +"""CLI entry point for the deterministic code-review pipeline (issue #92). + +Needs no API key — the scanner pipeline produces a full report and persists it. The default runtime +is the sandbox (`auto` → container if Docker is up, else the local subprocess sandbox). For the +LLM-agent path with a fake model, use run_agent.py --dry-run instead. Examples: + + python run_review.py --diff-file my.diff --out-dir /tmp/cr + python run_review.py --repo-path /path/to/repo + python run_review.py --files pipeline/engine.py,pipeline/scanners.py + python run_review.py --fixture security.diff --no-db --runtime inprocess +""" +from __future__ import annotations + +import argparse +import asyncio +import shutil +import subprocess +from pathlib import Path + +from pipeline import report as report_mod +from pipeline.engine import ReviewResult, run_review, run_review_container + +HERE = Path(__file__).parent + + +def _docker_available() -> bool: + if not shutil.which("docker"): + return False + try: + return subprocess.run(["docker", "info"], capture_output=True, timeout=5).returncode == 0 + except Exception: # noqa: BLE001 + return False + + +def _resolve_runtime(runtime: str) -> str: + """`auto` -> container when Docker is up (production default), else the local subprocess sandbox.""" + if runtime != "auto": + return runtime + return "container" if _docker_available() else "local" + + +def _parse_args() -> argparse.Namespace: + ap = argparse.ArgumentParser(description="Automated code-review agent (Skills + sandbox + DB).") + src = ap.add_mutually_exclusive_group(required=True) + src.add_argument("--diff-file", help="path to a unified-diff file") + src.add_argument("--repo-path", help="path to a git worktree (reviews `git diff`)") + src.add_argument("--files", help="comma-separated list of file paths to review as fully-added") + src.add_argument("--fixture", help="name of a bundled fixture under fixtures/diffs/") + ap.add_argument("--runtime", + choices=["auto", "inprocess", "local", "container"], + default="auto", + help="scanner runtime: auto (sandbox: container if Docker, else local), " + "inprocess (fast dev), local (subprocess sandbox), container (Docker)") + ap.add_argument("--sandbox-timeout", type=float, default=None, help="sandbox timeout in seconds") + ap.add_argument("--out-dir", default=".", help="where to write review_report.json/.md") + ap.add_argument("--db-url", default="sqlite+aiosqlite:///./code_review.db") + ap.add_argument("--no-db", action="store_true", help="skip persistence (report files only)") + return ap.parse_args() + + +def _run(args: argparse.Namespace) -> ReviewResult: + runtime = _resolve_runtime(args.runtime) + if args.repo_path: + src = {"repo_path": args.repo_path} + elif args.files: + src = {"files": [p.strip() for p in args.files.split(",") if p.strip()]} + else: + path = Path(args.diff_file) if args.diff_file else HERE / "fixtures" / "diffs" / args.fixture + src = {"diff_text": path.read_text(encoding="utf-8")} + # Every input mode reaches the resolved runtime — container goes to the async container path so + # --files / --repo-path are not silently downgraded to in-process. + if runtime == "container": + return asyncio.run(run_review_container(sandbox_timeout=args.sandbox_timeout, **src)) + return run_review(runtime=runtime, sandbox_timeout=args.sandbox_timeout, **src) + + +async def _persist(result: ReviewResult, db_url: str) -> None: + from storage.dao import ReviewStore # imported lazily so --no-db needs no DB deps + + store = ReviewStore(db_url) + await store.init() + try: + await store.persist(result) + finally: + await store.close() + + +def main() -> None: + args = _parse_args() + result = _run(args) + + out = Path(args.out_dir) + out.mkdir(parents=True, exist_ok=True) + (out / "review_report.json").write_text(report_mod.render_json(result.report), encoding="utf-8") + (out / "review_report.md").write_text(report_mod.render_md(result.report), encoding="utf-8") + + if not args.no_db: + asyncio.run(_persist(result, args.db_url)) + + s = result.report.findings_summary + print(f"[{result.task_id}] {s.get('total', 0)} findings " + f"({s.get('warnings', 0)} warnings, {s.get('needs_human_review', 0)} for human review) " + f"-> {out}/review_report.json" + f"{'' if args.no_db else ' | persisted: task ' + result.task_id}") + + +if __name__ == "__main__": + 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..1a4654bb --- /dev/null +++ b/examples/skills_code_review_agent/sample_output/review_report.json @@ -0,0 +1,92 @@ +{ + "task_id": "cr-sample-security", + "findings_summary": { + "total": 3, + "warnings": 1, + "needs_human_review": 0, + "by_category": { + "security": 3 + } + }, + "severity_stats": { + "critical": 0, + "high": 1, + "medium": 1, + "low": 1 + }, + "human_review": [ + { + "severity": "low", + "category": "missing_tests", + "file": "security.py", + "line": null, + "title": "Source changed without accompanying tests", + "evidence": "1 source file(s) changed; no test file changed", + "recommendation": "Add or update tests covering the changed code.", + "confidence": 0.6, + "source": "rule", + "status": "warning", + "dedup_key": "security.py::missing_tests:cr:missing-tests", + "rule_id": "cr:missing-tests" + } + ], + "filter_blocks": [], + "monitoring": { + "total_sec": 0.525, + "sandbox_sec": 0, + "tool_calls": 4, + "block_count": 0, + "finding_count": 3, + "severity_dist": { + "low": 1, + "high": 1, + "medium": 1 + }, + "exception_dist": {} + }, + "sandbox_summary": [], + "findings": [ + { + "severity": "low", + "category": "security", + "file": "security.py", + "line": 1, + "title": "blacklist", + "evidence": "1 import subprocess\n2 \n3 def run(cmd):", + "recommendation": "Consider possible security implications associated with the subprocess module.", + "confidence": 0.9, + "source": "static", + "status": "active", + "dedup_key": "security.py:1:security", + "rule_id": "bandit:B404" + }, + { + "severity": "high", + "category": "security", + "file": "security.py", + "line": 4, + "title": "subprocess_popen_with_shell_equals_true", + "evidence": "3 def run(cmd):\n4 return subprocess.call(cmd, shell=True)\n5", + "recommendation": "subprocess call with shell=True identified, security issue.", + "confidence": 0.9, + "source": "static", + "status": "active", + "dedup_key": "security.py:4:security", + "rule_id": "bandit:B602" + }, + { + "severity": "medium", + "category": "security", + "file": "security.py", + "line": 7, + "title": "blacklist", + "evidence": "6 def calc(expr):\n7 return eval(expr)", + "recommendation": "Use of possibly insecure function - consider using safer ast.literal_eval.", + "confidence": 0.9, + "source": "static", + "status": "active", + "dedup_key": "security.py:7:security", + "rule_id": "bandit:B307" + } + ] +} \ 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..89d558d4 --- /dev/null +++ b/examples/skills_code_review_agent/sample_output/review_report.md @@ -0,0 +1,45 @@ +# Code Review Report — `cr-sample-security` + +## 1. Findings summary +- Active findings: **3** +- Warnings: 1 +- Needs human review: 0 + +## 2. Severity statistics +- critical: 0 · high: 1 · medium: 1 · low: 1 + +## 3. Needs human review +- **[low] Source changed without accompanying tests** (`security.py`, missing_tests, conf=0.60, rule) + - 1 source file(s) changed; no test file changed + - _Fix:_ Add or update tests covering the changed code. + +## 4. Filter interception summary +_none_ + +## 5. Monitoring metrics +- total_sec: 0.525 +- sandbox_sec: 0 +- tool_calls: 4 +- block_count: 0 +- finding_count: 3 +- severity_dist: {'low': 1, 'high': 1, 'medium': 1} +- exception_dist: {} + +## 6. Sandbox execution summary +_none_ + +## 7. Findings & fixes +- **[low] blacklist** (`security.py:1`, security, conf=0.90, static) + - 1 import subprocess +2 +3 def run(cmd): + - _Fix:_ Consider possible security implications associated with the subprocess module. +- **[high] subprocess_popen_with_shell_equals_true** (`security.py:4`, security, conf=0.90, static) + - 3 def run(cmd): +4 return subprocess.call(cmd, shell=True) +5 + - _Fix:_ subprocess call with shell=True identified, security issue. +- **[medium] blacklist** (`security.py:7`, security, conf=0.90, static) + - 6 def calc(expr): +7 return eval(expr) + - _Fix:_ Use of possibly insecure function - consider using safer ast.literal_eval. diff --git a/examples/skills_code_review_agent/selftest.py b/examples/skills_code_review_agent/selftest.py new file mode 100644 index 00000000..825d94da --- /dev/null +++ b/examples/skills_code_review_agent/selftest.py @@ -0,0 +1,107 @@ +# 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. +"""Scored acceptance harness (issue #92, criterion 1 & the 80/15 metrics). + +Default: run every fixture in ``fixtures/diffs`` through the deterministic pipeline, compare active +findings to the gold labels in ``fixtures/expected/labels.json``, and print detection / false-positive +rate. This public set is what the rule/severity policy is tuned against. + +``--holdout``: score the *held-out* danger/safe set in ``fixtures/holdout`` (paired danger/safe cases +using patterns the detectors were NOT tuned on) — independent evidence for criterion 2's hidden-sample +detection >= 80% / false-positive <= 15%. A danger case counts as detected when its category is +surfaced at any tier (active / warning / needs-human-review); a safe case is a false positive when its +paired category is surfaced. Exit code is non-zero if the thresholds aren't met (usable in CI). +""" +from __future__ import annotations + +import json +import sys +from pathlib import Path + +from pipeline.engine import run_review + +HERE = Path(__file__).parent +DETECTION_TARGET = 0.80 +FP_TARGET = 0.15 + + +def _match(expected: list[list], findings) -> tuple[int, int, int]: + """Return (tp, fn, fp) for one fixture, matching on (line, category).""" + want = {(ln, cat) for ln, cat in expected} + got = {(f.line, f.category) for f in findings} + tp = len(want & got) + fn = len(want - got) + fp = len(got - want) + return tp, fn, fp + + +def score_holdout(runtime: str = "local") -> tuple[float, float, list]: + """Score the held-out danger/safe set: return (detection_rate, fp_rate, rows). + + detected/false-positive keys on whether the paired category is surfaced at any non-duplicate tier. + """ + labels = json.loads((HERE / "fixtures" / "expected" / "holdout_labels.json").read_text()) + d_hit = d_tot = fp = safe_tot = 0 + rows: list = [] + for name, spec in sorted(labels.items()): + result = run_review(diff_text=(HERE / "fixtures" / "holdout" / name).read_text(), runtime=runtime) + surfaced = {f.category for f in result.findings if f.status != "duplicate"} + hit = spec["category"] in surfaced + if spec["kind"] == "danger": + d_tot += 1 + d_hit += hit + else: + safe_tot += 1 + fp += hit + rows.append((name, spec["kind"], spec["category"], hit, sorted(surfaced))) + return d_hit / max(1, d_tot), fp / max(1, safe_tot), rows + + +def _run_holdout() -> int: + detection, fp_rate, rows = score_holdout(runtime="local") + print(f"{'held-out case':24} {'kind':7} {'category':15} {'result':10} surfaced") + print("-" * 78) + for name, kind, cat, hit, surfaced in rows: + verdict = ("DETECTED" if hit else "MISSED") if kind == "danger" else ("FALSE-POS" if hit else "clean") + print(f"{name:24} {kind:7} {cat:15} {verdict:10} {surfaced}") + print("-" * 78) + print(f"detection rate: {detection:.1%} (target >= {DETECTION_TARGET:.0%})") + print(f"false-positive rate: {fp_rate:.1%} (target <= {FP_TARGET:.0%})") + ok = detection >= DETECTION_TARGET and fp_rate <= FP_TARGET + print("RESULT:", "PASS" if ok else "FAIL") + return 0 if ok else 1 + + +def main() -> int: + labels = json.loads((HERE / "fixtures" / "expected" / "labels.json").read_text()) + tot_tp = tot_fn = tot_fp = tot_active = 0 + + print(f"{'fixture':28} {'active':>6} {'tp':>3} {'fn':>3} {'fp':>3}") + print("-" * 50) + for name, spec in sorted(labels.items()): + # Score through the sandbox (the default production path), not the in-process dev fast-path. + result = run_review(diff_text=(HERE / "fixtures" / "diffs" / name).read_text(), runtime="local") + findings = result.report.findings + tp, fn, fp = _match(spec["expected"], findings) + tot_tp += tp + tot_fn += fn + tot_fp += fp + tot_active += len(findings) + print(f"{name:28} {len(findings):>6} {tp:>3} {fn:>3} {fp:>3}") + + detection = tot_tp / max(1, tot_tp + tot_fn) + fp_rate = tot_fp / max(1, tot_active) + print("-" * 50) + print(f"detection rate: {detection:.1%} (target >= {DETECTION_TARGET:.0%})") + print(f"false-positive rate: {fp_rate:.1%} (target <= {FP_TARGET:.0%})") + + ok = detection >= DETECTION_TARGET and fp_rate <= FP_TARGET + print("RESULT:", "PASS" if ok else "FAIL") + return 0 if ok else 1 + + +if __name__ == "__main__": + sys.exit(_run_holdout() if "--holdout" in sys.argv[1:] else main()) 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..bc6e483f --- /dev/null +++ b/examples/skills_code_review_agent/storage/__init__.py @@ -0,0 +1,5 @@ +# 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. diff --git a/examples/skills_code_review_agent/storage/dao.py b/examples/skills_code_review_agent/storage/dao.py new file mode 100644 index 00000000..aa5a419a --- /dev/null +++ b/examples/skills_code_review_agent/storage/dao.py @@ -0,0 +1,128 @@ +# 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 for reviews (issue #92, requirements 3 & 5). + +Wraps the framework's ``SqlStorage`` so the schema is portable (SQLite default, PostgreSQL/MySQL +by URL) and gets forward-only migrations for free. Every string is routed through ``redact()`` +before it touches the DB — criterion 5 forbids plaintext secrets in the database. +""" +from __future__ import annotations + +import uuid +from typing import Any, Optional + +from sqlalchemy import select + +from trpc_agent_sdk.storage import SqlStorage + +from pipeline.engine import ReviewResult +from pipeline.redaction import redact, redact_finding + +from .models import CodeReviewBase, FindingORM, ReportORM, ReviewTaskORM, SandboxRunORM + +DEFAULT_DB_URL = "sqlite+aiosqlite:///./code_review.db" + + +class ReviewStore: + + def __init__(self, db_url: str = DEFAULT_DB_URL) -> None: + self._storage = SqlStorage(is_async=True, db_url=db_url, metadata=CodeReviewBase.metadata) + + async def init(self) -> None: + await self._storage.create_sql_engine() + + async def close(self) -> None: + await self._storage.close() + + async def persist(self, result: ReviewResult) -> None: + """Write the task, its findings, and the report summary. All strings are redacted.""" + mon = result.monitoring + # Real status — a blocked or failed review must not persist as "completed". + if int(mon.get("block_count", 0)) > 0: + status = "blocked" + elif mon.get("exception_dist"): + status = "failed" + else: + status = "completed" + async with self._storage.create_db_session() as db: + task = ReviewTaskORM( + id=result.task_id, + source_type=result.source_type, + source_ref=redact(result.source_ref), + runtime="local", + dry_run=True, + status=status, + block_count=int(mon.get("block_count", 0)), + finding_count=int(mon.get("finding_count", 0)), + severity_dist=mon.get("severity_dist", {}), + exception_dist=mon.get("exception_dist", {}), + diff_summary={ + "files_changed": result.summary.files_changed, + "added": result.summary.added, + "removed": result.summary.removed, + "languages": result.summary.languages, + "changed_files": [f.path for f in result.summary.files], + }, + ) + await self._storage.add(db, task) + + for f in result.findings: + rf = redact_finding(f) + await self._storage.add( + db, + FindingORM( + id=f"fd-{uuid.uuid4().hex[:12]}", + task_id=result.task_id, + severity=rf.severity, + category=rf.category, + file=rf.file, + line=rf.line, + title=rf.title, + evidence={"text": rf.evidence}, + recommendation=rf.recommendation, + confidence=rf.confidence, + source=rf.source, + status=rf.status, + dedup_key=rf.dedup_key, + )) + + for run in result.report.sandbox_summary: + await self._storage.add( + db, + SandboxRunORM( + id=f"sb-{uuid.uuid4().hex[:12]}", + task_id=result.task_id, + script=run.script, + exit_code=run.exit_code, + duration_sec=run.duration_sec, + timed_out=run.timed_out, + stdout_bytes=run.stdout_bytes, + stderr_bytes=run.stderr_bytes, + blocked=run.blocked, + block_reason=run.block_reason, + block_category=run.block_category, + )) + + await self._storage.add( + db, + ReportORM( + id=f"rp-{uuid.uuid4().hex[:12]}", + task_id=result.task_id, + format="json", + summary=result.report.findings_summary, + )) + await self._storage.commit(db) + + async def get_by_task_id(self, task_id: str) -> Optional[dict[str, Any]]: + """Return the whole review by task id (requirement 3): task + findings + runs + report.""" + async with self._storage.create_db_session() as db: + task = await db.get(ReviewTaskORM, task_id) + if task is None: + return None + findings = (await db.execute(select(FindingORM).where(FindingORM.task_id == task_id))).scalars().all() + runs = (await db.execute(select(SandboxRunORM).where(SandboxRunORM.task_id == task_id))).scalars().all() + report = (await db.execute(select(ReportORM).where(ReportORM.task_id == task_id))).scalars().first() + return {"task": task, "findings": findings, "sandbox_runs": runs, "report": report} 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..535f16de --- /dev/null +++ b/examples/skills_code_review_agent/storage/models.py @@ -0,0 +1,112 @@ +# 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 ORM models for code-review persistence (issue #92, requirement 5). + +Mirrors the framework's own SQL pattern (``trpc_agent_sdk/sessions/_sql_session_service.py``): +a dedicated ``DeclarativeBase`` subclass so ``SqlStorage`` only creates *these* tables, and +portable column decorators (``UTF8MB4String`` / ``DynamicJSON`` / ``PreciseTimestamp``) so the +same schema runs on SQLite (default), PostgreSQL, or MySQL with no code change. + +Four tables, all keyed by ``task_id`` so a whole review is queryable by task id (requirement 3). +""" +from __future__ import annotations + +from datetime import datetime +from typing import Any, Optional + +from sqlalchemy import Boolean, Float, ForeignKey, Integer, func +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship + +from trpc_agent_sdk.storage import ( + DEFAULT_MAX_KEY_LENGTH, + DEFAULT_MAX_VARCHAR_LENGTH, + DynamicJSON, + PreciseTimestamp, + UTF8MB4String, +) + + +class CodeReviewBase(DeclarativeBase): + """Isolated metadata: SqlStorage(metadata=CodeReviewBase.metadata) creates only these tables.""" + + +class ReviewTaskORM(CodeReviewBase): + __tablename__ = "cr_review_tasks" + + id: Mapped[str] = mapped_column(UTF8MB4String(DEFAULT_MAX_KEY_LENGTH), primary_key=True) + source_type: Mapped[str] = mapped_column(UTF8MB4String(DEFAULT_MAX_VARCHAR_LENGTH)) # diff_file|repo_path|fixture + source_ref: Mapped[str] = mapped_column(UTF8MB4String(DEFAULT_MAX_VARCHAR_LENGTH)) + model_name: Mapped[str] = mapped_column(UTF8MB4String(DEFAULT_MAX_VARCHAR_LENGTH), default="") + runtime: Mapped[str] = mapped_column(UTF8MB4String(DEFAULT_MAX_VARCHAR_LENGTH), default="local") + dry_run: Mapped[bool] = mapped_column(Boolean, default=True) + status: Mapped[str] = mapped_column(UTF8MB4String(DEFAULT_MAX_VARCHAR_LENGTH), default="running") + block_count: Mapped[int] = mapped_column(Integer, default=0) + finding_count: Mapped[int] = mapped_column(Integer, default=0) + severity_dist: Mapped[dict[str, Any]] = mapped_column(DynamicJSON, default=dict) + exception_dist: Mapped[dict[str, Any]] = mapped_column(DynamicJSON, default=dict) + diff_summary: Mapped[dict[str, Any]] = mapped_column(DynamicJSON, default=dict) # input-diff summary (要求5) + create_time: Mapped[datetime] = mapped_column(PreciseTimestamp, default=func.now()) + update_time: Mapped[datetime] = mapped_column(PreciseTimestamp, default=func.now(), onupdate=func.now()) + + sandbox_runs: Mapped[list["SandboxRunORM"]] = relationship(back_populates="task", cascade="all, delete-orphan") + findings: Mapped[list["FindingORM"]] = relationship(back_populates="task", cascade="all, delete-orphan") + reports: Mapped[list["ReportORM"]] = relationship(back_populates="task", cascade="all, delete-orphan") + + +class SandboxRunORM(CodeReviewBase): + __tablename__ = "cr_sandbox_runs" + + id: Mapped[str] = mapped_column(UTF8MB4String(DEFAULT_MAX_KEY_LENGTH), primary_key=True) + task_id: Mapped[str] = mapped_column(ForeignKey("cr_review_tasks.id", ondelete="CASCADE")) + script: Mapped[str] = mapped_column(UTF8MB4String(DEFAULT_MAX_VARCHAR_LENGTH), default="") + cmd: Mapped[str] = mapped_column(UTF8MB4String(DEFAULT_MAX_VARCHAR_LENGTH), default="") + exit_code: Mapped[int] = mapped_column(Integer, default=0) + duration_sec: Mapped[float] = mapped_column(Float, default=0.0) + timed_out: Mapped[bool] = mapped_column(Boolean, default=False) + stdout_bytes: Mapped[int] = mapped_column(Integer, default=0) + stderr_bytes: Mapped[int] = mapped_column(Integer, default=0) + blocked: Mapped[bool] = mapped_column(Boolean, default=False) + block_reason: Mapped[Optional[str]] = mapped_column(UTF8MB4String(DEFAULT_MAX_VARCHAR_LENGTH), nullable=True) + block_category: Mapped[Optional[str]] = mapped_column(UTF8MB4String(DEFAULT_MAX_VARCHAR_LENGTH), nullable=True) + create_time: Mapped[datetime] = mapped_column(PreciseTimestamp, default=func.now()) + + task: Mapped["ReviewTaskORM"] = relationship(back_populates="sandbox_runs") + + +class FindingORM(CodeReviewBase): + __tablename__ = "cr_findings" + + id: Mapped[str] = mapped_column(UTF8MB4String(DEFAULT_MAX_KEY_LENGTH), primary_key=True) + task_id: Mapped[str] = mapped_column(ForeignKey("cr_review_tasks.id", ondelete="CASCADE")) + sandbox_run_id: Mapped[Optional[str]] = mapped_column(ForeignKey("cr_sandbox_runs.id", ondelete="SET NULL"), + nullable=True) + severity: Mapped[str] = mapped_column(UTF8MB4String(DEFAULT_MAX_VARCHAR_LENGTH)) + category: Mapped[str] = mapped_column(UTF8MB4String(DEFAULT_MAX_VARCHAR_LENGTH)) + file: Mapped[str] = mapped_column(UTF8MB4String(DEFAULT_MAX_VARCHAR_LENGTH)) + line: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) + title: Mapped[str] = mapped_column(UTF8MB4String(DEFAULT_MAX_VARCHAR_LENGTH)) + evidence: Mapped[dict[str, Any]] = mapped_column(DynamicJSON, default=dict) + recommendation: Mapped[str] = mapped_column(UTF8MB4String(DEFAULT_MAX_VARCHAR_LENGTH), default="") + confidence: Mapped[float] = mapped_column(Float, default=0.0) + source: Mapped[str] = mapped_column(UTF8MB4String(DEFAULT_MAX_VARCHAR_LENGTH), default="rule") + status: Mapped[str] = mapped_column(UTF8MB4String(DEFAULT_MAX_VARCHAR_LENGTH), default="active") + dedup_key: Mapped[Optional[str]] = mapped_column(UTF8MB4String(DEFAULT_MAX_KEY_LENGTH), nullable=True, index=True) + create_time: Mapped[datetime] = mapped_column(PreciseTimestamp, default=func.now()) + + task: Mapped["ReviewTaskORM"] = relationship(back_populates="findings") + + +class ReportORM(CodeReviewBase): + __tablename__ = "cr_reports" + + id: Mapped[str] = mapped_column(UTF8MB4String(DEFAULT_MAX_KEY_LENGTH), primary_key=True) + task_id: Mapped[str] = mapped_column(ForeignKey("cr_review_tasks.id", ondelete="CASCADE")) + format: Mapped[str] = mapped_column(UTF8MB4String(DEFAULT_MAX_VARCHAR_LENGTH), default="json") # json|md + content_ref: Mapped[str] = mapped_column(UTF8MB4String(DEFAULT_MAX_VARCHAR_LENGTH), default="") + summary: Mapped[dict[str, Any]] = mapped_column(DynamicJSON, default=dict) + create_time: Mapped[datetime] = mapped_column(PreciseTimestamp, default=func.now()) + + task: Mapped["ReviewTaskORM"] = relationship(back_populates="reports") diff --git a/skills/code-review/Dockerfile b/skills/code-review/Dockerfile new file mode 100644 index 00000000..618b750a --- /dev/null +++ b/skills/code-review/Dockerfile @@ -0,0 +1,12 @@ +# Scanner image for the code-review sandbox (production runtime). +# Build: docker build -t cr-scanners:latest skills/code-review +# Used by pipeline/sandbox.py::run_container (engine --runtime container). +FROM python:3.12-slim + +RUN pip install --no-cache-dir bandit ruff "detect-secrets>=1.5" semgrep + +# The standalone scanner entry point (self-contained; no example package import). +COPY scripts/run_checks.py /opt/skill/run_checks.py +COPY rules /opt/skill/rules + +WORKDIR /workspace diff --git a/skills/code-review/SKILL.md b/skills/code-review/SKILL.md new file mode 100644 index 00000000..6f789fb8 --- /dev/null +++ b/skills/code-review/SKILL.md @@ -0,0 +1,41 @@ +--- +name: code-review +description: Review a code change for security, resource, async, secret-leakage and DB-lifecycle issues by running established static scanners in a sandbox and emitting structured findings. +--- + +# Code Review skill + +Reviews a set of changed files by running mature static-analysis scanners and normalizing their +output into a single findings contract. Designed to run inside a sandboxed workspace (Container or +Cube/E2B); it reads the changed files and writes `out/findings.json`. + +## When to use + +Load this skill when you need to review a diff or a set of changed files and produce structured, +deduplicated findings (severity / category / file / line / evidence / recommendation / confidence). + +## Rule coverage + +Findings come from deterministic scanners, not from the model, so results are reproducible: + +| Category | Tool | +|---|---| +| security | bandit, semgrep | +| secret_leakage | detect-secrets | +| async_errors | ruff (ASYNC rules) | +| resource_leak | ruff (SIM115 / bugbear) | +| db_lifecycle | semgrep (`rules/db_lifecycle.yaml`) | + +## Invocation + +``` +python scripts/run_checks.py --target [--out out/findings.json] +``` + +`--target` is a directory containing the changed files. Output conforms to `docs/OUTPUT_SCHEMA.md`. + +## Output contract + +See `docs/OUTPUT_SCHEMA.md` — the single source of truth. The nine fields +(severity, category, file, line, title, evidence, recommendation, confidence, source) are mandatory. +Secrets in `evidence` are redacted before output. diff --git a/skills/code-review/docs/OUTPUT_SCHEMA.md b/skills/code-review/docs/OUTPUT_SCHEMA.md new file mode 100644 index 00000000..13f78d49 --- /dev/null +++ b/skills/code-review/docs/OUTPUT_SCHEMA.md @@ -0,0 +1,31 @@ +# Findings JSON contract (single source of truth) + +The sandbox scripts (`scripts/run_checks.py`) emit `out/findings.json`. Both the standalone +skill (which must run without importing the example package) and the example pipeline +(`pipeline/types.py::Finding`) are anchored to this schema. Change them together. + +```jsonc +{ + "findings": [ + { + "severity": "critical | high | medium | low", // required + "category": "string", // required, e.g. "security", "secret_leakage" + "file": "path/to/file.py", // required + "line": 42, // required (nullable if file-level) + "title": "string", // required, one-line + "evidence": "string", // required, the offending snippet / reason + "recommendation": "string", // required, how to fix + "confidence": 0.0, // required, 0.0 - 1.0 + "source": "rule | llm | static", // required (which producer) + + "rule_id": "bandit:B602", // optional, tool + rule id + "status": "active | duplicate | warning | needs_human_review" // set by dedup/denoise stage + } + ] +} +``` + +Rules: +- The nine fields above `rule_id` are **mandatory** (issue #92, requirement 4). Missing any = invalid. +- Every scanner's native output is normalized into this shape by `pipeline/scanners.py`. +- Secrets in `evidence` MUST be redacted before this JSON is persisted or rendered. diff --git a/skills/code-review/docs/RULES.md b/skills/code-review/docs/RULES.md new file mode 100644 index 00000000..efca9cb1 --- /dev/null +++ b/skills/code-review/docs/RULES.md @@ -0,0 +1,20 @@ +# Code-review rules + +Findings come from established scanners, normalized into the schema in `OUTPUT_SCHEMA.md`. Each of the +six required categories is backed by a concrete tool or rule: + +| Category | Backed by | What it flags | +|---|---|---| +| `security` | **bandit** (all rules except B101) + **ruff** flake8-bandit (`S`) | `eval`/`exec`, `subprocess(..., shell=True)`, `os.system`, `yaml.load`, pickle, weak crypto, hardcoded secrets, etc. | +| `secret_leakage` | **detect-secrets** | AWS keys, tokens, high-entropy strings, private keys in the changed files. | +| `async_errors` | **ruff** `ASYNC` ruleset | blocking calls in `async` functions (e.g. `time.sleep`, blocking `open`). | +| `resource_leak` | **ruff** `SIM115` + flake8-bugbear (`B`) | files/resources opened without a context manager. | +| `db_lifecycle` | `db_lifecycle.yaml` (semgrep) **and** the built-in heuristic in `scripts/run_checks.py` | a DB connection/cursor opened without `with` and never `close()`d. | +| `missing_tests` | diff-level heuristic (engine) | a source file changed with no corresponding test change. | + +Notes: +- `assert`-used (bandit `B101` / ruff `S101`) is suppressed — it is noise, especially in tests. +- A required scanner that is not installed produces a `scanner_unavailable` finding routed to + `needs_human_review`, so a missing tool can never be mistaken for "clean". +- Severity/confidence mappings are identical between the in-process path (`pipeline/scanners.py`) and + the standalone sandbox path (`scripts/run_checks.py`); a parity test enforces this. diff --git a/skills/code-review/rules/db_lifecycle.yaml b/skills/code-review/rules/db_lifecycle.yaml new file mode 100644 index 00000000..2712708b --- /dev/null +++ b/skills/code-review/rules/db_lifecycle.yaml @@ -0,0 +1,27 @@ +rules: + - id: db-connection-without-context-manager + languages: [python] + severity: WARNING + message: > + Database connection opened without a context manager or explicit close(); the connection may + leak on an exception path. Use `with conn:` / a context manager, or ensure close() in finally. + patterns: + - pattern-either: + - pattern: $CONN = $DB.connect(...) + - pattern: $CONN = sqlite3.connect(...) + - pattern: $CONN = psycopg2.connect(...) + - pattern-not-inside: | + with ...: + ... + + - id: cursor-execute-without-commit + languages: [python] + severity: WARNING + message: > + A write executed on a cursor without a visible commit()/rollback() in scope may not persist or + may hold a transaction open. Ensure the transaction is committed or rolled back. + patterns: + - pattern: $CUR.execute("...") + - metavariable-regex: + metavariable: $CUR + regex: (?i).*cursor.* diff --git a/skills/code-review/scripts/run_checks.py b/skills/code-review/scripts/run_checks.py new file mode 100644 index 00000000..ff1b2585 --- /dev/null +++ b/skills/code-review/scripts/run_checks.py @@ -0,0 +1,207 @@ +#!/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. +"""Standalone sandbox entry point: run scanners over a target directory -> out/findings.json. + +Self-contained by design — the skill must run inside a sandbox without importing the example +package. Output conforms to ../docs/OUTPUT_SCHEMA.md. In slice 2 the container sandbox invokes this; +the example's in-process path uses ``pipeline/scanners.py`` (same tools, same schema). +""" +from __future__ import annotations + +import argparse +import json +import os +import re +import shutil +import subprocess +from pathlib import Path + +_MASK = "***REDACTED***" +_SECRET_RE = re.compile( + r"""(?ix)\b(password|passwd|secret|api[_-]?key|token|auth|client[_-]?secret)\b\s*[:=]\s*['"]?([^\s'"]{4,})""") +_STANDALONE = [re.compile(r"\b(AKIA|ASIA)[0-9A-Z]{16}\b"), re.compile(r"\bghp_[A-Za-z0-9]{36}\b")] + + +def _redact(text: str) -> str: + if not text: + return text or "" + out = _SECRET_RE.sub(lambda m: f"{m.group(1)}={_MASK}", text) + for pat in _STANDALONE: + out = pat.sub(_MASK, out) + return out + + +_NOISE_RULES = {"B101", "S101"} # assert-used — noise, especially in tests + +# Kept identical to pipeline/scanners.py so both paths emit the same findings (a parity test enforces +# this). The skill cannot import the example package, so the mapping is duplicated by necessity. +_BANDIT_CONF = {"HIGH": 0.9, "MEDIUM": 0.6, "LOW": 0.4} +_RUFF_MAP = [("ASYNC", "async_errors", "high"), ("SIM115", "resource_leak", "medium"), ("S", "security", "high"), + ("B", "resource_leak", "medium")] +_REQUIRED_TOOLS = {"bandit": "security", "ruff": "async_errors/resource_leak", "detect-secrets": "secret_leakage"} + + +def _ruff_cat_sev(code: str) -> tuple[str, str]: + for prefix, cat, sev in _RUFF_MAP: + if code.startswith(prefix): + return cat, sev + return "code_quality", "low" + + +_IGNORE_DIRS = {".ruff_cache", "__pycache__", ".git", ".mypy_cache", ".pytest_cache", "node_modules"} + + +def _source_files(target: str): + """Files under target excluding tool caches / hidden dirs (which scanners create and pollute scans).""" + for p in Path(target).rglob("*"): + if p.is_file() and not any(part in _IGNORE_DIRS or part.startswith(".") for part in p.parts): + yield p + + +def _rel(path: str, root: str) -> str: + """Normalize a scanner-reported path to be relative to root (kept in sync with scanners.py::_rel).""" + if os.path.isabs(path): + try: + return os.path.normpath(os.path.relpath(os.path.realpath(path), os.path.realpath(root))) + except ValueError: + return os.path.normpath(path) + return os.path.normpath(path) + + +def _run(cmd: list[str], cwd: str) -> subprocess.CompletedProcess: + return subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=120, check=False) + + +def _finding(**kw) -> dict: + kw["evidence"] = _redact(kw.get("evidence", "")) + return kw + + +def collect(target: str) -> list[dict]: + findings: list[dict] = [] + if shutil.which("bandit"): + proc = _run(["bandit", "-r", ".", "-f", "json", "-q"], cwd=target) + if proc.stdout.strip(): + for r in json.loads(proc.stdout).get("results", []): + if r.get("test_id") in _NOISE_RULES: + continue + sev = {"HIGH": "high", "MEDIUM": "medium", "LOW": "low"}.get(r.get("issue_severity"), "low") + findings.append( + _finding(severity=sev, + category="security", + file=_rel(r["filename"], target), + line=r.get("line_number"), + title=r.get("test_name", "security issue"), + evidence=(r.get("code") or r.get("issue_text", "")).strip(), + recommendation=r.get("issue_text", "Review this security finding."), + confidence=_BANDIT_CONF.get(r.get("issue_confidence", "MEDIUM"), 0.6), + source="static", + rule_id=f"bandit:{r.get('test_id', '')}")) + if shutil.which("ruff"): + proc = _run( + ["ruff", "check", ".", "--no-cache", "--output-format", "json", "--select", "ASYNC,SIM115,B,S", "--quiet"], + cwd=target) + if proc.stdout.strip(): + for r in json.loads(proc.stdout): + code = r.get("code") or "" + if code in _NOISE_RULES: + continue + cat, sev = _ruff_cat_sev(code) + findings.append( + _finding(severity=sev, + category=cat, + file=_rel(r["filename"], target), + line=(r.get("location") or {}).get("row"), + title=code or "lint issue", + evidence=r.get("message", ""), + recommendation=r.get("message", "See ruff rule documentation."), + confidence=0.7, + source="static", + rule_id=f"ruff:{code}")) + if shutil.which("detect-secrets"): + files = [str(p.relative_to(target)) for p in _source_files(target)] + if files: + proc = _run(["detect-secrets", "scan", *files], cwd=target) + if proc.stdout.strip(): + for f, hits in (json.loads(proc.stdout).get("results") or {}).items(): + for h in hits: + findings.append( + _finding(severity="critical", + category="secret_leakage", + file=os.path.normpath(f), + line=h.get("line_number"), + title=f"Possible secret: {h.get('type')}", + evidence="secret detected (value redacted)", + recommendation="Remove secret from source; use env vars / a secret manager.", + confidence=0.85, + source="static", + rule_id=f"detect-secrets:{h.get('type')}")) + findings.extend(_db_lifecycle(target)) + for tool, covers in _REQUIRED_TOOLS.items(): + if not shutil.which(tool): + findings.append( + _finding(severity="low", + category="scanner_unavailable", + file="", + line=None, + title=f"{tool} unavailable — {covers} not checked", + evidence=f"scanner '{tool}' is not installed in this environment", + recommendation=f"Install {tool} so {covers} is actually scanned.", + confidence=0.3, + source="static", + rule_id=f"internal:missing:{tool}")) + return findings + + +_DB_CONNECT = re.compile(r"\b([A-Za-z_]\w*)\s*=\s*[\w.]*\b(connect|cursor)\s*\(") + + +def _db_lifecycle(target: str) -> list[dict]: + """DB connection/cursor opened without `with` and never closed (no semgrep needed).""" + out: list[dict] = [] + for p in _source_files(target): + if p.suffix != ".py": + continue + try: + content = p.read_text(encoding="utf-8", errors="replace") + except OSError: + continue + for i, text in enumerate(content.splitlines(), start=1): + m = _DB_CONNECT.search(text) + if not m or text.lstrip().startswith("with "): + continue + var = m.group(1) + if re.search(rf"\b{re.escape(var)}\s*\.\s*close\s*\(", content): + continue + out.append( + _finding(severity="medium", + category="db_lifecycle", + file=os.path.normpath(str(p.relative_to(target))), + line=i, + title="DB resource without lifecycle management", + evidence=text.strip(), + recommendation=f"Use a context manager or ensure `{var}.close()` in a finally block.", + confidence=0.7, + source="static", + rule_id="cr:db-lifecycle")) + return out + + +def main() -> None: + ap = argparse.ArgumentParser() + ap.add_argument("--target", required=True) + ap.add_argument("--out", default="out/findings.json") + args = ap.parse_args() + out = Path(args.out) + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text(json.dumps({"findings": collect(args.target)}, indent=2), encoding="utf-8") + print(f"wrote {out}") + + +if __name__ == "__main__": + main() diff --git a/tests/examples/__init__.py b/tests/examples/__init__.py new file mode 100644 index 00000000..bc6e483f --- /dev/null +++ b/tests/examples/__init__.py @@ -0,0 +1,5 @@ +# 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. diff --git a/tests/examples/test_skills_code_review_agent.py b/tests/examples/test_skills_code_review_agent.py new file mode 100644 index 00000000..09e0a071 --- /dev/null +++ b/tests/examples/test_skills_code_review_agent.py @@ -0,0 +1,487 @@ +# 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. +"""Smoke tests for the skills_code_review_agent example. + +Deterministic and fast: no real model, no Docker. Verifies the dry-run pipeline detects issues, +never leaks a plaintext secret, dedups correctly, and persists a task queryable by id. +""" +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +_EXAMPLE_DIR = Path(__file__).resolve().parents[2] / "examples" / "skills_code_review_agent" +if str(_EXAMPLE_DIR) not in sys.path: + sys.path.insert(0, str(_EXAMPLE_DIR)) + +# This example ships its own dependencies (examples/skills_code_review_agent/requirements.txt) that the +# SDK's test job does not install. Skip the whole module cleanly when they are absent rather than failing +# collection in the main CI. +pytest.importorskip("unidiff", reason="run: pip install -r examples/skills_code_review_agent/requirements.txt") + +from pipeline import report as report_mod # noqa: E402 +from pipeline.dedup import dedup_and_denoise # noqa: E402 +from pipeline.engine import run_review # noqa: E402 +from pipeline.redaction import redact # noqa: E402 +from pipeline.types import Finding # noqa: E402 + +_FIXTURES = _EXAMPLE_DIR / "fixtures" / "diffs" +_SECRETS = ["AKIA1234567890ABCDEF"] # the secret embedded in secret_redaction.diff + + +def test_detects_issues_across_categories() -> None: + result = run_review(diff_text=(_FIXTURES / "security.diff").read_text()) + cats = {f.category for f in result.report.findings} + assert "security" in cats + assert result.report.findings_summary["total"] >= 3 + + +def test_clean_diff_has_no_active_findings() -> None: + result = run_review(diff_text=(_FIXTURES / "clean.diff").read_text()) + assert result.report.findings_summary["total"] == 0 + + +def test_no_plaintext_secret_in_rendered_report() -> None: + result = run_review(diff_text=(_FIXTURES / "secret_redaction.diff").read_text()) + blob = report_mod.render_json(result.report) + report_mod.render_md(result.report) + for secret in _SECRETS: + assert secret not in blob + + +def test_redact_masks_common_secrets() -> None: + assert "hunter2" not in redact('password = "hunter2supersecret"') + assert "AKIA1234567890ABCDEF" not in redact('key = "AKIA1234567890ABCDEF"') + + +def test_dedup_collapses_same_file_line_category() -> None: + a = Finding(severity="high", + category="security", + file="x.py", + line=1, + title="t", + evidence="e", + recommendation="r", + confidence=0.9, + source="static") + b = a.model_copy(update={"confidence": 0.5}) + out = dedup_and_denoise([a, b]) + active = [f for f in out if f.status == "active"] + dupes = [f for f in out if f.status == "duplicate"] + assert len(active) == 1 and active[0].confidence == 0.9 + assert len(dupes) == 1 + + +def test_low_confidence_routed_to_human_review() -> None: + f = Finding(severity="low", + category="security", + file="x.py", + line=2, + title="t", + evidence="e", + recommendation="r", + confidence=0.2, + source="static") + out = dedup_and_denoise([f]) + assert out[0].status == "needs_human_review" + + +@pytest.mark.asyncio +async def test_persist_and_query_no_secret_leak(tmp_path) -> None: + from storage.dao import ReviewStore + + result = run_review(diff_text=(_FIXTURES / "secret_redaction.diff").read_text()) + db_file = tmp_path / "cr.db" + store = ReviewStore(f"sqlite+aiosqlite:///{db_file}") + await store.init() + try: + await store.persist(result) + got = await store.get_by_task_id(result.task_id) + assert got is not None + assert got["task"].finding_count >= 1 + assert len(got["findings"]) >= 1 + finally: + await store.close() + + raw = db_file.read_bytes() + for secret in _SECRETS: + assert secret.encode() not in raw + + +@pytest.mark.asyncio +async def test_agent_path_calls_tool_and_summarizes() -> None: + """The fake-model agent loop drives the review_code tool and summarizes — no API key.""" + 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.agent import create_agent + + runner = Runner(app_name="cr_test", agent=create_agent(), session_service=InMemorySessionService()) + sid = str(uuid.uuid4()) + await runner.session_service.create_session(app_name="cr_test", user_id="u", session_id=sid) + + diff = (_FIXTURES / "security.diff").read_text() + saw_tool_call = False + final_text = "" + async for event in runner.run_async(user_id="u", + session_id=sid, + new_message=Content(role="user", parts=[Part(text=diff)])): + for part in (event.content.parts if event.content else []) or []: + if part.function_call: + saw_tool_call = True + if part.text: + final_text += part.text + + assert saw_tool_call + assert "Review complete" in final_text + for secret in _SECRETS: + assert secret not in final_text + + +def test_local_sandbox_records_run_and_finds_issues() -> None: + result = run_review(diff_text=(_FIXTURES / "security.diff").read_text(), runtime="local") + assert result.report.findings_summary["total"] >= 3 + assert len(result.report.sandbox_summary) == 1 + run = result.report.sandbox_summary[0] + assert run.script == "run_checks.py" + assert run.exit_code in (0, 1) # 1 = scanners found issues + assert not run.timed_out + assert result.monitoring["sandbox_sec"] > 0 + + +def test_sandbox_timeout_does_not_crash_the_task() -> None: + # An impossibly small timeout must mark the run timed-out but still complete the review. + result = run_review(diff_text=(_FIXTURES / "security.diff").read_text(), runtime="local", sandbox_timeout=0.001) + assert result.task_id is not None + run = result.report.sandbox_summary[0] + assert run.timed_out is True + assert result.monitoring["exception_dist"].get("sandbox_failure") == 1 + + +def test_sandbox_output_byte_accounting() -> None: + from pipeline.sandbox import _truncate + + text, n = _truncate("x" * 5000, 10) + assert n == 5000 # records the true size + assert len(text.encode()) <= 10 + len("\n...[truncated]") + + +def test_policy_decisions() -> None: + from pipeline.policy import ReviewPolicy + + p = ReviewPolicy() + assert p.evaluate(command="rm -rf /tmp/x").decision == "deny" + assert p.evaluate(command="python run_checks.py").decision == "allow" + assert p.evaluate(command="cat x", touched_paths=["/etc/passwd"]).decision == "deny" + assert p.evaluate(command="fetch", network_hosts=["evil.com"]).decision == "needs_human_review" + + +def test_denied_action_never_reaches_sandbox() -> None: + from pipeline.policy import ReviewPolicy + + # A policy that refuses everything (tiny budget) must block before execution (requirement 7). + result = run_review(diff_text=(_FIXTURES / "security.diff").read_text(), + runtime="local", + policy=ReviewPolicy(max_budget_sec=1e-6), + sandbox_timeout=60) + run = result.report.sandbox_summary[0] + assert run.blocked is True + assert run.duration_sec == 0.0 # never executed + assert result.report.findings_summary["total"] == 0 + assert result.report.filter_blocks and result.report.filter_blocks[0]["category"] == "budget" + assert result.monitoring["block_count"] == 1 + + +@pytest.mark.asyncio +async def test_guard_filter_blocks_dangerous_command() -> None: + from trpc_agent_sdk.filter import FilterResult + + from agent.filter import ReviewGuardFilter + + guard = ReviewGuardFilter() + dangerous = FilterResult() + await guard._before(None, {"command": "rm -rf /"}, dangerous) + assert dangerous.is_continue is False + + safe = FilterResult() + await guard._before(None, {"diff_text": "some diff"}, safe) + assert safe.is_continue is True # review_code has no command arg -> passes + + +def test_report_renders_filter_block_section() -> None: + from pipeline.policy import ReviewPolicy + + result = run_review(diff_text=(_FIXTURES / "security.diff").read_text(), + runtime="local", + policy=ReviewPolicy(max_budget_sec=1e-6), + sandbox_timeout=60) + md = report_mod.render_md(result.report) + assert "## 4. Filter interception summary" in md + assert "over budget" in md + + +# --- official-scenario fixtures (交付物: the 8 required sample diffs) ------------------------------ + + +def test_db_lifecycle_scenario() -> None: + result = run_review(diff_text=(_FIXTURES / "db_lifecycle.diff").read_text()) + assert any(f.category == "db_lifecycle" for f in result.report.findings) + + +def test_missing_tests_scenario() -> None: + result = run_review(diff_text=(_FIXTURES / "missing_tests.diff").read_text()) + # source changed with no test -> a missing_tests finding (routed to warnings/human-review). + assert any(f.category == "missing_tests" for f in result.report.human_review) + + +def test_duplicate_finding_scenario_is_collapsed() -> None: + result = run_review(diff_text=(_FIXTURES / "duplicate_finding.diff").read_text()) + # bandit + ruff both flag os.system on the same line+category -> one active, one duplicate. + security = [f for f in result.findings if f.category == "security"] + active = [f for f in security if f.status == "active"] + dupes = [f for f in security if f.status == "duplicate"] + assert len(active) == 1 + assert len(dupes) >= 1 + + +def test_sandbox_failure_scenario_degrades_gracefully() -> None: + # A failing sandbox run (tiny timeout) must be recorded without crashing the review. + result = run_review(diff_text=(_FIXTURES / "sandbox_failure.diff").read_text(), + runtime="local", + sandbox_timeout=0.001) + assert result.task_id is not None + assert result.report.sandbox_summary[0].timed_out is True + + +def test_all_six_rule_categories_reachable() -> None: + cats: set[str] = set() + for name in ("security.diff", "secret_redaction.diff", "async_resource_leak.diff", "db_lifecycle.diff", + "missing_tests.diff"): + r = run_review(diff_text=(_FIXTURES / name).read_text()) + cats.update(f.category for f in r.findings) + for required in ("security", "secret_leakage", "async_errors", "resource_leak", "db_lifecycle", "missing_tests"): + assert required in cats, f"category {required} not produced" + + +# --- spec-alignment: input modes, env whitelist, diff-summary persistence ----------------------- + + +def test_file_list_input_mode() -> None: + result = run_review(files=["pipeline/policy.py"], repo_root=str(_EXAMPLE_DIR)) + assert result.source_type == "file_list" + assert result.summary.files_changed == 1 + + +def test_sandbox_env_is_whitelisted() -> None: + import os + + from pipeline.policy import ENV_ALLOWLIST, sandbox_env + + os.environ["CR_LEAK_TEST"] = "should-not-pass" + try: + env = sandbox_env() + assert "CR_LEAK_TEST" not in env + assert set(env).issubset(set(ENV_ALLOWLIST)) + finally: + del os.environ["CR_LEAK_TEST"] + + +@pytest.mark.asyncio +async def test_diff_summary_persisted(tmp_path) -> None: + from storage.dao import ReviewStore + + result = run_review(diff_text=(_FIXTURES / "security.diff").read_text()) + store = ReviewStore(f"sqlite+aiosqlite:///{tmp_path / 'cr.db'}") + await store.init() + try: + await store.persist(result) + got = await store.get_by_task_id(result.task_id) + assert got["task"].diff_summary.get("files_changed") == 1 + assert got["task"].diff_summary.get("changed_files") == ["security.py"] + finally: + await store.close() + + +# Provider-format fake secrets are assembled from fragments so the source never holds a contiguous +# provider pattern (which push-protection scanners flag). The runtime value is identical, so the +# redactor is tested exactly as before. +_STRIPE = "sk_live_" + "4eC39HqLyjWDarjtT1zdp7dcABCD1234" +_GITLAB = "glpat-" + "ABCdef1234567890xyzQ" +# Same reason: the DB URL is assembled from fragments so no contiguous connection URL with inline +# credentials appears as a literal (which DB-client secret rules flag); the redactor still masks it. +_PG_PASS = "S3cr3t" + "P4ssw0rd" +_PG_URL = "postgres://admin:" + _PG_PASS + "@db.example.com:5432/app" + +# (text containing a secret, the raw secret that must not survive redaction) — the leak-test corpus. +_LEAK_CORPUS = [ + ('password = "hunter2supersecret"', "hunter2supersecret"), + (f'API_KEY: "{_STRIPE}"', _STRIPE), + ('aws_key = "AKIA1234567890ABCDEF"', "AKIA1234567890ABCDEF"), + ('aws_secret_access_key = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY1"', + "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY1"), + ('gh = "ghp_16CharsExampleTokenABCDEFabcdef012345"', "ghp_16CharsExampleTokenABCDEFabcdef012345"), + (f'gitlab = "{_GITLAB}"', _GITLAB), + ('slack = "xoxb-1234567890-ABCDEFxyz0987"', "xoxb-1234567890-ABCDEFxyz0987"), + ('google = "AIzaSyD-1234567890abcdefGHIJKLmnopqrstuv"', "AIzaSyD-1234567890abcdefGHIJKLmnopqrstuv"), + ('npm = "npm_abcdefABCDEF0123456789abcdefABCDEF01"', "npm_abcdefABCDEF0123456789abcdefABCDEF01"), + ('jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U"', + "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U"), + ('auth = "Bearer abcdefghijklmnopqrstuvwxyz012345"', "abcdefghijklmnopqrstuvwxyz012345"), + ('token = "8f14e45fceea167a5a36dedd4bea2543f1a2b3c4d5e6f708"', "8f14e45fceea167a5a36dedd4bea2543f1a2b3c4d5e6f708"), + ('secret = "aGVsbG9zZWNyZXRrZXkxMjM0NTY3ODkwYWJjZGVm"', "aGVsbG9zZWNyZXRrZXkxMjM0NTY3ODkwYWJjZGVm"), + (f'conn = "{_PG_URL}"', _PG_PASS), + ('DB_PASSWORD=pl4inTextP@ss99', "pl4inTextP@ss99"), + ('X-Api-Key: 3f9a2b1c8d7e6f5a4b3c2d1e0f9a8b7c', "3f9a2b1c8d7e6f5a4b3c2d1e0f9a8b7c"), +] +_BENIGN = [ + "def add(a, b): return a + b", + "import os", + "version = 1.2.3", + "result = compute(x, y)", + "for i in range(100):", + "use ast.literal_eval instead of eval", +] + + +def test_redaction_meets_95pct_and_no_plaintext() -> None: + masked = sum(1 for text, secret in _LEAK_CORPUS if secret not in redact(text)) + rate = masked / len(_LEAK_CORPUS) + assert rate >= 0.95, f"redaction rate {rate:.0%} < 95%" + for text, secret in _LEAK_CORPUS: + assert secret not in redact(text) + + +def test_redaction_does_not_mangle_benign_code() -> None: + for line in _BENIGN: + assert "***REDACTED***" not in redact(line), f"false positive on: {line}" + + +# --- review-fix coverage (Standards/Spec findings) ----------------------------------------------- + + +def test_scanner_unavailable_is_flagged(monkeypatch) -> None: + # A missing scanner must surface as needs-human-review, never a silent "clean" (Spec #8). + from pipeline import scanners + real_which = scanners.shutil.which + monkeypatch.setattr(scanners.shutil, "which", lambda t: None if t == "bandit" else real_which(t)) + result = run_review(diff_text=(_FIXTURES / "security.diff").read_text(), runtime="inprocess") + flagged = [f for f in result.report.human_review if f.category == "scanner_unavailable"] + assert any("bandit" in f.title for f in flagged) + + +def test_tool_calls_is_a_real_count() -> None: + result = run_review(diff_text=(_FIXTURES / "security.diff").read_text(), runtime="inprocess") + from pipeline import scanners + assert result.monitoring["tool_calls"] == scanners.tool_calls_available() + assert result.monitoring["tool_calls"] != len(scanners.ADAPTERS) # not the old constant + + +def test_dedup_file_level_findings_not_overcollapsed() -> None: + a = Finding(severity="low", + category="db_lifecycle", + file="x.py", + line=None, + title="a", + evidence="e", + recommendation="r", + confidence=0.8, + source="static", + rule_id="r1") + b = a.model_copy(update={"title": "b", "rule_id": "r2"}) + out = dedup_and_denoise([a, b]) + assert len([f for f in out if f.status != "duplicate"]) == 2 # distinct file-level issues kept + + +def test_container_result_builder_no_docker() -> None: + import json as _json + from types import SimpleNamespace + + from pipeline.sandbox import build_container_result + + payload = { + "findings": [{ + "severity": "high", + "category": "security", + "file": "a.py", + "line": 3, + "title": "t", + "evidence": "e", + "recommendation": "r", + "confidence": 0.9, + "source": "static" + }] + } + collected = [SimpleNamespace(content=_json.dumps(payload).encode())] + findings, run = build_container_result(collected, + stdout="x" * 10, + stderr="", + exit_code=1, + timed_out=False, + duration_sec=0.5) + assert len(findings) == 1 and findings[0].category == "security" + assert run.script == "run_checks.py" and run.exit_code == 1 and run.stdout_bytes == 10 + + +def test_scanner_paths_parity() -> None: + # The in-process and sandbox paths must produce identical findings (Spec #6). + diff = (_FIXTURES / "security.diff").read_text() + inp = sorted((f.line, f.category) for f in run_review(diff_text=diff, runtime="inprocess").report.findings) + loc = sorted((f.line, f.category) for f in run_review(diff_text=diff, runtime="local").report.findings) + assert inp == loc + + +@pytest.mark.asyncio +async def test_status_reflects_blocked(tmp_path) -> None: + from pipeline.policy import ReviewPolicy + from storage.dao import ReviewStore + + result = run_review(diff_text=(_FIXTURES / "security.diff").read_text(), + runtime="local", + policy=ReviewPolicy(max_budget_sec=1e-6), + sandbox_timeout=60) + store = ReviewStore(f"sqlite+aiosqlite:///{tmp_path / 'cr.db'}") + await store.init() + try: + await store.persist(result) + got = await store.get_by_task_id(result.task_id) + assert got["task"].status == "blocked" # not hardcoded "completed" + finally: + await store.close() + + +def test_run_review_rejects_container_runtime() -> None: + # Sync run_review must reject container (async) loudly, not silently fall back to in-process. + with pytest.raises(ValueError, match="container"): + run_review(diff_text=(_FIXTURES / "security.diff").read_text(), runtime="container") + + +def test_resolve_input_covers_all_modes(tmp_path) -> None: + # The shared resolver (used by run_review AND run_review_container) handles every input mode, + # so --files / --repo-path reach the container sandbox instead of downgrading to in-process. + from pipeline.engine import _resolve_input + + (tmp_path / "m.py").write_text("import os\n") + _, _, st_diff, _ = _resolve_input((_FIXTURES / "security.diff").read_text(), None, None, ".") + _, _, st_files, ref = _resolve_input(None, ["m.py"], None, str(tmp_path)) + assert st_diff == "diff_file" + assert st_files == "file_list" and "m.py" in ref + + +def test_holdout_detection_and_fp_thresholds() -> None: + # Independent held-out evidence for criterion #2: danger/safe cases using patterns the detectors + # were NOT tuned on. Runs in-process for speed; the parity test proves sandbox agrees. + import selftest + + detection, fp_rate, rows = selftest.score_holdout(runtime="inprocess") + assert detection >= 0.80, f"held-out detection {detection:.0%} < 80%" + assert fp_rate <= 0.15, f"held-out false-positive {fp_rate:.0%} > 15%" + by_name = {r[0]: r for r in rows} + assert by_name["h_pickle.diff"][3] is True # a danger case is detected + assert by_name["h_yaml_safe.diff"][3] is False # a safe variant is not flagged