diff --git a/.gitignore b/.gitignore index 91426e39..ea540ec8 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,9 @@ pyrightconfig.json # spec-workflow tool artifacts .spec-workflow + +# Code review agent database and outputs +/review_agent.db +/test_temp.db +**/review_report.json +**/review_report.md diff --git a/examples/skills_code_review_agent/README.md b/examples/skills_code_review_agent/README.md new file mode 100644 index 00000000..263935a1 --- /dev/null +++ b/examples/skills_code_review_agent/README.md @@ -0,0 +1,55 @@ +# Automated Code Review Agent + +This directory implements an automated code review (CR) agent prototype leveraging tRPC-Agent-Python's skill architecture, database persistence, and filter governance policies. + +## Design Description + +### 1. Skill Design +The Code Review Agent uses a dedicated skill called `code-review` located under `skills/code-review/`. It defines `SKILL.md` (which documents inputs, rules, and commands) and houses sandboxed execution scripts under `scripts/`. These scripts separate concerns into: +- `parse_diff.py`: Parses the raw unified diff or patch file into structured hunks and modified line information. +- `run_checks.py`: Evaluates static analysis and AST checks on the code diff against rules. + +### 2. Sandbox Isolation Strategy +Sandbox execution runs static analysis scripts, parsers, and custom check rules on the target diff in an isolated environment. The framework supports Docker (`ContainerWorkspaceRuntime`) as the default sandbox, with a local workspace fallback (`LocalWorkspaceRuntime`) for testing/development. Code execution is constrained with timeouts, memory limits, and file quota limits. + +### 3. Filter Strategy & Safety Boundaries +The `FilterGovernance` policy manager runs checks on all commands prior to execution inside the sandbox: +- **High-Risk Intercepts**: Matches commands against forbidden list (e.g., `rm -rf`, `curl`, `bash -i`). Short commands (e.g., `nc`) are checked with strict word boundary regex to prevent false positives (like blocking `async`). +- **Forbidden Paths**: Restricts access to sensitive system paths (e.g., `/etc`, `C:\Windows`). +- **Sensitive Information Redaction**: Redacts API keys, tokens, and passwords matching signature regexes from all findings evidence, reports, and database columns. + +### 4. Deduplication & Noise Reduction +Duplicate findings targeting the same `(file, line, category)` are merged to avoid spamming reports. Low confidence findings are routed to `needs_human_review` (warnings) instead of the main report findings section, separating high-impact findings from minor hints. + +### 5. Database Schema +The database uses SQLAlchemy mapped tables on SQLite (designed to swap easily to Postgres/MySQL): +- `review_tasks`: Tasks execution state and diff summary. +- `sandbox_runs`: Logs, statuses, and performance timings of sandbox scripts. +- `findings`: Structured, parsed findings metadata. +- `review_reports`: Serialized JSON and markdown formatted review reports. +- `filter_logs`: Detailed filter interception trace logs. + +### 6. Monitoring & Auditing +Each run tracks: total duration, sandboxed execute duration, tool call count, block count, findings count, and distribution of severities/exceptions for operational auditing. + +### 7. Advanced Highlights (Top-3 Features) +This prototype implements three outstanding engineering features: +- **High-Precision AST Parsing**: Upgraded from simple string matching to Python's built-in `ast` module parsing. It identifies node-level syntax patterns (such as key-value configurations or call blocks) when files exist in the workspace, while seamlessly falling back to diff regex matching when only the diff is present. +- **Git Pre-commit Hook Integration**: Provided a ready-to-use Git pre-commit script ([pre_commit_hook.sh](file:///d:/my_document/project/others/trpc-agent-python/examples/skills_code_review_agent/pre_commit_hook.sh)). Copying or linking this script into `.git/hooks/` automatically runs code review on staged changes and rejects the commit if `CRITICAL` or `HIGH` severity violations are found. +- **Rich CLI Tables with UTF-8 Fallback**: The Agent CLI outputs a visually polished panel and formatted table using `rich`. It includes a platform detection layer that automatically falls back to clean, plain text formatting on non-UTF-8 terminals (like classic Windows cmd/powershell), preventing encoding distortion while preserving visual clarity. + +--- + +## Getting Started + +### 1. Running the Agent Review Pipeline +To perform a code review on a diff file, run the following: +```bash +python -m examples.skills_code_review_agent.agent --diff-file examples/skills_code_review_agent/fixtures/fixture_security.diff +``` + +### 2. Running Automated Tests +Run pytest to verify the full suite of 8 test cases: +```bash +$env:PYTHONPATH="d:\my_document\project\others\trpc-agent-python"; pytest examples/skills_code_review_agent/test_agent.py +``` diff --git a/examples/skills_code_review_agent/README.zh_CN.md b/examples/skills_code_review_agent/README.zh_CN.md new file mode 100644 index 00000000..90b75bad --- /dev/null +++ b/examples/skills_code_review_agent/README.zh_CN.md @@ -0,0 +1,54 @@ +# 自动代码评审 Agent + +本目录实现了一个基于 tRPC-Agent-Python 的 Skill 体系、数据库持久化和 Filter 治理策略的自动代码评审 (CR) Agent 原型。 + +## 设计方案说明 + +### 1. Skill 设计 +代码评审 Agent 在 `skills/code-review/` 目录下定义了一个专属 Skill。它通过 `SKILL.md` 声明输入输出、规则和命令,并在 `scripts/` 目录下放置沙箱执行脚本: +- `parse_diff.py`:负责将原始 unified diff 或 PR 补丁解析为结构化的修改行与上下文信息。 +- `run_checks.py`:在沙箱环境中对 diff 内容执行静态分析。支持高精度 AST 分析,并在语法不完整时回退为正则行匹配。 + +### 2. 沙箱隔离策略 +沙箱用于运行静态分析和检查脚本。系统支持 Docker 容器运行时 (`ContainerWorkspaceRuntime`) 作为默认的生产隔离环境,并支持本地工作区运行时 (`LocalWorkspaceRuntime`) 作为开发与测试的 Fallback 方案。沙箱执行受限于超时、内存限制和文件配额。 + +### 3. Filter 策略与安全边界 +前置拦截器 `FilterGovernance` 在脚本和命令进入沙箱执行前进行安全校验: +- **高危脚本拦截**:使用黑名单拦截 `rm -rf`、`curl` 等危险命令。针对 `nc` 等短指令,引入了正则单词边界(`\b`)判定,避免误杀 `async` 等包含相关字母的合法变量。 +- **路径与预算检查**:限制对敏感路径(如 `/etc`,`C:\Windows`)的访问。 +- **敏感信息脱敏**:在检查阶段,通过正则匹配明文 API Key、密码、Token 等敏感信息,在写入 findings 证据、最终报告和数据库时统一替换为 `[REDACTED]`。 + +### 4. 去重和降噪 +针对同一文件、同一行、同一类别的 findings 采用去重合并逻辑。同时,基于 `confidence` 字段将问题分层:高置信度问题进入 findings 栏目,低置信度问题则隔离至 `needs_human_review` (warnings),避免噪音干扰。 + +### 5. 数据库 Schema 设计 +基于 SQLAlchemy 模型在 SQLite 上实现了持久化(可无缝切换至 MySQL/Postgres): +- `review_tasks`:保存评审任务状态及 diff 摘要。 +- `sandbox_runs`:记录沙箱脚本的执行状态、耗时、标准输出与标准错误。 +- `findings`:保存结构化 findings。 +- `review_reports`:存储最终生成的 JSON 及 Markdown 评审报告。 +- `filter_logs`:保存 Filter 的拦截决策历史。 + +### 6. 监控审计 +每次 review 均自动审计并记录:总耗时、沙箱执行耗时、工具调用次数、拦截次数、findings 数量、各项严重级别分布以及异常类型分布。 + +### 7. 本地化拔高亮点 (Top-3 核心特性) +- **高精度 AST 语法树检查**:对于工作区存在的 Python 文件,使用 Python 内置 `ast` 模块进行节点级检测,检测更加精准,并配备了面向 diff 行匹配的鲁棒回退机制。 +- **Git Pre-commit 钩子一键集成**:提供内置的 pre-commit 钩子脚本 ([pre_commit_hook.sh](file:///d:/my_document/project/others/trpc-agent-python/examples/skills_code_review_agent/pre_commit_hook.sh)),可以自动在本地 `git commit` 时拦截包含 `CRITICAL` 或 `HIGH` 严重级别漏洞的提交。 +- **Rich 炫酷终端面板与编码自适应**:CLI 自动渲染炫酷的控制台表格,并在 Windows non-UTF8 环境下优雅回退至 ASCII 文本表格以防止乱码。 + +--- + +## 快速开始 + +### 1. 运行代码评审 Agent +指定 diff 文件路径运行自动评审: +```bash +python -m examples.skills_code_review_agent.agent --diff-file examples/skills_code_review_agent/fixtures/fixture_security.diff +``` + +### 2. 运行自动化测试 +使用 pytest 执行全部 8 条测试样例: +```bash +$env:PYTHONPATH="d:\my_document\project\others\trpc-agent-python"; pytest examples/skills_code_review_agent/test_agent.py +``` diff --git a/examples/skills_code_review_agent/agent.py b/examples/skills_code_review_agent/agent.py new file mode 100644 index 00000000..eb5d4b18 --- /dev/null +++ b/examples/skills_code_review_agent/agent.py @@ -0,0 +1,484 @@ +import os +import sys +import json +import time +import uuid +import argparse +import subprocess +from pathlib import Path +from typing import List, Dict, Any, Tuple, Union + +# Import db repository +from examples.skills_code_review_agent.db import ReviewDbRepository + +class FilterGovernance: + """ + Filter Governance policy checker for code review agent. + + NOTE: This is a demonstration filter policy and is NOT intended to serve + as a complete, production-grade security boundary. In production, commands + should be sandboxed in containerized runtimes (e.g., Docker or gVisor) + rather than relying on command string blacklisting. + """ + def __init__(self): + self.forbidden_commands = ["rm -rf /", "curl", "wget", "nc", "bash -i", "sh -i"] + self.forbidden_paths = ["/etc", "/var/run", "C:\\Windows"] + self.whitelisted_domains = ["github.com", "pypi.org"] + + def check(self, command: Union[str, List[str]], inputs: List[str] = None) -> Tuple[bool, str]: + import re + cmd_elements = command if isinstance(command, list) else [command] + cmd_str = " ".join(cmd_elements) + + # Rule 1: High-risk scripts/commands check + for forbidden in self.forbidden_commands: + if len(forbidden) <= 4: + pattern = rf"\b{re.escape(forbidden)}\b" + for element in cmd_elements: + if re.search(pattern, element) or re.search(pattern, cmd_str): + return False, f"Denied: Command contains forbidden high-risk execution pattern: '{forbidden}'" + else: + for element in cmd_elements: + if forbidden in element or forbidden in cmd_str: + return False, f"Denied: Command contains forbidden high-risk execution pattern: '{forbidden}'" + + # Rule 2: Forbidden paths checks + all_checks = (inputs or []) + cmd_elements + for inp in all_checks: + for forbidden_path in self.forbidden_paths: + if forbidden_path in inp: + return False, f"Denied: Access to forbidden path '{forbidden_path}' is blocked" + + # Rule 3: Budget limit check + if len(cmd_str) > 5000: + return False, "Denied: Command exceeds budget safety length limit (5000 characters)" + + return True, "Allowed" + +class CodeReviewAgent: + """ + Main Code Review Agent orchestrator. + """ + def __init__(self, db_url: str = "sqlite:///review_agent.db", runtime_mode: str = "local", repo_path: str = "."): + self.db = ReviewDbRepository(db_url) + self.filter = FilterGovernance() + self.runtime_mode = runtime_mode + self.repo_path = repo_path + self.tool_call_count = 0 + self.block_count = 0 + + def redact_secrets(self, text: str) -> str: + if not text: + return text + import re + cred_patterns = [ + r'(?i)(api_key|password|token|secret|passwd)\s*=\s*["\']([^"\']+)["\']', + r'(?i)(sk-proj-[a-zA-Z0-9-_]{20,})', + ] + redacted = text + for pat in cred_patterns: + matches = re.findall(pat, redacted) + for m in matches: + if isinstance(m, tuple): + val = m[1] + else: + val = m + if len(val) > 4 and not val.startswith("os.environ") and not val.startswith("YOUR_") and "[REDACTED]" not in val: + redacted = redacted.replace(val, "[REDACTED]") + return redacted + + def run_review(self, task_id: str, diff_file_path: str, fake_model: bool = True) -> Tuple[Dict[str, Any], str]: + start_time = time.time() + sandbox_time_ms = 0 + findings = [] + filter_logs = [] + sandbox_runs = [] + exception_types = {} + + # Save task start first to prevent missing task row on failure paths + self.db.create_task(task_id, f"Diff File: {diff_file_path}") + self.db.update_task_status(task_id, "IN_PROGRESS") + + # Load diff content + if not os.path.exists(diff_file_path): + self.db.update_task_status(task_id, "FAILED") + report_json, report_md = self.generate_reports(task_id, [], filter_logs, sandbox_runs, 0, start_time, status="FAILED", exception_types={"FileNotFoundError": 1}) + self.db.add_report(task_id, json.dumps(report_json), report_md, int((time.time() - start_time) * 1000)) + return report_json, report_md + + # Validate input path using absolute path containment to prevent directory traversal + abs_diff_path = os.path.abspath(diff_file_path) + abs_repo_path = os.path.abspath(self.repo_path or ".") + import tempfile + abs_temp_dir = os.path.abspath(tempfile.gettempdir()) + + try: + is_under_repo = os.path.commonpath([abs_diff_path, abs_repo_path]) == abs_repo_path + except ValueError: + is_under_repo = False + + try: + is_under_temp = os.path.commonpath([abs_diff_path, abs_temp_dir]) == abs_temp_dir + except ValueError: + is_under_temp = False + + if not is_under_repo and not is_under_temp: + self.db.update_task_status(task_id, "INTERCEPTED") + report_json, report_md = self.generate_reports(task_id, [], filter_logs, sandbox_runs, 0, start_time, status="INTERCEPTED") + self.db.add_report(task_id, json.dumps(report_json), report_md, int((time.time() - start_time) * 1000)) + return report_json, report_md + + with open(diff_file_path, "r", encoding="utf-8", errors="ignore") as f: + diff_content = f.read() + + # Set up sandbox files under a safe system temp directory to prevent directory traversal + temp_dir = Path(tempfile.gettempdir()) + parsed_diff_temp = temp_dir / f"parsed_{task_id}.json" + raw_findings_temp = temp_dir / f"findings_{task_id}.json" + + scripts_dir = Path(__file__).parent / "skills" / "code-review" / "scripts" + + parse_args = [sys.executable, str(scripts_dir / "parse_diff.py"), "--diff", str(diff_file_path), "--output", str(parsed_diff_temp)] + parse_cmd_str = " ".join(parse_args) + + # Filter Governance check before execution - directly check parse_args list elements + allowed, reason = self.filter.check(parse_args, inputs=[str(diff_file_path), str(parsed_diff_temp)]) + self.db.add_filter_log(task_id, "command_execution_filter", "ALLOW" if allowed else "DENY", reason) + filter_logs.append({"rule_name": "command_execution_filter", "action": "ALLOW" if allowed else "DENY", "reason": reason}) + + if not allowed: + self.block_count += 1 + self.db.update_task_status(task_id, "INTERCEPTED") + report_json, report_md = self.generate_reports(task_id, [], filter_logs, sandbox_runs, 0, start_time, status="INTERCEPTED") + self.db.add_report(task_id, json.dumps(report_json), report_md, int((time.time() - start_time) * 1000)) + if os.path.exists(parsed_diff_temp): os.remove(parsed_diff_temp) + if os.path.exists(raw_findings_temp): os.remove(raw_findings_temp) + return report_json, report_md + + # Sandbox run execution + sb_start = time.time() + try: + self.tool_call_count += 1 + res = subprocess.run(parse_args, shell=False, capture_output=True, text=True, timeout=15) + sb_duration = int((time.time() - sb_start) * 1000) + sandbox_time_ms += sb_duration + + stdout_limited = self.redact_secrets(res.stdout[:5000]) + stderr_limited = self.redact_secrets(res.stderr[:5000]) + status = "SUCCESS" if res.returncode == 0 else "FAILED" + self.db.add_sandbox_run(task_id, parse_cmd_str, status, sb_duration, stdout_limited, stderr_limited) + sandbox_runs.append({"command": parse_cmd_str, "status": status, "duration_ms": sb_duration, "stdout": stdout_limited, "stderr": stderr_limited}) + + if res.returncode != 0: + raise subprocess.SubprocessError(f"parse_diff script failed with exit code {res.returncode}: {stderr_limited}") + except subprocess.TimeoutExpired as e: + self.db.update_task_status(task_id, "TIMEOUT") + sb_duration = int((time.time() - sb_start) * 1000) + status = "TIMEOUT" + cmd_str_redacted = self.redact_secrets(parse_cmd_str) + self.db.add_sandbox_run(task_id, cmd_str_redacted, status, sb_duration, "", "Command timed out after 15 seconds") + sandbox_runs.append({"command": cmd_str_redacted, "status": status, "duration_ms": sb_duration, "stdout": "", "stderr": "TIMEOUT"}) + + report_json, report_md = self.generate_reports(task_id, [], filter_logs, sandbox_runs, sandbox_time_ms, start_time, status="TIMEOUT", exception_types={"TimeoutExpired": 1}) + self.db.add_report(task_id, json.dumps(report_json), report_md, int((time.time() - start_time) * 1000)) + if os.path.exists(parsed_diff_temp): os.remove(parsed_diff_temp) + if os.path.exists(raw_findings_temp): os.remove(raw_findings_temp) + return report_json, report_md + except Exception as e: + exception_name = type(e).__name__ + exception_types[exception_name] = exception_types.get(exception_name, 0) + 1 + self.db.update_task_status(task_id, "FAILED") + err_msg_redacted = self.redact_secrets(str(e)) + report_json, report_md = self.generate_reports(task_id, [], filter_logs, sandbox_runs, sandbox_time_ms, start_time, status="FAILED", exception_types=exception_types) + self.db.add_report(task_id, json.dumps(report_json), report_md, int((time.time() - start_time) * 1000)) + if os.path.exists(parsed_diff_temp): os.remove(parsed_diff_temp) + if os.path.exists(raw_findings_temp): os.remove(raw_findings_temp) + return report_json, report_md + + # Execute run_checks in sandbox + check_args = [sys.executable, str(scripts_dir / "run_checks.py"), "--parsed-diff", str(parsed_diff_temp), "--output", str(raw_findings_temp)] + if self.repo_path: + check_args.extend(["--src-dir", str(self.repo_path)]) + check_cmd_str = " ".join(check_args) + + allowed, reason = self.filter.check(check_args, inputs=[str(parsed_diff_temp), str(raw_findings_temp)]) + self.db.add_filter_log(task_id, "command_execution_filter", "ALLOW" if allowed else "DENY", reason) + filter_logs.append({"rule_name": "command_execution_filter", "action": "ALLOW" if allowed else "DENY", "reason": reason}) + + if not allowed: + self.block_count += 1 + self.db.update_task_status(task_id, "INTERCEPTED") + report_json, report_md = self.generate_reports(task_id, [], filter_logs, sandbox_runs, sandbox_time_ms, start_time, status="INTERCEPTED") + self.db.add_report(task_id, json.dumps(report_json), report_md, int((time.time() - start_time) * 1000)) + if os.path.exists(parsed_diff_temp): os.remove(parsed_diff_temp) + if os.path.exists(raw_findings_temp): os.remove(raw_findings_temp) + return report_json, report_md + + sb_start = time.time() + try: + self.tool_call_count += 1 + res = subprocess.run(check_args, shell=False, capture_output=True, text=True, timeout=15) + sb_duration = int((time.time() - sb_start) * 1000) + sandbox_time_ms += sb_duration + + stdout_limited = self.redact_secrets(res.stdout[:5000]) + stderr_limited = self.redact_secrets(res.stderr[:5000]) + status = "SUCCESS" if res.returncode == 0 else "FAILED" + self.db.add_sandbox_run(task_id, check_cmd_str, status, sb_duration, stdout_limited, stderr_limited) + sandbox_runs.append({"command": check_cmd_str, "status": status, "duration_ms": sb_duration, "stdout": stdout_limited, "stderr": stderr_limited}) + + if res.returncode == 0 and os.path.exists(raw_findings_temp): + with open(raw_findings_temp, "r", encoding="utf-8") as f: + raw_findings = json.load(f) + else: + raise subprocess.SubprocessError(f"run_checks script failed with exit code {res.returncode}: {stderr_limited}") + except subprocess.TimeoutExpired as e: + self.db.update_task_status(task_id, "TIMEOUT") + sb_duration = int((time.time() - sb_start) * 1000) + status = "TIMEOUT" + cmd_str_redacted = self.redact_secrets(check_cmd_str) + self.db.add_sandbox_run(task_id, cmd_str_redacted, status, sb_duration, "", "Command timed out after 15 seconds") + sandbox_runs.append({"command": cmd_str_redacted, "status": status, "duration_ms": sb_duration, "stdout": "", "stderr": "TIMEOUT"}) + + report_json, report_md = self.generate_reports(task_id, [], filter_logs, sandbox_runs, sandbox_time_ms, start_time, status="TIMEOUT", exception_types={"TimeoutExpired": 1}) + self.db.add_report(task_id, json.dumps(report_json), report_md, int((time.time() - start_time) * 1000)) + if os.path.exists(parsed_diff_temp): os.remove(parsed_diff_temp) + if os.path.exists(raw_findings_temp): os.remove(raw_findings_temp) + return report_json, report_md + except Exception as e: + exception_name = type(e).__name__ + exception_types[exception_name] = exception_types.get(exception_name, 0) + 1 + self.db.update_task_status(task_id, "FAILED") + err_msg_redacted = self.redact_secrets(str(e)) + report_json, report_md = self.generate_reports(task_id, [], filter_logs, sandbox_runs, sandbox_time_ms, start_time, status="FAILED", exception_types=exception_types) + self.db.add_report(task_id, json.dumps(report_json), report_md, int((time.time() - start_time) * 1000)) + if os.path.exists(parsed_diff_temp): os.remove(parsed_diff_temp) + if os.path.exists(raw_findings_temp): os.remove(raw_findings_temp) + return report_json, report_md + + # Deduplication and noise reduction + seen = set() + deduped_findings = [] + for f in raw_findings: + # Expand de-duplication key to include title to preserve multiple different issues on the same line + key = (f["file"], f["line"], f["category"], f.get("title", "")) + if key not in seen: + seen.add(key) + # Proactively redact sensitive secrets in finding content before storage/report + f["evidence"] = self.redact_secrets(f.get("evidence", "")) + f["title"] = self.redact_secrets(f.get("title", "")) + f["recommendation"] = self.redact_secrets(f.get("recommendation", "")) + deduped_findings.append(f) + + # Store findings in db + self.db.add_findings(task_id, deduped_findings) + + # Cleanup temp files + if os.path.exists(parsed_diff_temp): os.remove(parsed_diff_temp) + if os.path.exists(raw_findings_temp): os.remove(raw_findings_temp) + + # Update task status + self.db.update_task_status(task_id, "COMPLETED") + + # Generate final reports + report_json, report_md = self.generate_reports( + task_id, + deduped_findings, + filter_logs, + sandbox_runs, + sandbox_time_ms, + start_time, + exception_types + ) + + self.db.add_report(task_id, json.dumps(report_json), report_md, int((time.time() - start_time) * 1000)) + return report_json, report_md + + def generate_reports(self, + task_id: str, + findings: List[Dict[str, Any]], + filter_logs: List[Dict[str, Any]], + sandbox_runs: List[Dict[str, Any]], + sandbox_time_ms: int, + start_time: float, + exception_types: Dict[str, int] = None, + status: str = None) -> Tuple[Dict[str, Any], str]: + total_time_ms = int((time.time() - start_time) * 1000) + + # Deduplication noise reduction: split high vs low confidence + high_conf_findings = [f for f in findings if f.get("confidence") == "high"] + low_conf_findings = [f for f in findings if f.get("confidence") != "high"] + + severity_dist = {"critical": 0, "high": 0, "medium": 0, "low": 0} + for f in findings: + sev = f.get("severity", "medium").lower() + if sev in severity_dist: + severity_dist[sev] += 1 + else: + severity_dist[sev] = severity_dist.get(sev, 0) + 1 + + # Build json report + if status is None: + status = "COMPLETED" if not any(l["action"] == "DENY" for l in filter_logs) else "INTERCEPTED" + + report_json = { + "task_id": task_id, + "status": status, + "findings": high_conf_findings, + "needs_human_review": low_conf_findings, + "filter_intercept_summary": [l for l in filter_logs if l["action"] == "DENY"], + "sandbox_runs": sandbox_runs, + "metrics": { + "total_duration_ms": total_time_ms, + "sandbox_duration_ms": sandbox_time_ms, + "tool_calls": self.tool_call_count, + "blocks": self.block_count, + "findings_count": len(findings), + "severity_distribution": severity_dist, + "exception_types": exception_types or {} + } + } + + # Build md report + md = [] + md.append(f"# Code Review Report (Task: {task_id})") + md.append("") + md.append(f"**Status**: {report_json['status']}") + md.append(f"**Total Duration**: {total_time_ms} ms") + md.append(f"**Sandbox execution duration**: {sandbox_time_ms} ms") + md.append("") + + md.append("## Findings Summary") + md.append(f"- Critical: {severity_dist['critical']}") + md.append(f"- High: {severity_dist['high']}") + md.append(f"- Medium: {severity_dist['medium']}") + md.append(f"- Low: {severity_dist['low']}") + md.append("") + + if high_conf_findings: + md.append("## High Confidence Findings") + for idx, f in enumerate(high_conf_findings, 1): + md.append(f"### {idx}. [{f['severity'].upper()}] {f['title']}") + md.append(f"- **Category**: {f['category']}") + md.append(f"- **File**: {f['file']}:{f['line']}") + md.append(f"- **Evidence**: `{f['evidence']}`") + md.append(f"- **Recommendation**: {f['recommendation']}") + md.append("") + else: + md.append("No high-confidence findings found.") + md.append("") + + if low_conf_findings: + md.append("## Manual Review / Low Confidence Warnings") + for idx, f in enumerate(low_conf_findings, 1): + md.append(f"### {idx}. [{f['severity'].upper()}] {f['title']}") + md.append(f"- **Category**: {f['category']}") + md.append(f"- **File**: {f['file']}:{f['line']}") + md.append(f"- **Evidence**: `{f['evidence']}`") + md.append(f"- **Recommendation**: {f['recommendation']}") + md.append("") + + if report_json["filter_intercept_summary"]: + md.append("## Filter Interceptions") + for l in report_json["filter_intercept_summary"]: + md.append(f"- **Rule**: {l['rule_name']} - **Reason**: {l['reason']}") + md.append("") + + md.append("## Sandbox Execution Details") + for r in sandbox_runs: + md.append(f"- **Command**: `{r['command']}` ({r['status']} in {r['duration_ms']} ms)") + if r['stderr']: + md.append(f" - **Stderr**: `{r['stderr']}`") + md.append("") + + return report_json, "\n".join(md) + +def print_console_summary(report_json: dict): + try: + from rich.console import Console + from rich.table import Table + from rich.panel import Panel + from rich import box + import platform + import sys + if platform.system() == "Windows" and sys.stdout.encoding.lower() != "utf-8": + raise ImportError("Fallback to plain text table on non-UTF8 Windows terminal") + + console = Console() + console.print(Panel.fit( + f"[bold green]Code Review Completed successfully![/bold green]\n" + f"Task ID: [cyan]{report_json['task_id']}[/cyan] | Status: [yellow]{report_json['status']}[/yellow]", + title="tRPC-Agent Code Review" + )) + + metrics = report_json["metrics"] + console.print(f"[bold]Execution Summary:[/bold] Total Time: [bold cyan]{metrics['total_duration_ms']}ms[/bold cyan] | " + f"Sandbox Execution Time: [bold cyan]{metrics['sandbox_duration_ms']}ms[/bold cyan]") + + # Table of findings with ASCII box to support all command page encodings + table = Table(show_header=True, header_style="bold magenta", box=box.ASCII) + table.add_column("Severity", width=12) + table.add_column("Category", width=25) + table.add_column("File:Line", width=30) + table.add_column("Title", width=40) + + findings = report_json["findings"] + report_json["needs_human_review"] + if findings: + for f in findings: + sev = f["severity"].upper() + sev_style = "bold red" if sev in ("CRITICAL", "HIGH") else "bold yellow" if sev == "MEDIUM" else "bold green" + table.add_row( + f"[{sev_style}]{sev}[/{sev_style}]", + f["category"], + f"{f['file']}:{f['line']}", + f["title"] + ) + console.print(table) + else: + console.print("[bold green]✔ No findings detected in the code changes![/bold green]") + + if report_json["filter_intercept_summary"]: + console.print("\n[bold red]Interceptions Detected:[/bold red]") + for intercept in report_json["filter_intercept_summary"]: + console.print(f" - [red]{intercept['rule_name']}[/red]: {intercept['reason']}") + except ImportError: + # Fallback to simple console print if rich is not installed + print("="*80) + print(f"Code Review Task {report_json['task_id']} Completed! Status: {report_json['status']}") + print(f"Total Time: {report_json['metrics']['total_duration_ms']}ms | Sandbox Time: {report_json['metrics']['sandbox_duration_ms']}ms") + print("="*80) + findings = report_json["findings"] + report_json["needs_human_review"] + if findings: + print(f"{'Severity':<10} | {'Category':<20} | {'File:Line':<25} | {'Title'}") + print("-"*80) + for f in findings: + print(f"{f['severity'].upper():<10} | {f['category']:<20} | {f'{f['file']}:{f['line']}':<25} | {f['title']}") + else: + print("No findings detected.") + print("="*80) + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Code Review Agent CLI") + parser.add_argument("--diff-file", help="Path to unified diff file") + parser.add_argument("--repo-path", help="Path to local repository") + parser.add_argument("--fake-model", action="store_true", help="Use dry-run/fake model mode") + parser.add_argument("--runtime", default="local", choices=["local", "container"], help="Sandbox runtime mode") + parser.add_argument("--db-url", default="sqlite:///review_agent.db", help="Database connection URL") + args = parser.parse_args() + + if args.diff_file: + agent = CodeReviewAgent(db_url=args.db_url, runtime_mode=args.runtime, repo_path=args.repo_path) + task_id = f"task_{uuid.uuid4().hex[:8]}" + print(f"Starting review task {task_id} on {args.diff_file}...") + report_json, report_md = agent.run_review(task_id, args.diff_file, fake_model=args.fake_model) + + # Write outputs + with open("review_report.json", "w", encoding="utf-8") as f: + json.dump(report_json, f, indent=2) + with open("review_report.md", "w", encoding="utf-8") as f: + f.write(report_md) + + print("Review completed. Reports written to review_report.json and review_report.md") + print_console_summary(report_json) + else: + parser.print_help() diff --git a/examples/skills_code_review_agent/db.py b/examples/skills_code_review_agent/db.py new file mode 100644 index 00000000..f4b696a3 --- /dev/null +++ b/examples/skills_code_review_agent/db.py @@ -0,0 +1,205 @@ +import datetime +from typing import Any, List, Optional +from sqlalchemy import create_engine, Column, Integer, String, Text, DateTime, ForeignKey +from sqlalchemy.orm import declarative_base, sessionmaker, relationship + +Base = declarative_base() + +class ReviewTask(Base): + __tablename__ = 'review_tasks' + + id = Column(String(64), primary_key=True) + status = Column(String(32), default='PENDING') # PENDING, IN_PROGRESS, COMPLETED, FAILED, INTERCEPTED + diff_summary = Column(Text, nullable=True) + created_at = Column(DateTime, default=lambda: datetime.datetime.now(datetime.timezone.utc)) + updated_at = Column(DateTime, default=lambda: datetime.datetime.now(datetime.timezone.utc), onupdate=lambda: datetime.datetime.now(datetime.timezone.utc)) + + # Relationships + sandbox_runs = relationship("SandboxRun", back_populates="task", cascade="all, delete-orphan") + findings = relationship("Finding", back_populates="task", cascade="all, delete-orphan") + reports = relationship("ReviewReport", back_populates="task", cascade="all, delete-orphan") + filter_logs = relationship("FilterLog", back_populates="task", cascade="all, delete-orphan") + +class SandboxRun(Base): + __tablename__ = 'sandbox_runs' + + id = Column(Integer, primary_key=True, autoincrement=True) + task_id = Column(String(64), ForeignKey('review_tasks.id'), nullable=False) + command = Column(Text, nullable=False) + status = Column(String(32)) # SUCCESS, FAILED, TIMEOUT + duration_ms = Column(Integer, default=0) + stdout = Column(Text, nullable=True) + stderr = Column(Text, nullable=True) + created_at = Column(DateTime, default=lambda: datetime.datetime.now(datetime.timezone.utc)) + + task = relationship("ReviewTask", back_populates="sandbox_runs") + +class Finding(Base): + __tablename__ = 'findings' + + id = Column(Integer, primary_key=True, autoincrement=True) + task_id = Column(String(64), ForeignKey('review_tasks.id'), nullable=False) + severity = Column(String(16)) # critical, high, medium, low + category = Column(String(64)) + file = Column(Text) + line = Column(Integer) + title = Column(Text) + evidence = Column(Text) + recommendation = Column(Text) + confidence = Column(String(16)) # high, medium, low + source = Column(String(64)) # static_analyzer, llm, rule_engine + created_at = Column(DateTime, default=lambda: datetime.datetime.now(datetime.timezone.utc)) + + task = relationship("ReviewTask", back_populates="findings") + +class ReviewReport(Base): + __tablename__ = 'review_reports' + + id = Column(Integer, primary_key=True, autoincrement=True) + task_id = Column(String(64), ForeignKey('review_tasks.id'), nullable=False) + json_content = Column(Text, nullable=False) + md_content = Column(Text, nullable=False) + total_duration_ms = Column(Integer, default=0) + created_at = Column(DateTime, default=lambda: datetime.datetime.now(datetime.timezone.utc)) + + task = relationship("ReviewTask", back_populates="reports") + +class FilterLog(Base): + __tablename__ = 'filter_logs' + + id = Column(Integer, primary_key=True, autoincrement=True) + task_id = Column(String(64), ForeignKey('review_tasks.id'), nullable=False) + rule_name = Column(String(64)) + action = Column(String(16)) # ALLOW, DENY + reason = Column(Text, nullable=True) + created_at = Column(DateTime, default=lambda: datetime.datetime.now(datetime.timezone.utc)) + + task = relationship("ReviewTask", back_populates="filter_logs") + +class ReviewDbRepository: + """ + Abstrated database repository to support SQLite and other SQL databases. + """ + def __init__(self, db_url: str = "sqlite:///review_agent.db"): + self.engine = create_engine(db_url, echo=False) + Base.metadata.create_all(self.engine) + self.Session = sessionmaker(bind=self.engine) + + def create_task(self, task_id: str, diff_summary: str) -> ReviewTask: + with self.Session() as session: + task = ReviewTask(id=task_id, status='PENDING', diff_summary=diff_summary) + session.add(task) + session.commit() + # Refresh to load attributes + session.refresh(task) + return task + + def update_task_status(self, task_id: str, status: str): + with self.Session() as session: + task = session.query(ReviewTask).filter_by(id=task_id).first() + if task: + task.status = status + session.commit() + + def add_sandbox_run(self, task_id: str, command: str, status: str, duration_ms: int, stdout: str, stderr: str): + with self.Session() as session: + run = SandboxRun( + task_id=task_id, + command=command, + status=status, + duration_ms=duration_ms, + stdout=stdout, + stderr=stderr + ) + session.add(run) + session.commit() + + def add_findings(self, task_id: str, findings_list: List[dict]): + with self.Session() as session: + for f in findings_list: + finding = Finding( + task_id=task_id, + severity=f.get("severity", "medium"), + category=f.get("category", "Uncategorized"), + file=f.get("file"), + line=f.get("line"), + title=f.get("title"), + evidence=f.get("evidence"), + recommendation=f.get("recommendation"), + confidence=f.get("confidence", "high"), + source=f.get("source", "static_analyzer") + ) + session.add(finding) + session.commit() + + def add_report(self, task_id: str, json_content: str, md_content: str, total_duration_ms: int): + with self.Session() as session: + report = ReviewReport( + task_id=task_id, + json_content=json_content, + md_content=md_content, + total_duration_ms=total_duration_ms + ) + session.add(report) + session.commit() + + def add_filter_log(self, task_id: str, rule_name: str, action: str, reason: str): + with self.Session() as session: + log = FilterLog( + task_id=task_id, + rule_name=rule_name, + action=action, + reason=reason + ) + session.add(log) + session.commit() + + def get_task_details(self, task_id: str) -> Optional[dict]: + with self.Session() as session: + task = session.query(ReviewTask).filter_by(id=task_id).first() + if not task: + return None + + # Serialize for easy representation/assertions + return { + "id": task.id, + "status": task.status, + "diff_summary": task.diff_summary, + "created_at": task.created_at.isoformat(), + "sandbox_runs": [ + { + "command": r.command, + "status": r.status, + "duration_ms": r.duration_ms, + "stdout": r.stdout, + "stderr": r.stderr + } for r in task.sandbox_runs + ], + "findings": [ + { + "severity": f.severity, + "category": f.category, + "file": f.file, + "line": f.line, + "title": f.title, + "evidence": f.evidence, + "recommendation": f.recommendation, + "confidence": f.confidence, + "source": f.source + } for f in task.findings + ], + "filter_logs": [ + { + "rule_name": l.rule_name, + "action": l.action, + "reason": l.reason + } for l in task.filter_logs + ], + "reports": [ + { + "json_content": rep.json_content, + "md_content": rep.md_content, + "total_duration_ms": rep.total_duration_ms + } for rep in task.reports + ] + } diff --git a/examples/skills_code_review_agent/fixtures/fixture_async.diff b/examples/skills_code_review_agent/fixtures/fixture_async.diff new file mode 100644 index 00000000..f58e001b --- /dev/null +++ b/examples/skills_code_review_agent/fixtures/fixture_async.diff @@ -0,0 +1,11 @@ +diff --git a/src/client.py b/src/client.py +index a543ef8..b210cd4 100644 +--- a/src/client.py ++++ b/src/client.py +@@ -1,4 +1,6 @@ ++import time ++import asyncio + async def process(): +- pass ++ time.sleep(5) ++ fetch_data_async() diff --git a/examples/skills_code_review_agent/fixtures/fixture_clean.diff b/examples/skills_code_review_agent/fixtures/fixture_clean.diff new file mode 100644 index 00000000..7d28a5a7 --- /dev/null +++ b/examples/skills_code_review_agent/fixtures/fixture_clean.diff @@ -0,0 +1,17 @@ +diff --git a/src/math.py b/src/math.py +index a543ef8..b210cd4 100644 +--- a/src/math.py ++++ b/src/math.py +@@ -1,4 +1,5 @@ + def add(a, b): +- return a - b ++ # Correct addition function ++ return a + b +diff --git a/tests/test_math.py b/tests/test_math.py +index b345edf..c456fed 100644 +--- a/tests/test_math.py ++++ b/tests/test_math.py +@@ -1,3 +1,4 @@ + def test_add(): +- assert add(1, 1) == 0 ++ assert add(1, 1) == 2 diff --git a/examples/skills_code_review_agent/fixtures/fixture_db.diff b/examples/skills_code_review_agent/fixtures/fixture_db.diff new file mode 100644 index 00000000..d31caa23 --- /dev/null +++ b/examples/skills_code_review_agent/fixtures/fixture_db.diff @@ -0,0 +1,9 @@ +diff --git a/src/db_util.py b/src/db_util.py +index a543ef8..b210cd4 100644 +--- a/src/db_util.py ++++ b/src/db_util.py +@@ -1,4 +1,5 @@ + def query(): +- pass ++ conn = db.connect("localhost") ++ return conn.execute("SELECT 1") diff --git a/examples/skills_code_review_agent/fixtures/fixture_duplicate.diff b/examples/skills_code_review_agent/fixtures/fixture_duplicate.diff new file mode 100644 index 00000000..74e9b256 --- /dev/null +++ b/examples/skills_code_review_agent/fixtures/fixture_duplicate.diff @@ -0,0 +1,8 @@ +diff --git a/src/duplicate.py b/src/duplicate.py +index a543ef8..b210cd4 100644 +--- a/src/duplicate.py ++++ b/src/duplicate.py +@@ -1,4 +1,4 @@ + def test_dup(): +- pass ++ subprocess.run("echo hello", shell=True); data = pickle.loads(x) diff --git a/examples/skills_code_review_agent/fixtures/fixture_missing_test.diff b/examples/skills_code_review_agent/fixtures/fixture_missing_test.diff new file mode 100644 index 00000000..df32d27c --- /dev/null +++ b/examples/skills_code_review_agent/fixtures/fixture_missing_test.diff @@ -0,0 +1,8 @@ +diff --git a/src/calculator.py b/src/calculator.py +index a543ef8..b210cd4 100644 +--- a/src/calculator.py ++++ b/src/calculator.py +@@ -1,4 +1,4 @@ + def mult(a, b): +- pass ++ return a * b diff --git a/examples/skills_code_review_agent/fixtures/fixture_sandbox_fail.diff b/examples/skills_code_review_agent/fixtures/fixture_sandbox_fail.diff new file mode 100644 index 00000000..acd0e7a5 --- /dev/null +++ b/examples/skills_code_review_agent/fixtures/fixture_sandbox_fail.diff @@ -0,0 +1,8 @@ +diff --git a/src/fail.py b/src/fail.py +index a543ef8..b210cd4 100644 +--- a/src/fail.py ++++ b/src/fail.py +@@ -1,3 +1,3 @@ + def fail(): +- pass ++ TRIGGER_SANDBOX_CRASH diff --git a/examples/skills_code_review_agent/fixtures/fixture_security.diff b/examples/skills_code_review_agent/fixtures/fixture_security.diff new file mode 100644 index 00000000..555749a9 --- /dev/null +++ b/examples/skills_code_review_agent/fixtures/fixture_security.diff @@ -0,0 +1,11 @@ +diff --git a/src/utils.py b/src/utils.py +index a543ef8..b210cd4 100644 +--- a/src/utils.py ++++ b/src/utils.py +@@ -1,4 +1,5 @@ ++import subprocess ++import pickle + def run_command(user_input): +- pass ++ subprocess.run(f"echo {user_input}", shell=True) ++ data = pickle.loads(user_input) diff --git a/examples/skills_code_review_agent/fixtures/fixture_sensitive.diff b/examples/skills_code_review_agent/fixtures/fixture_sensitive.diff new file mode 100644 index 00000000..b5bfc495 --- /dev/null +++ b/examples/skills_code_review_agent/fixtures/fixture_sensitive.diff @@ -0,0 +1,11 @@ +diff --git a/src/config.py b/src/config.py +index a543ef8..b210cd4 100644 +--- a/src/config.py ++++ b/src/config.py +@@ -1,4 +1,4 @@ + def setup(): +- # Config loading +- api_key = None ++ # Config loading with hardcoded key ++ api_key = "sk-proj-super-secret-key-12345" + password = "admin_password_9876" diff --git a/examples/skills_code_review_agent/init_db.py b/examples/skills_code_review_agent/init_db.py new file mode 100644 index 00000000..48b21629 --- /dev/null +++ b/examples/skills_code_review_agent/init_db.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +import sys +import os +from examples.skills_code_review_agent.db import Base, ReviewDbRepository + +def initialize_database(db_url: str = "sqlite:///review_agent.db"): + """ + Initializes the database schema by creating all required tables. + Also handles basic migration (dropping tables if requested via reset). + """ + print(f"Initializing database schema at: {db_url}") + + # Check if we want a clean reset + if "--reset" in sys.argv: + print("Warning: Reset option detected. Dropping all existing tables...") + # Get raw engine to drop + repo = ReviewDbRepository(db_url) + Base.metadata.drop_all(repo.engine) + print("Tables dropped.") + + repo = ReviewDbRepository(db_url) + print("Database tables successfully initialized/migrated.") + print("Tables created:") + for table_name in Base.metadata.tables.keys(): + print(f" - {table_name}") + +if __name__ == "__main__": + db_path = "sqlite:///review_agent.db" + initialize_database(db_path) diff --git a/examples/skills_code_review_agent/pre_commit_hook.sh b/examples/skills_code_review_agent/pre_commit_hook.sh new file mode 100644 index 00000000..cfe178d6 --- /dev/null +++ b/examples/skills_code_review_agent/pre_commit_hook.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# +# Git pre-commit hook to run automatic code review on staged changes. +# Copy or symlink this to .git/hooks/pre-commit +# + +echo "🔍 Running Automated Code Review Agent on staged changes..." + +# Create a temporary diff file +TEMP_DIFF=$(mktemp) +trap 'rm -f "$TEMP_DIFF" review_report.json review_report.md' EXIT +git diff --cached > "$TEMP_DIFF" + +# If diff is empty, exit early +if [ ! -s "$TEMP_DIFF" ]; then + echo "✅ No staged changes found. Skipping code review." + exit 0 +fi + +# Run the agent in fake-model (dry-run) mode +python3 -m examples.skills_code_review_agent.agent --diff-file "$TEMP_DIFF" +AGENT_EXIT_CODE=$? +if [ $AGENT_EXIT_CODE -ne 0 ]; then + echo "❌ [BLOCK] Code Review Agent execution failed (Exit code: $AGENT_EXIT_CODE)." + echo "Please resolve runtime/syntax errors before committing." + exit 1 +fi + +# Check the generated JSON report for any high/critical findings +if [ -f "review_report.json" ]; then + # Use python to parse JSON and check for high/critical severities + python3 -c " +import json, sys +try: + data = json.load(open('review_report.json')) + findings = data.get('findings', []) + data.get('needs_human_review', []) + high_critical = [f for f in findings if f.get('severity', '').lower() in ('critical', 'high')] + if len(high_critical) > 0: + print(f'❌ [BLOCK] Code Review Agent found {len(high_critical)} critical/high issues.') + sys.exit(1) +except Exception as e: + print('Failed to parse code review report:', e) + sys.exit(1) +" + if [ $? -ne 0 ]; then + echo "Please review the suggestions in review_report.md before committing." + exit 1 + fi +fi + +echo "✅ Code review completed successfully. Committing..." +exit 0 diff --git a/examples/skills_code_review_agent/samples/review_report.json b/examples/skills_code_review_agent/samples/review_report.json new file mode 100644 index 00000000..03a6f5f8 --- /dev/null +++ b/examples/skills_code_review_agent/samples/review_report.json @@ -0,0 +1,71 @@ +{ + "task_id": "task_a8c95fe3", + "status": "COMPLETED", + "findings": [ + { + "severity": "medium", + "category": "Missing Test", + "file": "src/utils.py", + "line": 1, + "title": "Missing unit test file or test updates", + "evidence": "Modified source file: src/utils.py without any matching test file changes in diff", + "recommendation": "Create or update a test file (e.g., tests/test_utils.py) to verify these changes.", + "confidence": "high", + "source": "static_analyzer" + }, + { + "severity": "high", + "category": "Security Risk", + "file": "src/utils.py", + "line": 4, + "title": "Subprocess execution with shell=True", + "evidence": "subprocess.run(f\"echo {user_input}\", shell=True)", + "recommendation": "Avoid using shell=True to prevent command/shell injection risks. Pass arguments as a list instead.", + "confidence": "high", + "source": "static_analyzer" + }, + { + "severity": "high", + "category": "Security Risk", + "file": "src/utils.py", + "line": 5, + "title": "Unsafe deserialization using pickle", + "evidence": "data = pickle.loads(user_input)", + "recommendation": "Use json or safer deserialization methods instead of pickle to prevent arbitrary code execution.", + "confidence": "high", + "source": "static_analyzer" + } + ], + "needs_human_review": [], + "filter_intercept_summary": [], + "sandbox_runs": [ + { + "command": "python skills/code-review/scripts/parse_diff.py --diff examples/skills_code_review_agent/fixtures/fixture_security.diff --output parsed_task_a8c95fe3.json", + "status": "SUCCESS", + "duration_ms": 194, + "stdout": "", + "stderr": "" + }, + { + "command": "python skills/code-review/scripts/run_checks.py --parsed-diff parsed_task_a8c95fe3.json --output findings_task_a8c95fe3.json", + "status": "SUCCESS", + "duration_ms": 192, + "stdout": "", + "stderr": "" + } + ], + "metrics": { + "total_duration_ms": 462, + "sandbox_duration_ms": 386, + "tool_calls": 3, + "blocks": 0, + "findings_count": 3, + "severity_distribution": { + "critical": 0, + "high": 2, + "medium": 1, + "low": 0 + }, + "exception_types": {} + } +} \ No newline at end of file diff --git a/examples/skills_code_review_agent/samples/review_report.md b/examples/skills_code_review_agent/samples/review_report.md new file mode 100644 index 00000000..4f082ca2 --- /dev/null +++ b/examples/skills_code_review_agent/samples/review_report.md @@ -0,0 +1,34 @@ +# Code Review Report (Task: task_a8c95fe3) + +**Status**: COMPLETED +**Total Duration**: 462 ms +**Sandbox execution duration**: 386 ms + +## Findings Summary +- Critical: 0 +- High: 2 +- Medium: 1 +- Low: 0 + +## High Confidence Findings +### 1. [MEDIUM] Missing unit test file or test updates +- **Category**: Missing Test +- **File**: src/utils.py:1 +- **Evidence**: `Modified source file: src/utils.py without any matching test file changes in diff` +- **Recommendation**: Create or update a test file (e.g., tests/test_utils.py) to verify these changes. + +### 2. [HIGH] Subprocess execution with shell=True +- **Category**: Security Risk +- **File**: src/utils.py:4 +- **Evidence**: `subprocess.run(f"echo {user_input}", shell=True)` +- **Recommendation**: Avoid using shell=True to prevent command/shell injection risks. Pass arguments as a list instead. + +### 3. [HIGH] Unsafe deserialization using pickle +- **Category**: Security Risk +- **File**: src/utils.py:5 +- **Evidence**: `data = pickle.loads(user_input)` +- **Recommendation**: Use json or safer deserialization methods instead of pickle to prevent arbitrary code execution. + +## Sandbox Execution Details +- **Command**: `python skills/code-review/scripts/parse_diff.py --diff examples/skills_code_review_agent/fixtures/fixture_security.diff --output parsed_task_a8c95fe3.json` (SUCCESS in 194 ms) +- **Command**: `python skills/code-review/scripts/run_checks.py --parsed-diff parsed_task_a8c95fe3.json --output findings_task_a8c95fe3.json` (SUCCESS in 192 ms) diff --git a/examples/skills_code_review_agent/skills/code-review/SKILL.md b/examples/skills_code_review_agent/skills/code-review/SKILL.md new file mode 100644 index 00000000..a1e72fa7 --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/SKILL.md @@ -0,0 +1,16 @@ +--- +name: code-review +description: Code review skill to parse diffs, run sanity checks, and review rules on modified files. +--- + +# Code Review Skill + +This skill provides utilities for parsing git diff/PR patches and executing linting, static checks, or testing rules in a sandboxed workspace environment. + +## Usage + +1. Parse git unified diff: + `python3 scripts/parse_diff.py --diff work/inputs/input.diff --output work/inputs/parsed_diff.json` + +2. Run static analysis rules (covering security, async, db connection, resource leak, and sensitive credentials): + `python3 scripts/run_checks.py --parsed-diff work/inputs/parsed_diff.json --src-dir work/inputs/repo/ --output out/raw_findings.json` diff --git a/examples/skills_code_review_agent/skills/code-review/rules.md b/examples/skills_code_review_agent/skills/code-review/rules.md new file mode 100644 index 00000000..11508814 --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/rules.md @@ -0,0 +1,25 @@ +# Code Review Rules + +This document outlines the standard code review rules utilized by the Code Review Agent. + +## 1. Security Risks +- **Shell Injections**: Avoid using `subprocess.run(..., shell=True)` with unvalidated user input or string formatting. +- **Unsafe Deserialization**: Avoid using Python's `pickle` on untrusted inputs. Use safe parsers like `json`. + +## 2. Async Errors +- **Coroutine Not Awaited**: Every async function/coroutine call must be prefixed with `await`, `asyncio.create_task()`, or processed via an event loop. +- **Blocking Calls**: Do not block the asyncio event loop with blocking synchronous operations (e.g. `time.sleep()`, synchronous network requests). Use `await asyncio.sleep()` instead. + +## 3. Resource Leaks +- **File Connection Lifecycles**: Always open files using context managers (`with open(...) as f:`). +- **Socket / Database Connection Leak**: Ensure network connections, database connections, and sockets are closed or released in a `finally` block or handled via context managers. + +## 4. Database Transaction and Connection Lifecycle +- **Unmanaged Transactions**: Database transactions must be managed properly. Commits/rollbacks should be inside try/except/finally blocks or managed by session contexts. +- **Leaked Sessions**: Avoid creating new DB sessions without closing them or using a context manager. + +## 5. Sensitive Information Leaks +- **Credentials/Keys**: Do not embed raw API keys, bearer tokens, or database passwords in source code. Any matches like `api_key = "..."` or `password = "..."` must be flagged and redacted. + +## 6. Missing Unit Tests +- **Test Coverage**: Any new classes or functions added to source files (e.g. in `src/` or core modules) should have corresponding tests added or modified in the `tests/` directory. diff --git a/examples/skills_code_review_agent/skills/code-review/scripts/parse_diff.py b/examples/skills_code_review_agent/skills/code-review/scripts/parse_diff.py new file mode 100644 index 00000000..4e0b81f7 --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/scripts/parse_diff.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +import argparse +import json +import re +import os + +def parse_diff(diff_content): + """ + Parses unified diff content. + Returns a dict mapping filename -> list of dicts(line_no, type, content) + """ + files = {} + current_file = None + current_line_num = 0 + + lines = diff_content.splitlines() + for line in lines: + if line.startswith('diff --git'): + # Reset current file info + current_file = None + elif line.startswith('--- '): + continue + elif line.startswith('+++ '): + # Target file path + # strip a/ or b/ if present + m = re.match(r'^\+\+\+\s+b/(.*)$', line) + if m: + current_file = m.group(1) + else: + m2 = re.match(r'^\+\+\+\s+(.*)$', line) + if m2: + current_file = m2.group(1) + if current_file: + # Remove possible quotes or trailing garbage + current_file = current_file.strip().strip('"') + files[current_file] = [] + else: + hunk_match = None + if line.startswith('@@') or '@@' in line: + hunk_match = re.search(r'@@\s+-\d+(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s*@@', line) + if hunk_match: + current_line_num = int(hunk_match.group(1)) + elif current_file is not None: + if line.startswith('+'): + # Added/Modified line + files[current_file].append({ + 'line': current_line_num, + 'type': 'added', + 'content': line[1:] + }) + current_line_num += 1 + elif line.startswith('-'): + # Deleted line, doesn't increment current_line_num for new file + pass + else: + # Context line + files[current_file].append({ + 'line': current_line_num, + 'type': 'context', + 'content': line[1:] if (line and line[0] == ' ') else line + }) + current_line_num += 1 + + return files + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('--diff', required=True) + parser.add_argument('--output', required=True) + args = parser.parse_args() + + if not os.path.exists(args.diff): + print(f"Error: diff file {args.diff} does not exist.") + # Create empty output on failure so sandbox run doesn't crash completely + with open(args.output, 'w') as f: + json.dump({}, f) + return + + with open(args.diff, 'r', encoding='utf-8', errors='ignore') as f: + content = f.read() + + parsed = parse_diff(content) + + out_dir = os.path.dirname(args.output) + if out_dir: + os.makedirs(out_dir, exist_ok=True) + with open(args.output, 'w', encoding='utf-8') as f: + json.dump(parsed, f, indent=2) + +if __name__ == '__main__': + main() diff --git a/examples/skills_code_review_agent/skills/code-review/scripts/run_checks.py b/examples/skills_code_review_agent/skills/code-review/scripts/run_checks.py new file mode 100644 index 00000000..23921854 --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/scripts/run_checks.py @@ -0,0 +1,363 @@ +#!/usr/bin/env python3 +import argparse +import json +import re +import os +import ast +import sys + +# Categories +CAT_SECURITY = "Security Risk" +CAT_ASYNC = "Async Error" +CAT_RESOURCE = "Resource Leak" +CAT_DB = "Database Connection Lifecycle" +CAT_TEST = "Missing Test" +CAT_SENSITIVE = "Sensitive Information Leak" + +class ASTCodeReviewVisitor(ast.NodeVisitor): + """ + AST Visitor to analyze Python source code with high precision. + """ + def __init__(self, filename, modified_lines_set): + self.filename = filename + self.modified_lines = modified_lines_set + self.findings = [] + self.in_async_def = False + self.in_with_stmt = False + + def visit_AsyncFunctionDef(self, node): + old_state = self.in_async_def + self.in_async_def = True + self.generic_visit(node) + self.in_async_def = old_state + + def visit_With(self, node): + old_state = self.in_with_stmt + self.in_with_stmt = True + self.generic_visit(node) + self.in_with_stmt = old_state + + def visit_Call(self, node): + # Only report findings on lines that were actually modified in the diff + if node.lineno in self.modified_lines: + # 1. Check for shell=True in subprocess calls + is_subprocess = False + if isinstance(node.func, ast.Attribute) and isinstance(node.func.value, ast.Name): + if node.func.value.id == "subprocess" and node.func.attr in ("run", "Popen", "call", "check_output"): + is_subprocess = True + elif isinstance(node.func, ast.Name) and node.func.id in ("run", "Popen", "call", "check_output"): + # if imported directly + is_subprocess = True + + if is_subprocess: + for keyword in node.keywords: + if keyword.arg == "shell" and isinstance(keyword.value, ast.Constant) and keyword.value.value is True: + self.findings.append({ + "severity": "high", + "category": CAT_SECURITY, + "file": self.filename, + "line": node.lineno, + "title": "Subprocess execution with shell=True", + "evidence": f"Subprocess call with shell=True on line {node.lineno}", + "recommendation": "Avoid using shell=True to prevent command/shell injection risks. Pass arguments as a list instead.", + "confidence": "high", + "source": "ast_analyzer" + }) + + # 2. Check for pickle.loads + is_pickle_loads = False + if isinstance(node.func, ast.Attribute) and isinstance(node.func.value, ast.Name): + if node.func.value.id == "pickle" and node.func.attr == "loads": + is_pickle_loads = True + elif isinstance(node.func, ast.Name) and node.func.id == "loads": + is_pickle_loads = True + + if is_pickle_loads: + self.findings.append({ + "severity": "high", + "category": CAT_SECURITY, + "file": self.filename, + "line": node.lineno, + "title": "Unsafe deserialization using pickle", + "evidence": f"pickle.loads call on line {node.lineno}", + "recommendation": "Use json or safer deserialization methods instead of pickle to prevent arbitrary code execution.", + "confidence": "high", + "source": "ast_analyzer" + }) + + # 3. Check for blocking time.sleep inside async functions + if self.in_async_def: + is_time_sleep = False + if isinstance(node.func, ast.Attribute) and isinstance(node.func.value, ast.Name): + if node.func.value.id == "time" and node.func.attr == "sleep": + is_time_sleep = True + elif isinstance(node.func, ast.Name) and node.func.id == "sleep": + is_time_sleep = True + + if is_time_sleep: + self.findings.append({ + "severity": "medium", + "category": CAT_ASYNC, + "file": self.filename, + "line": node.lineno, + "title": "Blocking call time.sleep() in async function", + "evidence": f"time.sleep() called inside async def on line {node.lineno}", + "recommendation": "Use 'await asyncio.sleep()' instead of 'time.sleep()' to avoid blocking the event loop.", + "confidence": "high", + "source": "ast_analyzer" + }) + + # 4. Check for open() outside 'with' statement + if isinstance(node.func, ast.Name) and node.func.id == "open" and not self.in_with_stmt: + self.findings.append({ + "severity": "medium", + "category": CAT_RESOURCE, + "file": self.filename, + "line": node.lineno, + "title": "File opened without context manager", + "evidence": f"open() called outside with-statement on line {node.lineno}", + "recommendation": "Use 'with open(...) as f:' context manager to ensure the file is closed automatically.", + "confidence": "high", + "source": "ast_analyzer" + }) + + # 5. Check for db.connect() outside 'with' statement + is_db_connect = False + if isinstance(node.func, ast.Attribute) and isinstance(node.func.value, ast.Name): + if node.func.value.id == "db" and node.func.attr == "connect": + is_db_connect = True + elif isinstance(node.func, ast.Name) and node.func.id == "connect": + is_db_connect = True + + if is_db_connect and not self.in_with_stmt: + self.findings.append({ + "severity": "high", + "category": CAT_DB, + "file": self.filename, + "line": node.lineno, + "title": "Database connection or session created outside context manager", + "evidence": f"Database connection created outside with-statement on line {node.lineno}", + "recommendation": "Manage database connections or sessions with a context manager (with ...) or ensure proper session.close() is called.", + "confidence": "high", + "source": "ast_analyzer" + }) + + self.generic_visit(node) + +def run_checks(parsed_diff, src_dir): + findings = [] + + # 1. Check for Missing Tests across all modified files + modified_source_files = [] + has_test_changes = False + + for filename in parsed_diff.keys(): + fn_lower = filename.lower() + if "test" in fn_lower: + has_test_changes = True + elif fn_lower.endswith(".py"): + modified_source_files.append(filename) + + if modified_source_files and not has_test_changes: + for src_file in modified_source_files: + findings.append({ + "severity": "medium", + "category": CAT_TEST, + "file": src_file, + "line": 1, + "title": "Missing unit test file or test updates", + "evidence": f"Modified source file: {src_file} without any matching test file changes in diff", + "recommendation": f"Create or update a test file (e.g., tests/test_{os.path.basename(src_file)}) to verify these changes.", + "confidence": "high", + "source": "static_analyzer" + }) + + # 2. Scan each file using AST (if possible) or regex line-by-line fallback + for filename, lines in parsed_diff.items(): + # Try to parse the full file from workspace if present, or reconstruct code + full_code = None + # Resolve and validate path to prevent directory traversal + if src_dir: + normalized_src = os.path.abspath(src_dir) + if os.path.isabs(filename) or ".." in filename: + continue + local_path = os.path.abspath(os.path.join(normalized_src, filename)) + try: + is_under = os.path.commonpath([local_path, normalized_src]) == normalized_src + except ValueError: + is_under = False + if not is_under: + continue + else: + if ".." in filename or os.path.isabs(filename): + continue + local_path = filename + + # Build set of modified lines + modified_lines_set = {item["line"] for item in lines if item["type"] == "added"} + + # Attempt AST check if file is readable + ast_success = False + if os.path.exists(local_path): + try: + with open(local_path, "r", encoding="utf-8", errors="ignore") as f: + full_code = f.read() + tree = ast.parse(full_code) + visitor = ASTCodeReviewVisitor(filename, modified_lines_set) + visitor.visit(tree) + findings.extend(visitor.findings) + ast_success = True + except Exception: + pass # Fallback to regex pattern matching on diff lines + + # Regex / Line-by-line fallback pattern matching + if not ast_success: + for item in lines: + line_num = item["line"] + content = item["content"] + + # TRIGGER_SANDBOX_CRASH + if "TRIGGER_SANDBOX_CRASH" in content: + raise ValueError("Simulated sandbox crash triggered by diff content") + + # Security checks + if "shell=True" in content and ("subprocess" in content or "run(" in content or "Popen" in content): + findings.append({ + "severity": "high", + "category": CAT_SECURITY, + "file": filename, + "line": line_num, + "title": "Subprocess execution with shell=True", + "evidence": content.strip(), + "recommendation": "Avoid using shell=True to prevent command/shell injection risks. Pass arguments as a list instead.", + "confidence": "high", + "source": "static_analyzer" + }) + + if "pickle.loads(" in content: + findings.append({ + "severity": "high", + "category": CAT_SECURITY, + "file": filename, + "line": line_num, + "title": "Unsafe deserialization using pickle", + "evidence": content.strip(), + "recommendation": "Use json or safer deserialization methods instead of pickle to prevent arbitrary code execution.", + "confidence": "high", + "source": "static_analyzer" + }) + + + + # Async Checks + if "time.sleep(" in content: + findings.append({ + "severity": "medium", + "category": CAT_ASYNC, + "file": filename, + "line": line_num, + "title": "Blocking call time.sleep() in async environment", + "evidence": content.strip(), + "recommendation": "Use 'await asyncio.sleep()' instead of 'time.sleep()' to avoid blocking the event loop.", + "confidence": "high", + "source": "static_analyzer" + }) + + if "async def" not in content and re.search(r'\b[a-zA-Z0-9_]+_async\s*\(', content) and "await " not in content: + findings.append({ + "severity": "high", + "category": CAT_ASYNC, + "file": filename, + "line": line_num, + "title": "Coroutine called without await", + "evidence": content.strip(), + "recommendation": "Prepend 'await' to the coroutine call or schedule it via asyncio.create_task().", + "confidence": "medium", + "source": "static_analyzer" + }) + + # Resource leak checks + if re.search(r'\w+\s*=\s*open\(', content) and "with " not in content: + findings.append({ + "severity": "medium", + "category": CAT_RESOURCE, + "file": filename, + "line": line_num, + "title": "File opened without context manager", + "evidence": content.strip(), + "recommendation": "Use 'with open(...) as f:' context manager to ensure the file is closed automatically.", + "confidence": "high", + "source": "static_analyzer" + }) + + # DB connection/transaction lifecycle checks + if ("db.connect(" in content or "create_engine(" in content or "sessionmaker" in content) and "with " not in content: + findings.append({ + "severity": "high", + "category": CAT_DB, + "file": filename, + "line": line_num, + "title": "Database connection or session created outside context manager", + "evidence": content.strip(), + "recommendation": "Manage database connections or sessions with a context manager (with ...) or ensure proper session.close() is called.", + "confidence": "high", + "source": "static_analyzer" + }) + # Unconditionally check for TRIGGER_SANDBOX_CRASH and sensitive credentials + for item in lines: + line_num = item["line"] + content = item["content"] + + if "TRIGGER_SANDBOX_CRASH" in content: + raise ValueError("Simulated sandbox crash triggered by diff content") + + cred_patterns = [ + r'(?i)(api_key|password|token|secret|passwd)\s*=\s*["\']([^"\']+)["\']', + ] + for pat in cred_patterns: + m = re.search(pat, content) + if m: + key_name = m.group(1) + raw_val = m.group(2) + if len(raw_val) > 4 and not raw_val.startswith("os.environ") and not raw_val.startswith("YOUR_"): + redacted_evidence = content.replace(raw_val, "[REDACTED]").strip() + findings.append({ + "severity": "critical", + "category": CAT_SENSITIVE, + "file": filename, + "line": line_num, + "title": "Potential hardcoded sensitive credential", + "evidence": redacted_evidence, + "recommendation": f"Remove the hardcoded credential for '{key_name}' and load it from environment variables or a configuration vault.", + "confidence": "high", + "source": "static_analyzer" + }) + + return findings + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('--parsed-diff', required=True) + parser.add_argument('--src-dir', required=False, default='.') + parser.add_argument('--output', required=True) + args = parser.parse_args() + + if not os.path.exists(args.parsed_diff): + print(f"Error: parsed diff {args.parsed_diff} does not exist.") + with open(args.output, 'w') as f: + json.dump([], f) + return + + with open(args.parsed_diff, 'r', encoding='utf-8') as f: + parsed_diff = json.load(f) + + findings = run_checks(parsed_diff, args.src_dir) + + out_dir = os.path.dirname(args.output) + if out_dir: + os.makedirs(out_dir, exist_ok=True) + with open(args.output, 'w', encoding='utf-8') as f: + json.dump(findings, f, indent=2) + +if __name__ == '__main__': + main() diff --git a/examples/skills_code_review_agent/test_agent.py b/examples/skills_code_review_agent/test_agent.py new file mode 100644 index 00000000..e34a6f89 --- /dev/null +++ b/examples/skills_code_review_agent/test_agent.py @@ -0,0 +1,352 @@ +import os +import uuid +import pytest +import sqlite3 +from pathlib import Path +from examples.skills_code_review_agent.agent import CodeReviewAgent +from examples.skills_code_review_agent.db import ReviewDbRepository + +FIXTURES_DIR = Path(__file__).parent / "fixtures" +TEST_DB_URL = "sqlite:///:memory:" # Use in-memory DB for isolated tests + +@pytest.fixture +def agent(): + return CodeReviewAgent(db_url=TEST_DB_URL, runtime_mode="local") + +def test_clean_diff(agent): + task_id = f"task_{uuid.uuid4().hex[:8]}" + diff_file = FIXTURES_DIR / "fixture_clean.diff" + + report_json, report_md = agent.run_review(task_id, str(diff_file), fake_model=True) + + assert report_json["status"] == "COMPLETED" + assert len(report_json["findings"]) == 0 + + # Query database to verify + task_details = agent.db.get_task_details(task_id) + assert task_details is not None + assert task_details["status"] == "COMPLETED" + assert len(task_details["findings"]) == 0 + +def test_security_diff(agent): + task_id = f"task_{uuid.uuid4().hex[:8]}" + diff_file = FIXTURES_DIR / "fixture_security.diff" + + report_json, report_md = agent.run_review(task_id, str(diff_file), fake_model=True) + + assert report_json["status"] == "COMPLETED" + findings = report_json["findings"] + categories = [f["category"] for f in findings] + + assert "Security Risk" in categories + assert any("shell=True" in f["evidence"] for f in findings) + assert any("pickle.loads" in f["evidence"] for f in findings) + +def test_async_diff(agent): + task_id = f"task_{uuid.uuid4().hex[:8]}" + diff_file = FIXTURES_DIR / "fixture_async.diff" + + report_json, report_md = agent.run_review(task_id, str(diff_file), fake_model=True) + + assert report_json["status"] == "COMPLETED" + findings = report_json["findings"] + categories = [f["category"] for f in findings] + + assert "Async Error" in categories + assert any("time.sleep" in f["evidence"] for f in findings) + +def test_db_lifecycle_diff(agent): + task_id = f"task_{uuid.uuid4().hex[:8]}" + diff_file = FIXTURES_DIR / "fixture_db.diff" + + report_json, report_md = agent.run_review(task_id, str(diff_file), fake_model=True) + + assert report_json["status"] == "COMPLETED" + findings = report_json["findings"] + categories = [f["category"] for f in findings] + + assert "Database Connection Lifecycle" in categories + assert any("db.connect" in f["evidence"] for f in findings) + +def test_missing_test_diff(agent): + task_id = f"task_{uuid.uuid4().hex[:8]}" + diff_file = FIXTURES_DIR / "fixture_missing_test.diff" + + report_json, report_md = agent.run_review(task_id, str(diff_file), fake_model=True) + + assert report_json["status"] == "COMPLETED" + findings = report_json["findings"] + categories = [f["category"] for f in findings] + + assert "Missing Test" in categories + +def test_duplicate_findings(agent): + task_id = f"task_{uuid.uuid4().hex[:8]}" + diff_file = FIXTURES_DIR / "fixture_duplicate.diff" + + report_json, report_md = agent.run_review(task_id, str(diff_file), fake_model=True) + + assert report_json["status"] == "COMPLETED" + findings = report_json["findings"] + + # Check that there is at least 1 'Security Risk' finding + security_findings = [f for f in findings if f["category"] == "Security Risk"] + assert len(security_findings) >= 1 + +def test_sandbox_failure(agent): + task_id = f"task_{uuid.uuid4().hex[:8]}" + diff_file = FIXTURES_DIR / "fixture_sandbox_fail.diff" + + # Run review. A sandbox failure shouldn't crash the whole task process. + report_json, report_md = agent.run_review(task_id, str(diff_file), fake_model=True) + + assert report_json["status"] == "FAILED" + + # Verify sandbox runs table has FAILED state + task_details = agent.db.get_task_details(task_id) + assert any(run["status"] == "FAILED" for run in task_details["sandbox_runs"]) + +def test_sensitive_redaction(agent): + task_id = f"task_{uuid.uuid4().hex[:8]}" + diff_file = FIXTURES_DIR / "fixture_sensitive.diff" + + report_json, report_md = agent.run_review(task_id, str(diff_file), fake_model=True) + + assert report_json["status"] == "COMPLETED" + + # Ensure sensitive credentials do not appear in cleartext + findings = report_json["findings"] + assert len(findings) > 0 + for f in findings: + if f["category"] == "Sensitive Information Leak": + assert "sk-proj-super-secret-key-12345" not in f["evidence"] + assert "admin_password_9876" not in f["evidence"] + assert "[REDACTED]" in f["evidence"] + + # Also check database report contents + task_details = agent.db.get_task_details(task_id) + for f in task_details["findings"]: + if f["category"] == "Sensitive Information Leak": + assert "sk-proj-super-secret-key-12345" not in f["evidence"] + assert "admin_password_9876" not in f["evidence"] + assert "[REDACTED]" in f["evidence"] + +def test_path_traversal_prevention(tmp_path): + # Construct a mock malicious diff that tries to traverse directory + malicious_diff = tmp_path / "fixture_traversal.diff" + malicious_diff.write_text("""diff --git a/src/config.py b/../../etc/passwd +--- a/src/config.py ++++ b/../../etc/passwd +@@ -1,1 +1,1 @@ ++subprocess.run("echo hello", shell=True) +""", encoding="utf-8") + + from examples.skills_code_review_agent.agent import CodeReviewAgent + agent = CodeReviewAgent(db_url="sqlite:///:memory:", runtime_mode="local", repo_path=str(tmp_path)) + task_id = f"task_{uuid.uuid4().hex[:8]}" + + # Run review on malicious diff + report_json, report_md = agent.run_review(task_id, str(malicious_diff), fake_model=True) + + # Should not crash, and shouldn't read or leak outside files + assert report_json["status"] == "COMPLETED" or report_json["status"] == "FAILED" + +def test_ast_path_coverage(tmp_path): + # Create the workspace layout + src_dir = tmp_path / "src" + src_dir.mkdir(parents=True, exist_ok=True) + + # Write a Python file with a violation inside the repo + py_file = src_dir / "app.py" + py_file.write_text("""def my_func(): + subprocess.run("echo ast", shell=True) +""", encoding="utf-8") + + # Create the diff pointing to this file + diff_file = tmp_path / "ast_test.diff" + diff_file.write_text("""diff --git a/src/app.py b/src/app.py +--- a/src/app.py ++++ b/src/app.py +@@ -1,2 +1,2 @@ + def my_func(): +- pass ++ subprocess.run("echo ast", shell=True) +""", encoding="utf-8") + + from examples.skills_code_review_agent.agent import CodeReviewAgent + # Instantiate agent pointing to the temporary workspace directory + agent = CodeReviewAgent(db_url="sqlite:///:memory:", runtime_mode="local", repo_path=str(tmp_path)) + task_id = f"task_{uuid.uuid4().hex[:8]}" + + report_json, report_md = agent.run_review(task_id, str(diff_file), fake_model=True) + + # Assert findings generated by AST checks (and didn't fallback to regex since file exists) + assert len(report_json["findings"]) >= 1 + security_findings = [f for f in report_json["findings"] if f["category"] == "Security Risk"] + assert len(security_findings) >= 1 + finding = security_findings[0] + assert "shell=True" in finding["evidence"] + +def test_sensitive_and_shell_same_line(tmp_path): + diff_file = tmp_path / "same_line.diff" + diff_file.write_text("""diff --git a/src/same_line.py b/src/same_line.py +--- a/src/same_line.py ++++ b/src/same_line.py +@@ -1,1 +1,1 @@ ++subprocess.run("echo hello", shell=True) # api_key="sk-proj-secret-123456789" +""", encoding="utf-8") + + from examples.skills_code_review_agent.agent import CodeReviewAgent + agent = CodeReviewAgent(db_url="sqlite:///:memory:", runtime_mode="local", repo_path=str(tmp_path)) + task_id = f"task_{uuid.uuid4().hex[:8]}" + + report_json, report_md = agent.run_review(task_id, str(diff_file), fake_model=True) + + assert report_json["status"] == "COMPLETED" + + # Verify that all findings have evidence redacted and do not leak cleartext credentials + all_findings = report_json["findings"] + report_json["needs_human_review"] + assert len(all_findings) >= 1 + for f in all_findings: + assert "sk-proj-secret-123456789" not in f["evidence"] + if f["category"] in ("Security Risk", "Sensitive Information Leak"): + assert "[REDACTED]" in f["evidence"] + + # Check DB + task_details = agent.db.get_task_details(task_id) + for f in task_details["findings"]: + assert "sk-proj-secret-123456789" not in f["evidence"] + +def test_ast_sensitive_credentials(tmp_path): + src_dir = tmp_path / "src" + src_dir.mkdir(parents=True, exist_ok=True) + + # Write Python file with hardcoded key + py_file = src_dir / "secret.py" + py_file.write_text("""api_key = "sk-proj-secret-123456789" +""", encoding="utf-8") + + diff_file = tmp_path / "secret.diff" + diff_file.write_text("""diff --git a/src/secret.py b/src/secret.py +--- a/src/secret.py ++++ b/src/secret.py +@@ -1,1 +1,1 @@ ++api_key = "sk-proj-secret-123456789" +""", encoding="utf-8") + + from examples.skills_code_review_agent.agent import CodeReviewAgent + agent = CodeReviewAgent(db_url="sqlite:///:memory:", runtime_mode="local", repo_path=str(tmp_path)) + task_id = f"task_{uuid.uuid4().hex[:8]}" + + report_json, report_md = agent.run_review(task_id, str(diff_file), fake_model=True) + + # Findings must include the sensitive key warning, and evidence must be redacted + all_findings = report_json["findings"] + report_json["needs_human_review"] + assert len(all_findings) >= 1 + cred_finding = [f for f in all_findings if f["category"] == "Sensitive Information Leak"][0] + assert "sk-proj-secret-123456789" not in cred_finding["evidence"] + assert "[REDACTED]" in cred_finding["evidence"] + +def test_real_git_diff_hunk_context(tmp_path): + diff_content = """diff --git a/src/app.py b/src/app.py +--- a/src/app.py ++++ b/src/app.py +@@ -1,2 +1,2 @@ def foo(): +- pass ++ subprocess.run("echo", shell=True) +""" + import importlib.util + from pathlib import Path + spec = importlib.util.spec_from_file_location( + "parse_diff", + str(Path(__file__).parent / "skills" / "code-review" / "scripts" / "parse_diff.py") + ) + parse_diff_module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(parse_diff_module) + parse_diff = parse_diff_module.parse_diff + parsed = parse_diff(diff_content) + assert "src/app.py" in parsed + added_lines = parsed["src/app.py"] + assert len(added_lines) == 1 + assert added_lines[0]["line"] == 1 + assert added_lines[0]["type"] == "added" + +def test_metacharacter_path_handling(tmp_path): + # Path containing shell metacharacters like $ and ; + special_dir = tmp_path / "my$project;dir" + special_dir.mkdir(parents=True, exist_ok=True) + + diff_file = special_dir / "test.diff" + diff_file.write_text("""diff --git a/src/app.py b/src/app.py +--- a/src/app.py ++++ b/src/app.py +@@ -1,1 +1,1 @@ ++print("Hello World") +""", encoding="utf-8") + + from examples.skills_code_review_agent.agent import CodeReviewAgent + # Instantiate pointing to the path with special characters + agent = CodeReviewAgent(db_url="sqlite:///:memory:", runtime_mode="local", repo_path=str(special_dir)) + task_id = f"task_{uuid.uuid4().hex[:8]}" + + report_json, report_md = agent.run_review(task_id, str(diff_file), fake_model=True) + + # Must NOT be INTERCEPTED due to shell metacharacters in paths since shell=False is used + assert report_json["status"] == "COMPLETED" + +def test_absolute_path_traversal_prevention(tmp_path): + # Place a target file outside the temporary workspace directory + outside_file = tmp_path / "outside.py" + outside_file.write_text("secret_key = 'unreachable'", encoding="utf-8") + + # workspace directory + workspace_dir = tmp_path / "workspace" + workspace_dir.mkdir() + + # Malicious diff that sets filename to absolute path of outside_file + diff_file = workspace_dir / "malicious.diff" + diff_file.write_text(f"""diff --git a/src/app.py b{outside_file.as_posix()} +--- a/src/app.py ++++ b{outside_file.as_posix()} +@@ -1,1 +1,1 @@ ++secret_key = 'unreachable' +""", encoding="utf-8") + + from examples.skills_code_review_agent.agent import CodeReviewAgent + agent = CodeReviewAgent(db_url="sqlite:///:memory:", runtime_mode="local", repo_path=str(workspace_dir)) + task_id = f"task_{uuid.uuid4().hex[:8]}" + + report_json, report_md = agent.run_review(task_id, str(diff_file), fake_model=True) + + # Must run to completion successfully but should not find the sensitive information because the file was skipped and not read + assert report_json["status"] == "COMPLETED" + findings = report_json["findings"] + for f in findings: + assert f["category"] != "Sensitive Information Leak" + +def test_diff_code_contains_hunk_marker(tmp_path): + diff_content = """diff --git a/src/app.py b/src/app.py +--- a/src/app.py ++++ b/src/app.py +@@ -1,2 +1,2 @@ + def foo(): ++ pat = re.compile('@@') +""" + import importlib.util + from pathlib import Path + spec = importlib.util.spec_from_file_location( + "parse_diff", + str(Path(__file__).parent / "skills" / "code-review" / "scripts" / "parse_diff.py") + ) + parse_diff_module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(parse_diff_module) + parse_diff = parse_diff_module.parse_diff + parsed = parse_diff(diff_content) + + # Verify app.py has the added line and it is not consumed/skipped as hunk header + assert "src/app.py" in parsed + added_lines = parsed["src/app.py"] + # 1 context line (def foo():) and 1 added line (pat = re.compile('@@')) + assert len(added_lines) == 2 + assert added_lines[1]["type"] == "added" + assert "re.compile('@@')" in added_lines[1]["content"]