From 14a2d43f5149c166ac501669e023bba3248453c1 Mon Sep 17 00:00:00 2001 From: DNKYr Date: Thu, 16 Jul 2026 12:00:15 +0800 Subject: [PATCH 01/22] feat(examples): add code-review skill scaffold and diff parser --- .../fixtures/clean.diff | 14 ++++ .../skills/code-review/SKILL.md | 29 +++++++ .../skills/code-review/scripts/diffparse.py | 82 +++++++++++++++++++ .../skills/code-review/scripts/parse_diff.py | 24 ++++++ .../tests/conftest.py | 15 ++++ .../tests/test_diffparse.py | 50 +++++++++++ 6 files changed, 214 insertions(+) create mode 100644 examples/skills_code_review_agent/fixtures/clean.diff create mode 100644 examples/skills_code_review_agent/skills/code-review/SKILL.md create mode 100644 examples/skills_code_review_agent/skills/code-review/scripts/diffparse.py create mode 100644 examples/skills_code_review_agent/skills/code-review/scripts/parse_diff.py create mode 100644 examples/skills_code_review_agent/tests/conftest.py create mode 100644 examples/skills_code_review_agent/tests/test_diffparse.py diff --git a/examples/skills_code_review_agent/fixtures/clean.diff b/examples/skills_code_review_agent/fixtures/clean.diff new file mode 100644 index 00000000..f53fc731 --- /dev/null +++ b/examples/skills_code_review_agent/fixtures/clean.diff @@ -0,0 +1,14 @@ +--- a/app/util.py ++++ b/app/util.py +@@ -1,4 +1,6 @@ + def add(a, b): ++ # add two numbers and return the result ++ # used by the billing module + return a + b +--- a/tests/test_util.py ++++ b/tests/test_util.py +@@ -1,3 +1,4 @@ + from app.util import add ++ + def test_add(): + assert add(1, 2) == 3 diff --git a/examples/skills_code_review_agent/skills/code-review/SKILL.md b/examples/skills_code_review_agent/skills/code-review/SKILL.md new file mode 100644 index 00000000..b9011654 --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/SKILL.md @@ -0,0 +1,29 @@ +--- +name: code-review +description: Automated code review rules and scripts. Parses unified diffs and detects security risks, async/resource leaks, DB lifecycle problems, missing tests, and secret leaks. +--- +Overview + +This skill reviews a unified diff. Stage the diff at work/inputs/changes.diff, then run +the scripts below from the workspace root. Every script prints a JSON object to stdout. + +Scripts + +- scripts/parse_diff.py : prints {"summary": ..., "files": [...]} +- scripts/check_security.py : security findings (eval/exec, shell=True, pickle, yaml.load, SQL injection) +- scripts/check_async_leak.py : async/resource leak findings (unreferenced tasks, unmanaged sessions/files) +- scripts/check_db_lifecycle.py : DB connection/cursor/transaction lifecycle findings +- scripts/check_tests_missing.py : source changed without test changes +- scripts/check_secrets.py : hardcoded secrets (evidence pre-redacted) + +Rules documentation lives under references/rules/. + +Examples + +1) python3 skills/code-review/scripts/parse_diff.py work/inputs/changes.diff +2) python3 skills/code-review/scripts/check_security.py work/inputs/changes.diff + +Output contract + +Checker scripts print: {"findings": [{"severity", "category", "file", "line", +"title", "evidence", "recommendation", "confidence", "source"}]} diff --git a/examples/skills_code_review_agent/skills/code-review/scripts/diffparse.py b/examples/skills_code_review_agent/skills/code-review/scripts/diffparse.py new file mode 100644 index 00000000..28fce8c8 --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/scripts/diffparse.py @@ -0,0 +1,82 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Unified diff parser shared by all code-review checker scripts (stdlib only).""" +import re + +_HUNK_RE = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@") + + +def _strip_prefix(path): + """Strip git's a/ b/ prefixes.""" + if path.startswith(("a/", "b/")): + return path[2:] + return path + + +def parse_unified_diff(text): + """Parse a unified diff into a list of per-file dicts with new-file line numbers.""" + files = [] + current = None + old_no = new_no = 0 + for line in text.splitlines(): + if line.startswith("diff --git") or line.startswith("Index: "): + current = None + continue + if line.startswith("--- "): + raw_old = line[4:].split("\t")[0].strip() + current = { + "path": "", + "old_path": "" if raw_old == "/dev/null" else _strip_prefix(raw_old), + "is_new": raw_old == "/dev/null", + "is_deleted": False, + "hunks": [], + "added_lines": [], + "removed_lines": [], + } + files.append(current) + continue + if line.startswith("+++ "): + if current is None: + continue + raw_new = line[4:].split("\t")[0].strip() + if raw_new == "/dev/null": + current["is_deleted"] = True + current["path"] = current["old_path"] + else: + current["path"] = _strip_prefix(raw_new) + continue + m = _HUNK_RE.match(line) + if m and current is not None: + old_no, new_no = int(m.group(1)), int(m.group(3)) + current["hunks"].append({"old_start": old_no, "new_start": new_no, "lines": []}) + continue + if current is None or not current["hunks"]: + continue + hunk = current["hunks"][-1] + if line.startswith("+"): + hunk["lines"].append({"tag": "+", "new_lineno": new_no, "old_lineno": None, "text": line[1:]}) + current["added_lines"].append({"line": new_no, "text": line[1:]}) + new_no += 1 + elif line.startswith("-"): + hunk["lines"].append({"tag": "-", "new_lineno": None, "old_lineno": old_no, "text": line[1:]}) + current["removed_lines"].append({"line": old_no, "text": line[1:]}) + old_no += 1 + elif line.startswith(" ") or line == "": + hunk["lines"].append({"tag": " ", "new_lineno": new_no, "old_lineno": old_no, "text": line[1:]}) + old_no += 1 + new_no += 1 + # "\ No newline at end of file" markers are intentionally ignored. + return files + + +def summarize(files): + """Return a compact summary dict for a parsed diff.""" + return { + "files_changed": len(files), + "additions": sum(len(f["added_lines"]) for f in files), + "deletions": sum(len(f["removed_lines"]) for f in files), + "files": [f["path"] or f["old_path"] for f in files], + } diff --git a/examples/skills_code_review_agent/skills/code-review/scripts/parse_diff.py b/examples/skills_code_review_agent/skills/code-review/scripts/parse_diff.py new file mode 100644 index 00000000..3600b7fb --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/scripts/parse_diff.py @@ -0,0 +1,24 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""CLI: parse a unified diff file and print a JSON summary to stdout.""" +import json +import sys + +from diffparse import parse_unified_diff, summarize + + +def main(): + if len(sys.argv) < 2: + print(json.dumps({"error": "usage: parse_diff.py "})) + return 2 + with open(sys.argv[1], encoding="utf-8", errors="replace") as fh: + files = parse_unified_diff(fh.read()) + print(json.dumps({"summary": summarize(files), "files": files})) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/examples/skills_code_review_agent/tests/conftest.py b/examples/skills_code_review_agent/tests/conftest.py new file mode 100644 index 00000000..9950708d --- /dev/null +++ b/examples/skills_code_review_agent/tests/conftest.py @@ -0,0 +1,15 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Test path setup for the skills_code_review_agent example.""" +import sys +from pathlib import Path + +EXAMPLE_ROOT = Path(__file__).resolve().parents[1] +SCRIPTS_DIR = EXAMPLE_ROOT / "skills" / "code-review" / "scripts" + +for p in (str(EXAMPLE_ROOT), str(SCRIPTS_DIR)): + if p not in sys.path: + sys.path.insert(0, p) diff --git a/examples/skills_code_review_agent/tests/test_diffparse.py b/examples/skills_code_review_agent/tests/test_diffparse.py new file mode 100644 index 00000000..bda8fa2a --- /dev/null +++ b/examples/skills_code_review_agent/tests/test_diffparse.py @@ -0,0 +1,50 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Tests for the unified diff parser.""" +import json +import subprocess +import sys +from pathlib import Path + +EXAMPLE_ROOT = Path(__file__).resolve().parents[1] +FIXTURES = EXAMPLE_ROOT / "fixtures" +SCRIPTS = EXAMPLE_ROOT / "skills" / "code-review" / "scripts" + + +def _clean_text(): + return (FIXTURES / "clean.diff").read_text(encoding="utf-8") + + +def test_parse_files_and_paths(): + from diffparse import parse_unified_diff + files = parse_unified_diff(_clean_text()) + assert [f["path"] for f in files] == ["app/util.py", "tests/test_util.py"] + + +def test_added_line_numbers(): + from diffparse import parse_unified_diff + files = parse_unified_diff(_clean_text()) + added = files[0]["added_lines"] + assert added[0]["line"] == 2 + assert "add two numbers" in added[0]["text"] + assert added[1]["line"] == 3 + + +def test_summarize(): + from diffparse import parse_unified_diff, summarize + s = summarize(parse_unified_diff(_clean_text())) + assert s["files_changed"] == 2 + assert s["additions"] == 3 + assert s["deletions"] == 0 + + +def test_parse_diff_cli(): + out = subprocess.run( + [sys.executable, str(SCRIPTS / "parse_diff.py"), str(FIXTURES / "clean.diff")], + capture_output=True, text=True, check=True) + payload = json.loads(out.stdout) + assert payload["summary"]["files_changed"] == 2 + assert payload["files"][0]["path"] == "app/util.py" From e5dc1e2696bfe3e9ae8cbab10b74da286a7a99c4 Mon Sep 17 00:00:00 2001 From: DNKYr Date: Thu, 16 Jul 2026 12:06:06 +0800 Subject: [PATCH 02/22] feat(examples): add shared secret patterns and host-side redaction --- .../review/__init__.py | 6 ++ .../review/redaction.py | 28 ++++++++++ .../code-review/scripts/secret_patterns.py | 44 +++++++++++++++ .../tests/test_redaction.py | 56 +++++++++++++++++++ 4 files changed, 134 insertions(+) create mode 100644 examples/skills_code_review_agent/review/__init__.py create mode 100644 examples/skills_code_review_agent/review/redaction.py create mode 100644 examples/skills_code_review_agent/skills/code-review/scripts/secret_patterns.py create mode 100644 examples/skills_code_review_agent/tests/test_redaction.py diff --git a/examples/skills_code_review_agent/review/__init__.py b/examples/skills_code_review_agent/review/__init__.py new file mode 100644 index 00000000..3e5d5e04 --- /dev/null +++ b/examples/skills_code_review_agent/review/__init__.py @@ -0,0 +1,6 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Code review pipeline package.""" diff --git a/examples/skills_code_review_agent/review/redaction.py b/examples/skills_code_review_agent/review/redaction.py new file mode 100644 index 00000000..0a900992 --- /dev/null +++ b/examples/skills_code_review_agent/review/redaction.py @@ -0,0 +1,28 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Host-side redaction. Imports the skill's secret_patterns module so the +sandbox checker and host redaction always share one pattern list.""" +import importlib.util +from pathlib import Path + +_PATTERNS_PATH = (Path(__file__).resolve().parents[1] + / "skills" / "code-review" / "scripts" / "secret_patterns.py") + +_spec = importlib.util.spec_from_file_location("cr_secret_patterns", _PATTERNS_PATH) +_mod = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_mod) + + +def redact_text(text): + """Redact all secrets in *text* using the shared skill patterns.""" + if not text: + return text + return _mod.redact(text) + + +def contains_secret(text): + """Return True when *text* contains at least one secret match.""" + return bool(_mod.find_secrets(text or "")) diff --git a/examples/skills_code_review_agent/skills/code-review/scripts/secret_patterns.py b/examples/skills_code_review_agent/skills/code-review/scripts/secret_patterns.py new file mode 100644 index 00000000..72155f1b --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/scripts/secret_patterns.py @@ -0,0 +1,44 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Secret detection + redaction patterns. Single source of truth for +check_secrets.py (sandbox side) and review/redaction.py (host side).""" +import hashlib +import re + +SECRET_PATTERNS = [ + ("anthropic_key", re.compile(r"sk-ant-[A-Za-z0-9\-]{10,}")), + ("openai_key", re.compile(r"sk-[A-Za-z0-9]{20,}")), + ("aws_access_key", re.compile(r"AKIA[0-9A-Z]{16}")), + ("github_token", re.compile(r"gh[pousr]_[A-Za-z0-9]{20,}")), + ("slack_token", re.compile(r"xox[baprs]-[A-Za-z0-9\-]{10,}")), + ("jwt", re.compile(r"eyJ[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}(?:\.[A-Za-z0-9_\-]{4,})?")), + ("private_key", re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----")), + ("bearer_token", re.compile(r"[Bb]earer\s+[A-Za-z0-9._\-]{12,}")), + ("url_basic_auth", re.compile(r"[a-z][a-z0-9+.\-]*://[^/\s:@]+:[^@\s]+@")), + ("sensitive_assign", re.compile( + r"(?i)(password|passwd|secret|token|api_key|apikey|secret_token|db_password)" + r"\s*[:=]\s*[\"'][^\"']{6,}[\"']")), +] + + +def find_secrets(text): + """Return the list of raw secret substrings found in *text*.""" + found = [] + for _, pattern in SECRET_PATTERNS: + for m in pattern.finditer(text): + found.append(m.group(0)) + return found + + +def _fingerprint(value): + return hashlib.sha256(value.encode("utf-8")).hexdigest()[:8] + + +def redact(text): + """Replace every secret match with ***REDACTED-*** (stable per value).""" + for _, pattern in SECRET_PATTERNS: + text = pattern.sub(lambda m: "***REDACTED-%s***" % _fingerprint(m.group(0)), text) + return text diff --git a/examples/skills_code_review_agent/tests/test_redaction.py b/examples/skills_code_review_agent/tests/test_redaction.py new file mode 100644 index 00000000..9deb3bfb --- /dev/null +++ b/examples/skills_code_review_agent/tests/test_redaction.py @@ -0,0 +1,56 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Tests for secret detection and redaction (>=95% detection target).""" +SAMPLE_SECRETS = [ + 'API_KEY = "sk-abcdefghijklmnopqrstuvwxyz123456"', + 'key = "sk-ant-api03-abcdefghijklmnopqrst"', + "aws AKIAIOSFODNN7EXAMPLE", + 'token = "ghp_abcdefghijklmnopqrstuvwxyz0123456789"', + "Authorization: Bearer abc123def456ghi789jkl", + "jwt eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.abc123def456", + "-----BEGIN RSA PRIVATE KEY-----", + 'password = "hunter2secret"', + 'PASSWD: "topsecretvalue"', + 'secret = "do-not-leak-me"', + 'api_key="deadbeefcafe1234"', + "https://user:p4ssw0rd@db.example.com/prod", + 'TOKEN = "9f8e7d6c5b4a39281706f5e4d3c2b1a0"', + 'apikey: "0123456789abcdefffff"', + "Bearer xoxb-1234567890-abcdefghij", + 'DB_PASSWORD = "prod-db-pass-2026"', + 'passwd = "another$ecret!"', + "AKIA0123456789ABCDEF", + 'secret_token = "veryhiddenvalue1"', + 'ghs_abcdefghijklmnopqrstuvwxyz0123456789', +] + + +def test_detection_rate_at_least_95_percent(): + from review.redaction import contains_secret + detected = sum(1 for s in SAMPLE_SECRETS if contains_secret(s)) + assert detected / len(SAMPLE_SECRETS) >= 0.95 + + +def test_redaction_removes_secret_values(): + from review.redaction import redact_text + for sample in SAMPLE_SECRETS: + out = redact_text(sample) + assert "***REDACTED-" in out or sample == out, sample + assert "hunter2secret" not in redact_text('password = "hunter2secret"') + assert "AKIAIOSFODNN7EXAMPLE" not in redact_text("aws AKIAIOSFODNN7EXAMPLE") + + +def test_redaction_is_stable_fingerprint(): + from review.redaction import redact_text + a = redact_text("Bearer abc123def456ghi789jkl") + b = redact_text("Bearer abc123def456ghi789jkl") + assert a == b + + +def test_plain_text_untouched(): + from review.redaction import redact_text + text = "def add(a, b):\n return a + b" + assert redact_text(text) == text From 7eda4e1a1c0c7526793340fb078009655076b104 Mon Sep 17 00:00:00 2001 From: DNKYr Date: Thu, 16 Jul 2026 12:16:44 +0800 Subject: [PATCH 03/22] feat(examples): add code-review checker scripts, rule docs, and diff fixtures --- .../fixtures/async_leak.diff | 14 ++++ .../fixtures/db_lifecycle.diff | 15 ++++ .../fixtures/duplicate_finding.diff | 12 +++ .../fixtures/missing_test.diff | 8 ++ .../fixtures/sandbox_failure.diff | 12 +++ .../fixtures/secret_leak.diff | 14 ++++ .../fixtures/security_eval.diff | 14 ++++ .../references/rules/async_resource_leak.md | 22 ++++++ .../references/rules/db_lifecycle.md | 22 ++++++ .../references/rules/missing_test.md | 24 ++++++ .../references/rules/secret_leak.md | 30 ++++++++ .../code-review/references/rules/security.md | 21 ++++++ .../code-review/scripts/check_async_leak.py | 48 ++++++++++++ .../code-review/scripts/check_db_lifecycle.py | 52 +++++++++++++ .../code-review/scripts/check_secrets.py | 29 ++++++++ .../code-review/scripts/check_security.py | 44 +++++++++++ .../scripts/check_tests_missing.py | 40 ++++++++++ .../skills/code-review/scripts/checklib.py | 48 ++++++++++++ .../tests/test_checkers.py | 73 +++++++++++++++++++ 19 files changed, 542 insertions(+) create mode 100644 examples/skills_code_review_agent/fixtures/async_leak.diff create mode 100644 examples/skills_code_review_agent/fixtures/db_lifecycle.diff create mode 100644 examples/skills_code_review_agent/fixtures/duplicate_finding.diff create mode 100644 examples/skills_code_review_agent/fixtures/missing_test.diff create mode 100644 examples/skills_code_review_agent/fixtures/sandbox_failure.diff create mode 100644 examples/skills_code_review_agent/fixtures/secret_leak.diff create mode 100644 examples/skills_code_review_agent/fixtures/security_eval.diff create mode 100644 examples/skills_code_review_agent/skills/code-review/references/rules/async_resource_leak.md create mode 100644 examples/skills_code_review_agent/skills/code-review/references/rules/db_lifecycle.md create mode 100644 examples/skills_code_review_agent/skills/code-review/references/rules/missing_test.md create mode 100644 examples/skills_code_review_agent/skills/code-review/references/rules/secret_leak.md create mode 100644 examples/skills_code_review_agent/skills/code-review/references/rules/security.md create mode 100644 examples/skills_code_review_agent/skills/code-review/scripts/check_async_leak.py create mode 100644 examples/skills_code_review_agent/skills/code-review/scripts/check_db_lifecycle.py create mode 100644 examples/skills_code_review_agent/skills/code-review/scripts/check_secrets.py create mode 100644 examples/skills_code_review_agent/skills/code-review/scripts/check_security.py create mode 100644 examples/skills_code_review_agent/skills/code-review/scripts/check_tests_missing.py create mode 100644 examples/skills_code_review_agent/skills/code-review/scripts/checklib.py create mode 100644 examples/skills_code_review_agent/tests/test_checkers.py diff --git a/examples/skills_code_review_agent/fixtures/async_leak.diff b/examples/skills_code_review_agent/fixtures/async_leak.diff new file mode 100644 index 00000000..21889f87 --- /dev/null +++ b/examples/skills_code_review_agent/fixtures/async_leak.diff @@ -0,0 +1,14 @@ +--- a/app/worker.py ++++ b/app/worker.py +@@ -5,6 +5,9 @@ async def start(): + config = load_config() ++ session = aiohttp.ClientSession() ++ asyncio.create_task(poll_updates()) ++ f = open("data.txt") + return config +--- a/tests/test_worker.py ++++ b/tests/test_worker.py +@@ -1,2 +1,3 @@ + def test_start(): ++ assert True + pass diff --git a/examples/skills_code_review_agent/fixtures/db_lifecycle.diff b/examples/skills_code_review_agent/fixtures/db_lifecycle.diff new file mode 100644 index 00000000..d2e319fe --- /dev/null +++ b/examples/skills_code_review_agent/fixtures/db_lifecycle.diff @@ -0,0 +1,15 @@ +--- a/app/repository.py ++++ b/app/repository.py +@@ -8,6 +8,10 @@ def reset_balances(): + logger.info("resetting balances") ++ conn = sqlite3.connect("app.db") ++ cur = conn.cursor() ++ cur.execute("UPDATE accounts SET balance = 0") ++ conn.commit() + return True +--- a/tests/test_repository.py ++++ b/tests/test_repository.py +@@ -1,2 +1,3 @@ + def test_reset(): ++ assert True + pass diff --git a/examples/skills_code_review_agent/fixtures/duplicate_finding.diff b/examples/skills_code_review_agent/fixtures/duplicate_finding.diff new file mode 100644 index 00000000..502bd22f --- /dev/null +++ b/examples/skills_code_review_agent/fixtures/duplicate_finding.diff @@ -0,0 +1,12 @@ +--- a/app/dynamic.py ++++ b/app/dynamic.py +@@ -3,5 +3,6 @@ def run_snippet(snippet, use_eval): + logger.debug("running snippet") ++ result = eval(snippet) if use_eval else exec(snippet) + return result +--- a/tests/test_dynamic.py ++++ b/tests/test_dynamic.py +@@ -1,2 +1,3 @@ + def test_run(): ++ assert True + pass diff --git a/examples/skills_code_review_agent/fixtures/missing_test.diff b/examples/skills_code_review_agent/fixtures/missing_test.diff new file mode 100644 index 00000000..f93f6963 --- /dev/null +++ b/examples/skills_code_review_agent/fixtures/missing_test.diff @@ -0,0 +1,8 @@ +--- a/app/service.py ++++ b/app/service.py +@@ -5,6 +5,9 @@ def process(order): + validate(order) ++ if order.total < 0: ++ raise ValueError("negative total") ++ order.status = "processed" + return order diff --git a/examples/skills_code_review_agent/fixtures/sandbox_failure.diff b/examples/skills_code_review_agent/fixtures/sandbox_failure.diff new file mode 100644 index 00000000..153f7803 --- /dev/null +++ b/examples/skills_code_review_agent/fixtures/sandbox_failure.diff @@ -0,0 +1,12 @@ +--- a/app/misc.py ++++ b/app/misc.py +@@ -1,3 +1,4 @@ + def greet(name): ++ # trivial change used by the sandbox-failure scenario + return "hello " + name +--- a/tests/test_misc.py ++++ b/tests/test_misc.py +@@ -1,2 +1,3 @@ + def test_greet(): ++ assert True + pass diff --git a/examples/skills_code_review_agent/fixtures/secret_leak.diff b/examples/skills_code_review_agent/fixtures/secret_leak.diff new file mode 100644 index 00000000..af9cd3bc --- /dev/null +++ b/examples/skills_code_review_agent/fixtures/secret_leak.diff @@ -0,0 +1,14 @@ +--- a/app/settings.py ++++ b/app/settings.py +@@ -1,4 +1,7 @@ + APP_NAME = "demo" ++API_KEY = "sk-fakefakefakefakefakefake123456" ++DB_PASSWORD = "prod-db-pass-2026" ++aws_key = "AKIA0123456789ABCDEF" + DEBUG = False +--- a/tests/test_settings.py ++++ b/tests/test_settings.py +@@ -1,2 +1,3 @@ + def test_settings(): ++ assert True + pass diff --git a/examples/skills_code_review_agent/fixtures/security_eval.diff b/examples/skills_code_review_agent/fixtures/security_eval.diff new file mode 100644 index 00000000..0eeebd21 --- /dev/null +++ b/examples/skills_code_review_agent/fixtures/security_eval.diff @@ -0,0 +1,14 @@ +--- a/app/handler.py ++++ b/app/handler.py +@@ -10,6 +10,9 @@ def handle(request): + data = request.get("data", "") ++ result = eval(data) ++ subprocess.run(cmd, shell=True) ++ row = cursor.execute(f"SELECT * FROM users WHERE name = '{name}'") + return result +--- a/tests/test_handler.py ++++ b/tests/test_handler.py +@@ -1,2 +1,3 @@ + def test_handle(): ++ assert True + pass diff --git a/examples/skills_code_review_agent/skills/code-review/references/rules/async_resource_leak.md b/examples/skills_code_review_agent/skills/code-review/references/rules/async_resource_leak.md new file mode 100644 index 00000000..1239d2be --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/references/rules/async_resource_leak.md @@ -0,0 +1,22 @@ +# Async Resource Leak Rule + +Detects resource management issues in async Python code. + +## Patterns + +| Pattern | Severity | Confidence | Guidance | +|---------|----------|------------|----------| +| `aiohttp.ClientSession()` without `async with` | high | 0.8 | Use `async with aiohttp.ClientSession() as session:` or ensure session.close() is awaited. | +| `asyncio.create_task()` without storing reference | medium | 0.7 | Store the task and await/cancel it; unreferenced tasks may be garbage collected mid-flight. | +| `open()` assigned without `with` context manager | medium | 0.7 | Use `with open(...) as f:` so the handle is always closed. | + +## Rationale + +Resources created inside async functions must be properly managed. Leaked sessions, +fire-and-forget tasks, and unclosed file handles can cause connection pool exhaustion, +memory leaks, and unpredictable behavior under load. + +## Remediation + +Wrap resources in context managers (`async with` / `with`) or ensure explicit cleanup +in `finally` blocks. diff --git a/examples/skills_code_review_agent/skills/code-review/references/rules/db_lifecycle.md b/examples/skills_code_review_agent/skills/code-review/references/rules/db_lifecycle.md new file mode 100644 index 00000000..d6ec462b --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/references/rules/db_lifecycle.md @@ -0,0 +1,22 @@ +# Database Lifecycle Rule + +Detects database connections, cursors, and transactions opened without proper lifecycle management. + +## Patterns + +| Pattern | Severity | Confidence | Guidance | +|---------|----------|------------|----------| +| `.connect()` without context manager or close() | high | 0.8 | Use `with ...connect(...) as conn:` or close the connection in a finally block. | +| `.cursor()` without context manager | low | 0.5 | Close the cursor or use a context manager. | +| `.commit()` without rollback handling | medium | 0.65 | Wrap the transaction in try/except and roll back on failure. | + +## Rationale + +Unclosed database connections leak file descriptors and connection pool slots. +Transactions committed without rollback handling can leave data in an inconsistent +state on error. + +## Remediation + +- Use context managers (`with conn:` / `with conn.cursor()`) wherever possible. +- Always wrap transactions in `try/except` and call `rollback()` on failure. diff --git a/examples/skills_code_review_agent/skills/code-review/references/rules/missing_test.md b/examples/skills_code_review_agent/skills/code-review/references/rules/missing_test.md new file mode 100644 index 00000000..08935e1b --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/references/rules/missing_test.md @@ -0,0 +1,24 @@ +# Missing Test Rule + +Detects source code changes that lack corresponding test file changes. + +## Trigger + +Fires when a diff contains added lines in `.py` source files but no changes in: +- Files under `tests/` directories +- Files whose name starts with `test_` or ends with `_test.py` + +## Findings + +| Condition | Severity | Confidence | Guidance | +|-----------|----------|------------|----------| +| Source .py changed, no test files touched | medium | 0.8 | Add or update unit tests covering the changed behavior. | + +## Rationale + +Every behavior change should include or reference a test. Without test coverage, +regressions go undetected. + +## Remediation + +Add or update unit tests that exercise the changed code paths. diff --git a/examples/skills_code_review_agent/skills/code-review/references/rules/secret_leak.md b/examples/skills_code_review_agent/skills/code-review/references/rules/secret_leak.md new file mode 100644 index 00000000..ab3c22dc --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/references/rules/secret_leak.md @@ -0,0 +1,30 @@ +# Secret Leak Rule + +Detects hardcoded secrets (API keys, passwords, AWS keys) in source code. + +## Detection + +Uses the `secret_patterns` module which checks for: +- OpenAI-style keys (`sk-*`) +- Generic API key assignments +- Password assignments +- AWS access key patterns (`AKIA*`) +- Private key headers (`-----BEGIN ... PRIVATE KEY-----`) + +## Findings + +| Condition | Severity | Confidence | Guidance | +|-----------|----------|------------|----------| +| Any recognized secret pattern in added source line | critical | 0.95 | Remove the secret, rotate it, and load it from environment variables or a secret manager. | + +## Evidence Redaction + +All evidence strings are redacted before output; raw secrets never appear in findings. +The evidence field will contain `***REDACTED-{type}` markers. + +## Remediation + +1. Remove the secret from the source immediately. +2. Rotate (revoke and reissue) the exposed credential. +3. Load secrets at runtime from environment variables, a `.env` file (never committed), + or a secrets manager. diff --git a/examples/skills_code_review_agent/skills/code-review/references/rules/security.md b/examples/skills_code_review_agent/skills/code-review/references/rules/security.md new file mode 100644 index 00000000..f6e6652f --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/references/rules/security.md @@ -0,0 +1,21 @@ +# Security Rule + +Detects dangerous patterns in added Python source lines. + +## Patterns + +| Pattern | Severity | Confidence | Guidance | +|---------|----------|------------|----------| +| `eval()` | high | 0.9 | Avoid eval(); use ast.literal_eval or explicit parsing. | +| `exec()` | high | 0.9 | Avoid exec(); restructure to avoid dynamic execution. | +| `shell=True` | high | 0.85 | Pass an argument list and shell=False to avoid shell injection. | +| `pickle.load/loads()` | high | 0.8 | Never unpickle untrusted input; use JSON or a safe serializer. | +| `yaml.load()` without SafeLoader | medium | 0.75 | Use yaml.safe_load or pass Loader=yaml.SafeLoader. | +| SQL via f-string: `.execute(f"...")` | high | 0.85 | Use parameterized queries (placeholders) instead of string interpolation. | +| SQL via concatenation: `.execute("..." + ...` | high | 0.8 | Use parameterized queries (placeholders) instead of concatenation. | +| `os.system()` | medium | 0.7 | Use subprocess.run with an argument list. | + +## Remediation + +Each finding includes a specific recommendation. All security findings should be resolved +before merging; high-severity findings must not be deferred. diff --git a/examples/skills_code_review_agent/skills/code-review/scripts/check_async_leak.py b/examples/skills_code_review_agent/skills/code-review/scripts/check_async_leak.py new file mode 100644 index 00000000..143b78a8 --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/scripts/check_async_leak.py @@ -0,0 +1,48 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Async/resource leak checker for added lines.""" +import re +import sys + +from checklib import emit, finding, iter_added_py_lines, load_files + +_SESSION_RE = re.compile(r"aiohttp\.ClientSession\s*\(") +_CREATE_TASK_RE = re.compile(r"^\s*asyncio\.create_task\s*\(") +_OPEN_ASSIGN_RE = re.compile(r"^\s*\w+\s*=\s*open\s*\(") + + +def main(): + files = load_files(sys.argv) + findings = [] + for path, line_no, text in iter_added_py_lines(files): + if _SESSION_RE.search(text) and "async with" not in text: + findings.append(finding( + "high", "async_resource_leak", path, line_no, + "ClientSession created without async context manager", + evidence=text.strip(), + recommendation="Use 'async with aiohttp.ClientSession() as session:' " + "or ensure session.close() is awaited.", + confidence=0.8)) + if _CREATE_TASK_RE.match(text): + findings.append(finding( + "medium", "async_resource_leak", path, line_no, + "Fire-and-forget asyncio.create_task without keeping a reference", + evidence=text.strip(), + recommendation="Store the task and await/cancel it; unreferenced " + "tasks may be garbage collected mid-flight.", + confidence=0.7)) + if _OPEN_ASSIGN_RE.match(text) and "with " not in text: + findings.append(finding( + "medium", "async_resource_leak", path, line_no, + "File opened without context manager", + evidence=text.strip(), + recommendation="Use 'with open(...) as f:' so the handle is always closed.", + confidence=0.7)) + emit(findings) + + +if __name__ == "__main__": + main() diff --git a/examples/skills_code_review_agent/skills/code-review/scripts/check_db_lifecycle.py b/examples/skills_code_review_agent/skills/code-review/scripts/check_db_lifecycle.py new file mode 100644 index 00000000..e1a707b3 --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/scripts/check_db_lifecycle.py @@ -0,0 +1,52 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Database connection / transaction lifecycle checker.""" +import re +import sys + +from checklib import emit, finding, iter_added_py_lines, load_files + +_CONNECT_RE = re.compile(r"^\s*\w+\s*=\s*\w[\w.]*\.connect\s*\(") +_CURSOR_RE = re.compile(r"^\s*\w+\s*=\s*\w+\.cursor\s*\(") +_COMMIT_RE = re.compile(r"\.commit\s*\(") + + +def main(): + files = load_files(sys.argv) + findings = [] + added_text_by_file = {} + for path, line_no, text in iter_added_py_lines(files): + added_text_by_file.setdefault(path, []).append(text) + if _CONNECT_RE.match(text) and "with " not in text: + findings.append(finding( + "high", "db_lifecycle", path, line_no, + "Connection opened without context manager or close()", + evidence=text.strip(), + recommendation="Use 'with ...connect(...) as conn:' or close the " + "connection in a finally block.", + confidence=0.8)) + if _CURSOR_RE.match(text) and "with " not in text: + findings.append(finding( + "low", "db_lifecycle", path, line_no, + "Cursor created without explicit lifecycle management", + evidence=text.strip(), + recommendation="Close the cursor or use a context manager.", + confidence=0.5)) + for path, line_no, text in iter_added_py_lines(files): + if _COMMIT_RE.search(text): + all_added = "\n".join(added_text_by_file.get(path, [])) + if "rollback" not in all_added: + findings.append(finding( + "medium", "db_lifecycle", path, line_no, + "commit() without visible rollback/error handling", + evidence=text.strip(), + recommendation="Wrap the transaction in try/except and roll back on failure.", + confidence=0.65)) + emit(findings) + + +if __name__ == "__main__": + main() diff --git a/examples/skills_code_review_agent/skills/code-review/scripts/check_secrets.py b/examples/skills_code_review_agent/skills/code-review/scripts/check_secrets.py new file mode 100644 index 00000000..ef660afa --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/scripts/check_secrets.py @@ -0,0 +1,29 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Hardcoded-secret checker. Evidence is redacted before it leaves this script.""" +import sys + +from checklib import emit, finding, iter_added_py_lines, load_files +from secret_patterns import find_secrets, redact + + +def main(): + files = load_files(sys.argv) + findings = [] + for path, line_no, text in iter_added_py_lines(files): + if find_secrets(text): + findings.append(finding( + "critical", "secret_leak", path, line_no, + "Hardcoded secret committed in source", + evidence=redact(text.strip()), + recommendation="Remove the secret, rotate it, and load it from " + "environment variables or a secret manager.", + confidence=0.95)) + emit(findings) + + +if __name__ == "__main__": + main() diff --git a/examples/skills_code_review_agent/skills/code-review/scripts/check_security.py b/examples/skills_code_review_agent/skills/code-review/scripts/check_security.py new file mode 100644 index 00000000..8f863453 --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/scripts/check_security.py @@ -0,0 +1,44 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Security checker: eval/exec, shell=True, pickle, unsafe yaml.load, SQL injection.""" +import re +import sys + +from checklib import emit, finding, iter_added_py_lines, load_files + +RULES = [ + (re.compile(r"\beval\s*\("), "high", "Use of eval() on dynamic data", + "Avoid eval(); use ast.literal_eval or explicit parsing.", 0.9), + (re.compile(r"\bexec\s*\("), "high", "Use of exec() on dynamic data", + "Avoid exec(); restructure the code to avoid dynamic execution.", 0.9), + (re.compile(r"shell\s*=\s*True"), "high", "subprocess with shell=True", + "Pass an argument list and shell=False to avoid shell injection.", 0.85), + (re.compile(r"pickle\.loads?\s*\("), "high", "Unpickling untrusted data", + "Never unpickle untrusted input; use JSON or a safe serializer.", 0.8), + (re.compile(r"yaml\.load\s*\((?!.*SafeLoader)"), "medium", "yaml.load without SafeLoader", + "Use yaml.safe_load or pass Loader=yaml.SafeLoader.", 0.75), + (re.compile(r"\.execute\s*\(\s*f[\"']"), "high", "SQL built with f-string (possible injection)", + "Use parameterized queries (placeholders) instead of string interpolation.", 0.85), + (re.compile(r"\.execute\s*\([^,)]*[\"']\s*\+"), "high", "SQL built with string concatenation", + "Use parameterized queries (placeholders) instead of concatenation.", 0.8), + (re.compile(r"os\.system\s*\("), "medium", "Use of os.system", + "Use subprocess.run with an argument list.", 0.7), +] + + +def main(): + files = load_files(sys.argv) + findings = [] + for path, line_no, text in iter_added_py_lines(files): + for pattern, severity, title, rec, conf in RULES: + if pattern.search(text): + findings.append(finding(severity, "security", path, line_no, title, + evidence=text.strip(), recommendation=rec, confidence=conf)) + emit(findings) + + +if __name__ == "__main__": + main() diff --git a/examples/skills_code_review_agent/skills/code-review/scripts/check_tests_missing.py b/examples/skills_code_review_agent/skills/code-review/scripts/check_tests_missing.py new file mode 100644 index 00000000..b30340bd --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/scripts/check_tests_missing.py @@ -0,0 +1,40 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Missing-test checker: source .py files changed but no test files changed.""" +import sys + +from checklib import emit, finding, load_files + + +def _is_test_path(path): + name = path.rsplit("/", 1)[-1] + return (path.startswith("tests/") or "/tests/" in path + or name.startswith("test_") or name.endswith("_test.py")) + + +def main(): + files = load_files(sys.argv) + changed_paths = [f["path"] or f["old_path"] for f in files] + tests_changed = any(_is_test_path(p) for p in changed_paths) + findings = [] + if not tests_changed: + for f in files: + path = f["path"] or f["old_path"] + if not path.endswith(".py") or _is_test_path(path): + continue + if not f["added_lines"]: + continue + findings.append(finding( + "medium", "missing_test", path, f["added_lines"][0]["line"], + "Source change without accompanying test change", + evidence="%d added line(s), no test files in this diff" % len(f["added_lines"]), + recommendation="Add or update unit tests covering the changed behavior.", + confidence=0.8)) + emit(findings) + + +if __name__ == "__main__": + main() diff --git a/examples/skills_code_review_agent/skills/code-review/scripts/checklib.py b/examples/skills_code_review_agent/skills/code-review/scripts/checklib.py new file mode 100644 index 00000000..7f3123cc --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/scripts/checklib.py @@ -0,0 +1,48 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Shared helpers for checker scripts.""" +import json + +from diffparse import parse_unified_diff + + +def load_files(argv): + """Parse the diff file given as argv[1]; exit(2) on usage error.""" + if len(argv) < 2: + print(json.dumps({"error": "usage: "})) + raise SystemExit(2) + with open(argv[1], encoding="utf-8", errors="replace") as fh: + return parse_unified_diff(fh.read()) + + +def finding(severity, category, file, line, title, evidence="", recommendation="", confidence=0.9): + """Build one finding dict (source is always 'static' for scripts).""" + return { + "severity": severity, + "category": category, + "file": file, + "line": line, + "title": title, + "evidence": evidence, + "recommendation": recommendation, + "confidence": confidence, + "source": "static", + } + + +def emit(findings): + """Print the findings JSON contract to stdout.""" + print(json.dumps({"findings": findings})) + + +def iter_added_py_lines(files): + """Yield (path, line_no, text) for added lines in Python files.""" + for f in files: + path = f["path"] or f["old_path"] + if not path.endswith(".py"): + continue + for added in f["added_lines"]: + yield path, added["line"], added["text"] diff --git a/examples/skills_code_review_agent/tests/test_checkers.py b/examples/skills_code_review_agent/tests/test_checkers.py new file mode 100644 index 00000000..56c425b2 --- /dev/null +++ b/examples/skills_code_review_agent/tests/test_checkers.py @@ -0,0 +1,73 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Tests for the sandbox checker scripts (run host-side as plain subprocesses).""" +import json +import subprocess +import sys +from pathlib import Path + +EXAMPLE_ROOT = Path(__file__).resolve().parents[1] +FIXTURES = EXAMPLE_ROOT / "fixtures" +SCRIPTS = EXAMPLE_ROOT / "skills" / "code-review" / "scripts" + + +def run_checker(script, fixture): + out = subprocess.run( + [sys.executable, str(SCRIPTS / script), str(FIXTURES / fixture)], + capture_output=True, text=True, check=True) + return json.loads(out.stdout)["findings"] + + +def test_security_checker_detects_eval_shell_sql(): + findings = run_checker("check_security.py", "security_eval.diff") + titles = " | ".join(f["title"] for f in findings) + assert any(f["category"] == "security" for f in findings) + assert "eval" in titles.lower() + assert any(f["line"] == 11 for f in findings) + assert all(f["source"] == "static" for f in findings) + + +def test_security_checker_clean_diff_is_quiet(): + assert run_checker("check_security.py", "clean.diff") == [] + + +def test_async_leak_checker(): + findings = run_checker("check_async_leak.py", "async_leak.diff") + assert any(f["category"] == "async_resource_leak" for f in findings) + assert len(findings) >= 2 + + +def test_db_lifecycle_checker(): + findings = run_checker("check_db_lifecycle.py", "db_lifecycle.diff") + assert any(f["category"] == "db_lifecycle" for f in findings) + assert any("connect" in f["title"].lower() for f in findings) + + +def test_tests_missing_checker_fires_without_test_changes(): + findings = run_checker("check_tests_missing.py", "missing_test.diff") + assert len(findings) == 1 + assert findings[0]["category"] == "missing_test" + assert findings[0]["file"] == "app/service.py" + + +def test_tests_missing_checker_quiet_when_tests_changed(): + assert run_checker("check_tests_missing.py", "clean.diff") == [] + + +def test_secrets_checker_redacts_evidence(): + findings = run_checker("check_secrets.py", "secret_leak.diff") + assert any(f["category"] == "secret_leak" for f in findings) + for f in findings: + assert "sk-fakefakefakefakefakefake123456" not in f["evidence"] + assert "AKIA0123456789ABCDEF" not in f["evidence"] + assert "***REDACTED-" in f["evidence"] + + +def test_duplicate_fixture_produces_two_security_findings_same_line(): + findings = run_checker("check_security.py", "duplicate_finding.diff") + keys = [(f["file"], f["line"], f["category"]) for f in findings] + assert len(keys) == 2 + assert keys[0] == keys[1] From c88112a953b0873cee44221d512796928287a40f Mon Sep 17 00:00:00 2001 From: DNKYr Date: Thu, 16 Jul 2026 12:23:17 +0800 Subject: [PATCH 04/22] feat(examples): add finding model with dedup and confidence gating --- .../review/findings.py | 68 +++++++++++++++++++ .../tests/test_findings.py | 45 ++++++++++++ 2 files changed, 113 insertions(+) create mode 100644 examples/skills_code_review_agent/review/findings.py create mode 100644 examples/skills_code_review_agent/tests/test_findings.py diff --git a/examples/skills_code_review_agent/review/findings.py b/examples/skills_code_review_agent/review/findings.py new file mode 100644 index 00000000..2a609756 --- /dev/null +++ b/examples/skills_code_review_agent/review/findings.py @@ -0,0 +1,68 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Finding model plus dedup and confidence-gating helpers.""" +from pydantic import BaseModel + +SEVERITY_ORDER = {"critical": 4, "high": 3, "medium": 2, "low": 1, "info": 0} +CONFIDENCE_THRESHOLD = 0.6 + + +class Finding(BaseModel): + """One structured review finding.""" + + severity: str + category: str + file: str + line: int + title: str + evidence: str = "" + recommendation: str = "" + confidence: float = 1.0 + source: str = "static" + + @property + def dedup_key(self) -> str: + return f"{self.file}:{self.line}:{self.category}" + + +def _merge(winner: Finding, loser: Finding) -> Finding: + update = {} + if SEVERITY_ORDER.get(loser.severity, 0) > SEVERITY_ORDER.get(winner.severity, 0): + update["severity"] = loser.severity + if loser.confidence > winner.confidence: + update["confidence"] = loser.confidence + if loser.source != winner.source: + update["source"] = "static+llm" + return winner.model_copy(update=update) if update else winner + + +def dedupe(findings): + """Collapse findings sharing (file, line, category). Returns (kept, dropped).""" + kept: dict[str, Finding] = {} + dropped = [] + for f in findings: + cur = kept.get(f.dedup_key) + if cur is None: + kept[f.dedup_key] = f + else: + kept[f.dedup_key] = _merge(cur, f) + dropped.append(f) + return list(kept.values()), dropped + + +def gate(findings, threshold: float = CONFIDENCE_THRESHOLD): + """Split into (reported, needs_human_review) by confidence.""" + reported = [f for f in findings if f.confidence >= threshold] + needs = [f for f in findings if f.confidence < threshold] + return reported, needs + + +def severity_distribution(findings): + """Count findings per severity.""" + dist: dict[str, int] = {} + for f in findings: + dist[f.severity] = dist.get(f.severity, 0) + 1 + return dist diff --git a/examples/skills_code_review_agent/tests/test_findings.py b/examples/skills_code_review_agent/tests/test_findings.py new file mode 100644 index 00000000..62266007 --- /dev/null +++ b/examples/skills_code_review_agent/tests/test_findings.py @@ -0,0 +1,45 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Tests for the Finding model, dedup and confidence gating.""" +from review.findings import Finding, dedupe, gate, severity_distribution + + +def make(severity="high", category="security", file="a.py", line=1, + confidence=0.9, source="static", title="t"): + return Finding(severity=severity, category=category, file=file, line=line, + title=title, confidence=confidence, source=source) + + +def test_dedup_same_file_line_category(): + kept, dropped = dedupe([make(title="eval"), make(title="exec", confidence=0.8)]) + assert len(kept) == 1 and len(dropped) == 1 + assert kept[0].confidence == 0.9 + + +def test_dedup_merges_sources(): + kept, _ = dedupe([make(source="static"), make(source="llm", confidence=0.7)]) + assert kept[0].source == "static+llm" + + +def test_dedup_keeps_highest_severity(): + kept, _ = dedupe([make(severity="medium"), make(severity="critical", confidence=0.5)]) + assert kept[0].severity == "critical" + + +def test_no_dedup_across_lines(): + kept, dropped = dedupe([make(line=1), make(line=2)]) + assert len(kept) == 2 and dropped == [] + + +def test_gate_splits_low_confidence(): + reported, needs = gate([make(confidence=0.9), make(line=2, confidence=0.5)]) + assert len(reported) == 1 and len(needs) == 1 + assert needs[0].confidence == 0.5 + + +def test_severity_distribution(): + dist = severity_distribution([make(), make(line=2, severity="low")]) + assert dist == {"high": 1, "low": 1} From 0fe2071251fa070da605f5603f8ee2a249c647dc Mon Sep 17 00:00:00 2001 From: DNKYr Date: Thu, 16 Jul 2026 12:28:03 +0800 Subject: [PATCH 05/22] fix(examples): add typed signatures to finding helpers --- examples/skills_code_review_agent/review/findings.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/skills_code_review_agent/review/findings.py b/examples/skills_code_review_agent/review/findings.py index 2a609756..2f8050e8 100644 --- a/examples/skills_code_review_agent/review/findings.py +++ b/examples/skills_code_review_agent/review/findings.py @@ -39,7 +39,7 @@ def _merge(winner: Finding, loser: Finding) -> Finding: return winner.model_copy(update=update) if update else winner -def dedupe(findings): +def dedupe(findings: list[Finding]) -> tuple[list[Finding], list[Finding]]: """Collapse findings sharing (file, line, category). Returns (kept, dropped).""" kept: dict[str, Finding] = {} dropped = [] @@ -53,14 +53,14 @@ def dedupe(findings): return list(kept.values()), dropped -def gate(findings, threshold: float = CONFIDENCE_THRESHOLD): +def gate(findings: list[Finding], threshold: float = CONFIDENCE_THRESHOLD) -> tuple[list[Finding], list[Finding]]: """Split into (reported, needs_human_review) by confidence.""" reported = [f for f in findings if f.confidence >= threshold] needs = [f for f in findings if f.confidence < threshold] return reported, needs -def severity_distribution(findings): +def severity_distribution(findings: list[Finding]) -> dict[str, int]: """Count findings per severity.""" dist: dict[str, int] = {} for f in findings: From 3ebd0f9b7915318a459f110cc68e9c6735000c3a Mon Sep 17 00:00:00 2001 From: DNKYr Date: Thu, 16 Jul 2026 12:34:18 +0800 Subject: [PATCH 06/22] feat(examples): add code-review SQL schema and ReviewStore --- .../storage/__init__.py | 6 + .../storage/models.py | 104 +++++++++++++++ .../skills_code_review_agent/storage/store.py | 124 ++++++++++++++++++ .../tests/test_store.py | 73 +++++++++++ 4 files changed, 307 insertions(+) create mode 100644 examples/skills_code_review_agent/storage/__init__.py create mode 100644 examples/skills_code_review_agent/storage/models.py create mode 100644 examples/skills_code_review_agent/storage/store.py create mode 100644 examples/skills_code_review_agent/tests/test_store.py diff --git a/examples/skills_code_review_agent/storage/__init__.py b/examples/skills_code_review_agent/storage/__init__.py new file mode 100644 index 00000000..fe78e593 --- /dev/null +++ b/examples/skills_code_review_agent/storage/__init__.py @@ -0,0 +1,6 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Persistence layer for the code-review example.""" diff --git a/examples/skills_code_review_agent/storage/models.py b/examples/skills_code_review_agent/storage/models.py new file mode 100644 index 00000000..698eb7f6 --- /dev/null +++ b/examples/skills_code_review_agent/storage/models.py @@ -0,0 +1,104 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""SQLAlchemy declarative models for the code-review example.""" +import uuid +from datetime import datetime + +from sqlalchemy import JSON, Boolean, DateTime, Float, ForeignKey, Integer, String, Text +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + +def _uuid() -> str: + return uuid.uuid4().hex + + +class CrBase(DeclarativeBase): + """Dedicated base so only this example's tables are created.""" + + +class ReviewTaskRow(CrBase): + __tablename__ = "cr_review_tasks" + + id: Mapped[str] = mapped_column(String(64), primary_key=True, default=_uuid) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now) + finished_at: Mapped[datetime] = mapped_column(DateTime, nullable=True) + status: Mapped[str] = mapped_column(String(32), default="pending") + input_type: Mapped[str] = mapped_column(String(32), default="") + input_ref: Mapped[str] = mapped_column(String(512), default="") + runtime: Mapped[str] = mapped_column(String(32), default="local") + dry_run: Mapped[bool] = mapped_column(Boolean, default=True) + diff_summary: Mapped[dict] = mapped_column(JSON, nullable=True) + + +class SandboxRunRow(CrBase): + __tablename__ = "cr_sandbox_runs" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + task_id: Mapped[str] = mapped_column(String(64), ForeignKey("cr_review_tasks.id")) + script: Mapped[str] = mapped_column(String(128), default="") + category: Mapped[str] = mapped_column(String(64), default="") + status: Mapped[str] = mapped_column(String(32), default="") + exit_code: Mapped[int] = mapped_column(Integer, default=0) + duration_ms: Mapped[int] = mapped_column(Integer, default=0) + timed_out: Mapped[bool] = mapped_column(Boolean, default=False) + stdout_summary: Mapped[str] = mapped_column(Text, default="") + stderr_summary: Mapped[str] = mapped_column(Text, default="") + error_type: Mapped[str] = mapped_column(String(64), default="") + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now) + + +class FindingRow(CrBase): + __tablename__ = "cr_findings" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + task_id: Mapped[str] = mapped_column(String(64), ForeignKey("cr_review_tasks.id")) + severity: Mapped[str] = mapped_column(String(16), default="") + category: Mapped[str] = mapped_column(String(64), default="") + file: Mapped[str] = mapped_column(String(512), default="") + line: Mapped[int] = mapped_column(Integer, default=0) + title: Mapped[str] = mapped_column(String(256), default="") + evidence: Mapped[str] = mapped_column(Text, default="") + recommendation: Mapped[str] = mapped_column(Text, default="") + confidence: Mapped[float] = mapped_column(Float, default=1.0) + source: Mapped[str] = mapped_column(String(32), default="static") + status: Mapped[str] = mapped_column(String(32), default="reported") + dedup_key: Mapped[str] = mapped_column(String(640), default="") + + +class FilterEventRow(CrBase): + __tablename__ = "cr_filter_events" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + task_id: Mapped[str] = mapped_column(String(64), ForeignKey("cr_review_tasks.id")) + target: Mapped[str] = mapped_column(String(512), default="") + decision: Mapped[str] = mapped_column(String(32), default="") + rule: Mapped[str] = mapped_column(String(64), default="") + reason: Mapped[str] = mapped_column(Text, default="") + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now) + + +class MetricsRow(CrBase): + __tablename__ = "cr_metrics" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + task_id: Mapped[str] = mapped_column(String(64), ForeignKey("cr_review_tasks.id")) + total_duration_ms: Mapped[int] = mapped_column(Integer, default=0) + sandbox_duration_ms: Mapped[int] = mapped_column(Integer, default=0) + tool_calls: Mapped[int] = mapped_column(Integer, default=0) + intercepts: Mapped[int] = mapped_column(Integer, default=0) + findings_total: Mapped[int] = mapped_column(Integer, default=0) + severity_distribution: Mapped[dict] = mapped_column(JSON, nullable=True) + error_distribution: Mapped[dict] = mapped_column(JSON, nullable=True) + + +class ReportRow(CrBase): + __tablename__ = "cr_reports" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + task_id: Mapped[str] = mapped_column(String(64), ForeignKey("cr_review_tasks.id")) + report_json: Mapped[dict] = mapped_column(JSON, nullable=True) + report_md: Mapped[str] = mapped_column(Text, default="") + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now) diff --git a/examples/skills_code_review_agent/storage/store.py b/examples/skills_code_review_agent/storage/store.py new file mode 100644 index 00000000..9e5ad596 --- /dev/null +++ b/examples/skills_code_review_agent/storage/store.py @@ -0,0 +1,124 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""ReviewStore: persistence built on the SDK's SqlStorage (SQLite by default, +any SQLAlchemy db_url works).""" +from datetime import datetime + +from trpc_agent_sdk.storage import SqlCondition, SqlKey, SqlStorage + +from review.redaction import redact_text + +from .models import (CrBase, FilterEventRow, FindingRow, MetricsRow, ReportRow, + ReviewTaskRow, SandboxRunRow) + + +def _row_to_dict(row): + if row is None: + return None + return {c.name: getattr(row, c.name) for c in row.__table__.columns} + + +class ReviewStore: + """CRUD facade over the code-review tables. All write paths redact secrets.""" + + def __init__(self, db_url: str = "sqlite:///code_review.db"): + self._storage = SqlStorage(is_async=False, db_url=db_url, metadata=CrBase.metadata) + + async def _add(self, row) -> None: + async with self._storage.create_db_session() as db: + await self._storage.add(db, row) + await self._storage.commit(db) + + async def create_task(self, input_type: str, input_ref: str, runtime: str, + dry_run: bool) -> str: + row = ReviewTaskRow(input_type=input_type, input_ref=input_ref, + runtime=runtime, dry_run=dry_run, status="running") + task_id = row.id or "" + if not task_id: + import uuid + task_id = uuid.uuid4().hex + row.id = task_id + await self._add(row) + return task_id + + async def update_task(self, task_id: str, status: str = None, + diff_summary: dict = None, finished: bool = False) -> None: + async with self._storage.create_db_session() as db: + row = await self._storage.get(db, SqlKey(key=(task_id,), storage_cls=ReviewTaskRow)) + if row is None: + return + if status is not None: + row.status = status + if diff_summary is not None: + row.diff_summary = diff_summary + if finished: + row.finished_at = datetime.now() + await self._storage.commit(db) + + async def add_sandbox_run(self, task_id: str, *, script: str, category: str, + status: str, exit_code: int, duration_ms: int, + timed_out: bool, stdout_summary: str, + stderr_summary: str, error_type: str) -> None: + await self._add(SandboxRunRow( + task_id=task_id, script=script, category=category, status=status, + exit_code=exit_code, duration_ms=duration_ms, timed_out=timed_out, + stdout_summary=redact_text(stdout_summary), + stderr_summary=redact_text(stderr_summary), error_type=error_type)) + + async def add_findings(self, task_id: str, findings, status: str) -> None: + for f in findings: + await self._add(FindingRow( + task_id=task_id, severity=f.severity, category=f.category, + file=f.file, line=f.line, title=f.title, + evidence=redact_text(f.evidence), recommendation=f.recommendation, + confidence=f.confidence, source=f.source, status=status, + dedup_key=f.dedup_key)) + + async def add_filter_event(self, task_id: str, target: str, decision: str, + rule: str, reason: str) -> None: + await self._add(FilterEventRow(task_id=task_id, target=redact_text(target), + decision=decision, rule=rule, reason=reason)) + + async def add_metrics(self, task_id: str, metrics: dict) -> None: + await self._add(MetricsRow( + task_id=task_id, + total_duration_ms=metrics.get("total_duration_ms", 0), + sandbox_duration_ms=metrics.get("sandbox_duration_ms", 0), + tool_calls=metrics.get("tool_calls", 0), + intercepts=metrics.get("intercepts", 0), + findings_total=metrics.get("findings_total", 0), + severity_distribution=metrics.get("severity_distribution", {}), + error_distribution=metrics.get("error_distribution", {}))) + + async def add_report(self, task_id: str, report_json: dict, report_md: str) -> None: + await self._add(ReportRow(task_id=task_id, report_json=report_json, + report_md=redact_text(report_md))) + + async def _query_all(self, db, storage_cls, task_id): + rows = await self._storage.query( + db, SqlKey(key=(), storage_cls=storage_cls), + SqlCondition(filters=[storage_cls.task_id == task_id])) + return [_row_to_dict(r) for r in rows] + + async def get_task_bundle(self, task_id: str) -> dict: + async with self._storage.create_db_session() as db: + task = await self._storage.get(db, SqlKey(key=(task_id,), storage_cls=ReviewTaskRow)) + runs = await self._query_all(db, SandboxRunRow, task_id) + findings = await self._query_all(db, FindingRow, task_id) + events = await self._query_all(db, FilterEventRow, task_id) + metrics_rows = await self._query_all(db, MetricsRow, task_id) + report_rows = await self._query_all(db, ReportRow, task_id) + return { + "task": _row_to_dict(task), + "sandbox_runs": runs, + "findings": findings, + "filter_events": events, + "metrics": metrics_rows[0] if metrics_rows else None, + "report": report_rows[0] if report_rows else None, + } + + async def close(self) -> None: + await self._storage.close() diff --git a/examples/skills_code_review_agent/tests/test_store.py b/examples/skills_code_review_agent/tests/test_store.py new file mode 100644 index 00000000..5fc710e6 --- /dev/null +++ b/examples/skills_code_review_agent/tests/test_store.py @@ -0,0 +1,73 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Tests for the SQLite-backed ReviewStore.""" +from review.findings import Finding +from storage.store import ReviewStore + + +async def _new_store(tmp_path): + return ReviewStore(db_url=f"sqlite:///{tmp_path}/cr.db") + + +async def test_task_roundtrip(tmp_path): + store = await _new_store(tmp_path) + task_id = await store.create_task("fixture", "clean.diff", "local", True) + assert task_id + await store.update_task(task_id, status="completed", + diff_summary={"files_changed": 1}, finished=True) + bundle = await store.get_task_bundle(task_id) + assert bundle["task"]["status"] == "completed" + assert bundle["task"]["diff_summary"]["files_changed"] == 1 + assert bundle["task"]["dry_run"] is True + await store.close() + + +async def test_bundle_collects_all_tables(tmp_path): + store = await _new_store(tmp_path) + task_id = await store.create_task("fixture", "x.diff", "local", True) + await store.add_sandbox_run(task_id, script="check_security.py", category="security", + status="ok", exit_code=0, duration_ms=12, timed_out=False, + stdout_summary="{}", stderr_summary="", error_type="") + await store.add_findings(task_id, [Finding( + severity="critical", category="secret_leak", file="a.py", line=3, + title="secret", evidence='key = "sk-abcdefghijklmnopqrstuvwxyz123456"', + confidence=0.95)], status="reported") + await store.add_filter_event(task_id, "rm -rf /", "deny", "risk_command", "destructive command") + await store.add_metrics(task_id, {"total_duration_ms": 1000, "sandbox_duration_ms": 500, + "tool_calls": 6, "intercepts": 1, "findings_total": 1, + "severity_distribution": {"critical": 1}, + "error_distribution": {}}) + await store.add_report(task_id, {"conclusion": "blocked"}, "# report") + bundle = await store.get_task_bundle(task_id) + assert len(bundle["sandbox_runs"]) == 1 + assert len(bundle["findings"]) == 1 + assert len(bundle["filter_events"]) == 1 + assert bundle["metrics"]["tool_calls"] == 6 + assert bundle["report"]["report_json"]["conclusion"] == "blocked" + await store.close() + + +async def test_store_redacts_evidence_and_output(tmp_path): + store = await _new_store(tmp_path) + task_id = await store.create_task("fixture", "x.diff", "local", True) + secret = "sk-abcdefghijklmnopqrstuvwxyz123456" + await store.add_findings(task_id, [Finding( + severity="critical", category="secret_leak", file="a.py", line=3, + title="secret", evidence=f'key = "{secret}"', confidence=0.95)], status="reported") + await store.add_sandbox_run(task_id, script="s.py", category="c", status="ok", + exit_code=0, duration_ms=1, timed_out=False, + stdout_summary=f"leaked {secret}", stderr_summary="", error_type="") + bundle = await store.get_task_bundle(task_id) + assert secret not in bundle["findings"][0]["evidence"] + assert secret not in bundle["sandbox_runs"][0]["stdout_summary"] + await store.close() + + +async def test_unknown_task_returns_none_task(tmp_path): + store = await _new_store(tmp_path) + bundle = await store.get_task_bundle("nope") + assert bundle["task"] is None + await store.close() From 4d3ef6844de9867416f8e33b8ef84b37127dbfd3 Mon Sep 17 00:00:00 2001 From: DNKYr Date: Thu, 16 Jul 2026 12:39:19 +0800 Subject: [PATCH 07/22] fix(examples): tidy store imports and cover all redaction write paths in tests --- .../skills_code_review_agent/storage/store.py | 2 +- .../tests/test_store.py | 19 +++++++++++++------ 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/examples/skills_code_review_agent/storage/store.py b/examples/skills_code_review_agent/storage/store.py index 9e5ad596..78d76a9c 100644 --- a/examples/skills_code_review_agent/storage/store.py +++ b/examples/skills_code_review_agent/storage/store.py @@ -5,6 +5,7 @@ # tRPC-Agent-Python is licensed under Apache-2.0. """ReviewStore: persistence built on the SDK's SqlStorage (SQLite by default, any SQLAlchemy db_url works).""" +import uuid from datetime import datetime from trpc_agent_sdk.storage import SqlCondition, SqlKey, SqlStorage @@ -38,7 +39,6 @@ async def create_task(self, input_type: str, input_ref: str, runtime: str, runtime=runtime, dry_run=dry_run, status="running") task_id = row.id or "" if not task_id: - import uuid task_id = uuid.uuid4().hex row.id = task_id await self._add(row) diff --git a/examples/skills_code_review_agent/tests/test_store.py b/examples/skills_code_review_agent/tests/test_store.py index 5fc710e6..058bb28d 100644 --- a/examples/skills_code_review_agent/tests/test_store.py +++ b/examples/skills_code_review_agent/tests/test_store.py @@ -8,12 +8,12 @@ from storage.store import ReviewStore -async def _new_store(tmp_path): +def _new_store(tmp_path): return ReviewStore(db_url=f"sqlite:///{tmp_path}/cr.db") async def test_task_roundtrip(tmp_path): - store = await _new_store(tmp_path) + store = _new_store(tmp_path) task_id = await store.create_task("fixture", "clean.diff", "local", True) assert task_id await store.update_task(task_id, status="completed", @@ -26,7 +26,7 @@ async def test_task_roundtrip(tmp_path): async def test_bundle_collects_all_tables(tmp_path): - store = await _new_store(tmp_path) + store = _new_store(tmp_path) task_id = await store.create_task("fixture", "x.diff", "local", True) await store.add_sandbox_run(task_id, script="check_security.py", category="security", status="ok", exit_code=0, duration_ms=12, timed_out=False, @@ -51,7 +51,7 @@ async def test_bundle_collects_all_tables(tmp_path): async def test_store_redacts_evidence_and_output(tmp_path): - store = await _new_store(tmp_path) + store = _new_store(tmp_path) task_id = await store.create_task("fixture", "x.diff", "local", True) secret = "sk-abcdefghijklmnopqrstuvwxyz123456" await store.add_findings(task_id, [Finding( @@ -59,15 +59,22 @@ async def test_store_redacts_evidence_and_output(tmp_path): title="secret", evidence=f'key = "{secret}"', confidence=0.95)], status="reported") await store.add_sandbox_run(task_id, script="s.py", category="c", status="ok", exit_code=0, duration_ms=1, timed_out=False, - stdout_summary=f"leaked {secret}", stderr_summary="", error_type="") + stdout_summary=f"leaked {secret}", + stderr_summary=f"stderr leaked {secret}", error_type="") + await store.add_filter_event(task_id, target=f"/path/{secret}/config", + decision="deny", rule="risk_secret", reason="file contains secret") + await store.add_report(task_id, {"conclusion": "blocked"}, f"# Report\nSecret was: {secret}") bundle = await store.get_task_bundle(task_id) assert secret not in bundle["findings"][0]["evidence"] assert secret not in bundle["sandbox_runs"][0]["stdout_summary"] + assert secret not in bundle["sandbox_runs"][0]["stderr_summary"] + assert secret not in bundle["filter_events"][0]["target"] + assert secret not in bundle["report"]["report_md"] await store.close() async def test_unknown_task_returns_none_task(tmp_path): - store = await _new_store(tmp_path) + store = _new_store(tmp_path) bundle = await store.get_task_bundle("nope") assert bundle["task"] is None await store.close() From 73f448c917a518c33acac755a66d2eb284475692 Mon Sep 17 00:00:00 2001 From: DNKYr Date: Thu, 16 Jul 2026 12:45:41 +0800 Subject: [PATCH 08/22] feat(examples): add governance engine and skill_run tool filter --- .../filters_cr/__init__.py | 6 ++ .../filters_cr/governance_filter.py | 38 ++++++++ .../review/governance.py | 87 +++++++++++++++++++ .../tests/test_governance.py | 78 +++++++++++++++++ 4 files changed, 209 insertions(+) create mode 100644 examples/skills_code_review_agent/filters_cr/__init__.py create mode 100644 examples/skills_code_review_agent/filters_cr/governance_filter.py create mode 100644 examples/skills_code_review_agent/review/governance.py create mode 100644 examples/skills_code_review_agent/tests/test_governance.py diff --git a/examples/skills_code_review_agent/filters_cr/__init__.py b/examples/skills_code_review_agent/filters_cr/__init__.py new file mode 100644 index 00000000..de8eac1c --- /dev/null +++ b/examples/skills_code_review_agent/filters_cr/__init__.py @@ -0,0 +1,6 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Code review tool filters package.""" diff --git a/examples/skills_code_review_agent/filters_cr/governance_filter.py b/examples/skills_code_review_agent/filters_cr/governance_filter.py new file mode 100644 index 00000000..d6da9745 --- /dev/null +++ b/examples/skills_code_review_agent/filters_cr/governance_filter.py @@ -0,0 +1,38 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Tool-level filter guarding LLM-initiated skill_run calls.""" +from trpc_agent_sdk.abc import FilterType +from trpc_agent_sdk.filter import BaseFilter + +from review.governance import GovernanceEngine + + +class GovernanceToolFilter(BaseFilter): + """Blocks skill_run commands the GovernanceEngine does not allow.""" + + def __init__(self, engine: GovernanceEngine, on_event=None): + super().__init__() + self._type = FilterType.TOOL + self._name = "cr_governance" + self._engine = engine + self._on_event = on_event + + async def _before(self, ctx, req, rsp): + command = "" + if isinstance(req, dict): + command = str(req.get("command", "") or "") + if not command: + return + decision = self._engine.check_command(command) + if self._on_event is not None: + self._on_event(decision) + if decision.decision != "allow": + rsp.rsp = { + "error": f"blocked by governance filter ({decision.rule})", + "decision": decision.decision, + "reason": decision.reason, + } + rsp.is_continue = False diff --git a/examples/skills_code_review_agent/review/governance.py b/examples/skills_code_review_agent/review/governance.py new file mode 100644 index 00000000..36d61e0e --- /dev/null +++ b/examples/skills_code_review_agent/review/governance.py @@ -0,0 +1,87 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Governance policy: script allowlist, forbidden paths, network policy, +risk heuristics and execution budget.""" +import posixpath +from dataclasses import dataclass, field + +DEFAULT_ALLOWED_SCRIPTS = ( + "parse_diff.py", + "check_security.py", + "check_async_leak.py", + "check_db_lifecycle.py", + "check_tests_missing.py", + "check_secrets.py", +) + +NETWORK_TOOLS = frozenset({"curl", "wget", "pip", "pip3", "ssh", "scp", "nc", "git", "apt", "apt-get"}) +RISK_TOKENS = frozenset({"sudo", "docker", "chmod", "chown", "mount", "rm", "mkfs"}) +PYTHON_EXECUTABLES = frozenset({"python", "python3"}) + + +@dataclass +class GovernanceDecision: + """Outcome of one governance check.""" + + target: str + decision: str # allow | deny | needs_human_review + rule: str = "" + reason: str = "" + + +@dataclass +class GovernanceEngine: + """Stateful policy engine; deny/needs_human_review must never reach the sandbox.""" + + allowed_scripts: tuple = DEFAULT_ALLOWED_SCRIPTS + max_runs: int = 20 + max_sandbox_seconds: float = 300.0 + _runs: int = field(default=0, init=False) + _elapsed: float = field(default=0.0, init=False) + + def record_run(self, duration_s: float) -> None: + self._runs += 1 + self._elapsed += max(0.0, duration_s) + + def _check_budget(self, target: str): + if self._runs >= self.max_runs or self._elapsed >= self.max_sandbox_seconds: + return GovernanceDecision(target, "deny", "budget_exceeded", + f"budget: {self._runs} runs / {self._elapsed:.1f}s used") + return None + + @staticmethod + def _check_paths(target: str, argv): + for arg in argv: + if arg.startswith("/") or arg.startswith("~") or ".." in arg: + return GovernanceDecision(target, "deny", "forbidden_path", + f"path escapes workspace: {arg!r}") + return None + + def check_script(self, script: str, argv) -> GovernanceDecision: + target = f"{script} {' '.join(argv)}".strip() + blocked = self._check_budget(target) or self._check_paths(target, argv) + if blocked: + return blocked + if posixpath.basename(script) not in self.allowed_scripts: + return GovernanceDecision(target, "deny", "script_allowlist", + f"script {script!r} is not allowlisted") + return GovernanceDecision(target, "allow") + + def check_command(self, command: str) -> GovernanceDecision: + tokens = command.split() + if not tokens: + return GovernanceDecision(command, "deny", "empty_command", "empty command") + head = posixpath.basename(tokens[0]) + if head in NETWORK_TOOLS or any(posixpath.basename(t) in NETWORK_TOOLS for t in tokens): + return GovernanceDecision(command, "deny", "network_policy", + "non-whitelisted network access") + if head in RISK_TOKENS or any(posixpath.basename(t) in RISK_TOKENS for t in tokens): + return GovernanceDecision(command, "needs_human_review", "risk_command", + "high-risk command requires human review") + if head in PYTHON_EXECUTABLES and len(tokens) >= 2: + return self.check_script(tokens[1], tokens[2:]) + return GovernanceDecision(command, "needs_human_review", "unknown_command", + f"executable {head!r} is not recognized") diff --git a/examples/skills_code_review_agent/tests/test_governance.py b/examples/skills_code_review_agent/tests/test_governance.py new file mode 100644 index 00000000..502e96d7 --- /dev/null +++ b/examples/skills_code_review_agent/tests/test_governance.py @@ -0,0 +1,78 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Tests for governance decisions and the tool filter.""" +from trpc_agent_sdk.abc import FilterResult + +from review.governance import GovernanceEngine +from filters_cr.governance_filter import GovernanceToolFilter + + +def test_allowlisted_script_allowed(): + eng = GovernanceEngine() + d = eng.check_script("check_security.py", ["work/inputs/changes.diff"]) + assert d.decision == "allow" + + +def test_unknown_script_denied(): + d = GovernanceEngine().check_script("steal_data.py", ["work/inputs/changes.diff"]) + assert d.decision == "deny" + assert d.rule == "script_allowlist" + + +def test_forbidden_path_denied(): + eng = GovernanceEngine() + assert eng.check_script("check_security.py", ["/etc/passwd"]).decision == "deny" + assert eng.check_script("check_security.py", ["../../secrets"]).decision == "deny" + + +def test_budget_exceeded_denied(): + eng = GovernanceEngine(max_runs=1) + eng.record_run(0.1) + d = eng.check_script("check_security.py", ["work/inputs/changes.diff"]) + assert d.decision == "deny" + assert d.rule == "budget_exceeded" + + +def test_command_network_tool_denied(): + d = GovernanceEngine().check_command("curl http://evil.example.com") + assert d.decision == "deny" + assert d.rule == "network_policy" + + +def test_command_risk_token_needs_review(): + d = GovernanceEngine().check_command("sudo rm -rf /") + assert d.decision == "needs_human_review" + + +def test_command_allowlisted_python_script_allowed(): + d = GovernanceEngine().check_command( + "python3 skills/code-review/scripts/check_security.py work/inputs/changes.diff") + assert d.decision == "allow" + + +def test_command_unknown_executable_needs_review(): + d = GovernanceEngine().check_command("make all") + assert d.decision == "needs_human_review" + + +async def test_filter_blocks_denied_command(): + events = [] + filt = GovernanceToolFilter(GovernanceEngine(), on_event=events.append) + rsp = FilterResult() + await filt._before(None, {"skill": "code-review", "command": "curl http://x"}, rsp) + assert rsp.is_continue is False + assert "blocked" in str(rsp.rsp) + assert events and events[0].decision == "deny" + + +async def test_filter_allows_good_command(): + filt = GovernanceToolFilter(GovernanceEngine()) + rsp = FilterResult() + await filt._before(None, { + "skill": "code-review", + "command": "python3 skills/code-review/scripts/parse_diff.py work/inputs/changes.diff", + }, rsp) + assert rsp.is_continue is True From 5797a7d88de2f1029d70454cffa3216953fd2313 Mon Sep 17 00:00:00 2001 From: DNKYr Date: Thu, 16 Jul 2026 12:55:41 +0800 Subject: [PATCH 09/22] feat(examples): add sandbox session with env whitelist, timeout and output caps --- .../review/sandbox.py | 136 ++++++++++++++++++ .../tests/test_sandbox.py | 90 ++++++++++++ 2 files changed, 226 insertions(+) create mode 100644 examples/skills_code_review_agent/review/sandbox.py create mode 100644 examples/skills_code_review_agent/tests/test_sandbox.py diff --git a/examples/skills_code_review_agent/review/sandbox.py b/examples/skills_code_review_agent/review/sandbox.py new file mode 100644 index 00000000..bff19bbc --- /dev/null +++ b/examples/skills_code_review_agent/review/sandbox.py @@ -0,0 +1,136 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Sandbox execution of skill scripts via SDK workspace runtimes.""" +import os +import shutil +import sys +from dataclasses import dataclass + +from trpc_agent_sdk.code_executors import (WorkspacePutFileInfo, WorkspaceRunProgramSpec, + WorkspaceStageOptions, + create_container_workspace_runtime, + create_local_workspace_runtime) + +SKILL_WS_DIR = "skills/code-review" +DIFF_WS_PATH = "work/inputs/changes.diff" +_PYTHON3_DIR = os.path.dirname(shutil.which("python3") or sys.executable) +SANDBOX_ENV = {"PATH": f"{_PYTHON3_DIR}:/usr/local/bin:/usr/bin:/bin", + "HOME": "/tmp", "LANG": "C.UTF-8"} + +RUNTIME_LOCAL = "local" +RUNTIME_CONTAINER = "container" +RUNTIME_CUBE = "cube" + + +async def create_runtime(kind: str): + """Create a workspace runtime. Container is the production default; + local is a development-only fallback; cube needs env credentials.""" + if kind == RUNTIME_LOCAL: + return create_local_workspace_runtime() + if kind == RUNTIME_CONTAINER: + return create_container_workspace_runtime() + if kind == RUNTIME_CUBE: + from trpc_agent_sdk.code_executors.cube import (CubeClientConfig, + CubeWorkspaceRuntimeConfig, + create_cube_sandbox_client, + create_cube_workspace_runtime) + if not os.getenv("CUBE_API_KEY") and not os.getenv("E2B_API_KEY"): + raise ValueError("cube runtime requires CUBE_API_KEY/E2B_API_KEY; " + "see examples/skills_with_cube") + cfg = CubeClientConfig( + execute_timeout=float(os.getenv("CUBE_EXECUTE_TIMEOUT", "60")), + idle_timeout=int(os.getenv("CUBE_IDLE_TIMEOUT", "600")), + auto_recover=True) + client = await create_cube_sandbox_client(cfg) + return create_cube_workspace_runtime(sandbox_client=client, + execute_timeout=cfg.execute_timeout, + workspace_cfg=CubeWorkspaceRuntimeConfig()) + raise ValueError(f"unknown runtime {kind!r}; expected local|container|cube") + + +@dataclass +class SandboxRunOutcome: + """Result of one sandboxed script execution.""" + + script: str + status: str # ok | failed | timeout | error + exit_code: int + duration_ms: int + timed_out: bool + stdout: str + stderr: str + error_type: str = "" + truncated: bool = False + + +class SandboxSession: + """One workspace: staged code-review skill + staged diff + script runs.""" + + def __init__(self, runtime, skill_root: str, timeout_sec: float = 60.0, + max_output_bytes: int = 262144): + self._runtime = runtime + self._skill_root = skill_root + self._timeout = timeout_sec + self._max_output = max_output_bytes + self._ws = None + self._exec_id = "" + + async def open(self, exec_id: str) -> None: + self._exec_id = exec_id + manager = self._runtime.manager(None) + self._ws = await manager.create_workspace(exec_id, None) + fs = self._runtime.fs(None) + await fs.stage_directory(self._ws, self._skill_root, SKILL_WS_DIR, + WorkspaceStageOptions(mode="copy"), None) + + async def put_diff(self, diff_text: str) -> None: + fs = self._runtime.fs(None) + await fs.put_files(self._ws, [WorkspacePutFileInfo( + path=DIFF_WS_PATH, content=diff_text.encode("utf-8"), mode=0o644)], None) + + def _truncate(self, text: str): + if len(text.encode("utf-8", errors="replace")) <= self._max_output: + return text, False + return text.encode("utf-8", errors="replace")[:self._max_output].decode( + "utf-8", errors="replace"), True + + async def run_script(self, script_name: str, + args: tuple = (DIFF_WS_PATH,)) -> SandboxRunOutcome: + """Run one skill script under `env -i` (environment whitelist).""" + env_args = [f"{k}={v}" for k, v in SANDBOX_ENV.items()] + argv = ["-i", *env_args, "python3", + f"{SKILL_WS_DIR}/scripts/{script_name}", *args] + spec = WorkspaceRunProgramSpec(cmd="env", args=argv, env={}, cwd=".", + timeout=self._timeout) + try: + ret = await self._runtime.runner(None).run_program(self._ws, spec, None) + except Exception as ex: # noqa: BLE001 - sandbox failure must not crash the review + return SandboxRunOutcome(script=script_name, status="error", exit_code=-1, + duration_ms=0, timed_out=False, stdout="", + stderr=str(ex)[:1024], error_type=type(ex).__name__) + stdout, t1 = self._truncate(ret.stdout or "") + stderr, t2 = self._truncate(ret.stderr or "") + if ret.timed_out: + status = "timeout" + elif ret.exit_code == 0: + status = "ok" + else: + status = "failed" + return SandboxRunOutcome(script=script_name, status=status, + exit_code=ret.exit_code, + duration_ms=int(ret.duration * 1000), + timed_out=ret.timed_out, stdout=stdout, + stderr=stderr, + error_type="timeout" if ret.timed_out else "", + truncated=t1 or t2) + + async def close(self) -> None: + if self._ws is None: + return + try: + await self._runtime.manager(None).cleanup(self._exec_id, None) + except Exception: # noqa: BLE001 - best-effort cleanup + pass diff --git a/examples/skills_code_review_agent/tests/test_sandbox.py b/examples/skills_code_review_agent/tests/test_sandbox.py new file mode 100644 index 00000000..385164a4 --- /dev/null +++ b/examples/skills_code_review_agent/tests/test_sandbox.py @@ -0,0 +1,90 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Tests for SandboxSession using the local workspace runtime.""" +import os +from pathlib import Path + +from review.sandbox import DIFF_WS_PATH, SandboxSession, create_runtime # noqa: F401 + +EXAMPLE_ROOT = Path(__file__).resolve().parents[1] +SKILL_ROOT = EXAMPLE_ROOT / "skills" / "code-review" +FIXTURES = EXAMPLE_ROOT / "fixtures" + + +def _make_tmp_skill(tmp_path, script_name, body): + skill = tmp_path / "code-review" + (skill / "scripts").mkdir(parents=True) + (skill / "SKILL.md").write_text("---\nname: code-review\ndescription: t\n---\nbody\n") + (skill / "scripts" / script_name).write_text(body) + return skill + + +async def _open_session(skill_root, **kw): + runtime = await create_runtime("local") + session = SandboxSession(runtime, str(skill_root), **kw) + await session.open("cr_test_" + os.urandom(4).hex()) + return session + + +async def test_run_parse_diff_in_sandbox(): + session = await _open_session(SKILL_ROOT) + try: + await session.put_diff((FIXTURES / "clean.diff").read_text()) + outcome = await session.run_script("parse_diff.py") + assert outcome.status == "ok", outcome.stderr + assert '"files_changed": 2' in outcome.stdout + finally: + await session.close() + + +async def test_env_whitelist_blocks_host_env(monkeypatch, tmp_path): + monkeypatch.setenv("CR_LEAKED_SECRET", "should-not-be-visible") + skill = _make_tmp_skill(tmp_path, "print_env.py", + "import json, os\nprint(json.dumps(dict(os.environ)))\n") + session = await _open_session(skill) + try: + await session.put_diff("") + outcome = await session.run_script("print_env.py", args=()) + assert outcome.status == "ok", outcome.stderr + assert "CR_LEAKED_SECRET" not in outcome.stdout + finally: + await session.close() + + +async def test_timeout_is_enforced(tmp_path): + skill = _make_tmp_skill(tmp_path, "sleepy.py", "import time\ntime.sleep(30)\n") + session = await _open_session(skill, timeout_sec=2.0) + try: + await session.put_diff("") + outcome = await session.run_script("sleepy.py", args=()) + assert outcome.timed_out is True + assert outcome.status == "timeout" + finally: + await session.close() + + +async def test_output_size_cap(tmp_path): + skill = _make_tmp_skill(tmp_path, "noisy.py", "print('x' * 1000000)\n") + session = await _open_session(skill, max_output_bytes=1024) + try: + await session.put_diff("") + outcome = await session.run_script("noisy.py", args=()) + assert len(outcome.stdout) <= 1024 + assert outcome.truncated is True + finally: + await session.close() + + +async def test_failing_script_reports_failed(tmp_path): + skill = _make_tmp_skill(tmp_path, "broken.py", "import sys\nsys.exit(3)\n") + session = await _open_session(skill) + try: + await session.put_diff("") + outcome = await session.run_script("broken.py", args=()) + assert outcome.status == "failed" + assert outcome.exit_code == 3 + finally: + await session.close() From ec6a74e7393ad9cad7948166307a24aed5156c72 Mon Sep 17 00:00:00 2001 From: DNKYr Date: Thu, 16 Jul 2026 13:02:33 +0800 Subject: [PATCH 10/22] fix(examples): deterministic sandbox env whitelist and reopen guard --- .../review/sandbox.py | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/examples/skills_code_review_agent/review/sandbox.py b/examples/skills_code_review_agent/review/sandbox.py index bff19bbc..5607ce8c 100644 --- a/examples/skills_code_review_agent/review/sandbox.py +++ b/examples/skills_code_review_agent/review/sandbox.py @@ -9,15 +9,15 @@ import sys from dataclasses import dataclass -from trpc_agent_sdk.code_executors import (WorkspacePutFileInfo, WorkspaceRunProgramSpec, +from trpc_agent_sdk.code_executors import (BaseWorkspaceRuntime, WorkspacePutFileInfo, + WorkspaceRunProgramSpec, WorkspaceStageOptions, create_container_workspace_runtime, create_local_workspace_runtime) SKILL_WS_DIR = "skills/code-review" DIFF_WS_PATH = "work/inputs/changes.diff" -_PYTHON3_DIR = os.path.dirname(shutil.which("python3") or sys.executable) -SANDBOX_ENV = {"PATH": f"{_PYTHON3_DIR}:/usr/local/bin:/usr/bin:/bin", +SANDBOX_ENV = {"PATH": "/usr/local/bin:/usr/bin:/bin", "HOME": "/tmp", "LANG": "C.UTF-8"} RUNTIME_LOCAL = "local" @@ -69,7 +69,7 @@ class SandboxRunOutcome: class SandboxSession: """One workspace: staged code-review skill + staged diff + script runs.""" - def __init__(self, runtime, skill_root: str, timeout_sec: float = 60.0, + def __init__(self, runtime: BaseWorkspaceRuntime, skill_root: str, timeout_sec: float = 60.0, max_output_bytes: int = 262144): self._runtime = runtime self._skill_root = skill_root @@ -79,6 +79,8 @@ def __init__(self, runtime, skill_root: str, timeout_sec: float = 60.0, self._exec_id = "" async def open(self, exec_id: str) -> None: + if self._ws is not None: + raise RuntimeError("SandboxSession is already open; call close() first") self._exec_id = exec_id manager = self._runtime.manager(None) self._ws = await manager.create_workspace(exec_id, None) @@ -97,10 +99,21 @@ def _truncate(self, text: str): return text.encode("utf-8", errors="replace")[:self._max_output].decode( "utf-8", errors="replace"), True + def _effective_env(self) -> dict: + """Whitelist stays PATH/HOME/LANG only; host python3 dir is appended as a + dev fallback for hosts without /usr/bin/python3 (e.g. Nix); harmless + nonexistent entry inside containers.""" + env = dict(SANDBOX_ENV) + python3_dir = os.path.dirname(shutil.which("python3") or sys.executable) + if python3_dir and python3_dir not in env["PATH"].split(":"): + env["PATH"] = f"{env['PATH']}:{python3_dir}" + return env + async def run_script(self, script_name: str, args: tuple = (DIFF_WS_PATH,)) -> SandboxRunOutcome: """Run one skill script under `env -i` (environment whitelist).""" - env_args = [f"{k}={v}" for k, v in SANDBOX_ENV.items()] + env = self._effective_env() + env_args = [f"{k}={v}" for k, v in env.items()] argv = ["-i", *env_args, "python3", f"{SKILL_WS_DIR}/scripts/{script_name}", *args] spec = WorkspaceRunProgramSpec(cmd="env", args=argv, env={}, cwd=".", @@ -134,3 +147,4 @@ async def close(self) -> None: await self._runtime.manager(None).cleanup(self._exec_id, None) except Exception: # noqa: BLE001 - best-effort cleanup pass + self._ws = None From e4c02fd5110010c853dbefe2c601f326ed72868b Mon Sep 17 00:00:00 2001 From: DNKYr Date: Thu, 16 Jul 2026 13:08:15 +0800 Subject: [PATCH 11/22] feat(examples): add fake review model, agent wiring and llm review step --- .../agent/__init__.py | 6 ++ .../skills_code_review_agent/agent/agent.py | 31 ++++++++ .../skills_code_review_agent/agent/config.py | 23 ++++++ .../agent/fake_model.py | 34 +++++++++ .../skills_code_review_agent/agent/prompts.py | 30 ++++++++ .../review/llm_review.py | 75 +++++++++++++++++++ .../tests/test_llm_review.py | 51 +++++++++++++ 7 files changed, 250 insertions(+) create mode 100644 examples/skills_code_review_agent/agent/__init__.py create mode 100644 examples/skills_code_review_agent/agent/agent.py create mode 100644 examples/skills_code_review_agent/agent/config.py create mode 100644 examples/skills_code_review_agent/agent/fake_model.py create mode 100644 examples/skills_code_review_agent/agent/prompts.py create mode 100644 examples/skills_code_review_agent/review/llm_review.py create mode 100644 examples/skills_code_review_agent/tests/test_llm_review.py diff --git a/examples/skills_code_review_agent/agent/__init__.py b/examples/skills_code_review_agent/agent/__init__.py new file mode 100644 index 00000000..4faa7cf1 --- /dev/null +++ b/examples/skills_code_review_agent/agent/__init__.py @@ -0,0 +1,6 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Agent wiring — fake model, prompts, config, and LlmAgent factory.""" diff --git a/examples/skills_code_review_agent/agent/agent.py b/examples/skills_code_review_agent/agent/agent.py new file mode 100644 index 00000000..ba81da9e --- /dev/null +++ b/examples/skills_code_review_agent/agent/agent.py @@ -0,0 +1,31 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""LlmAgent wiring for the code review example.""" +from trpc_agent_sdk.agents import LlmAgent +from trpc_agent_sdk.models import OpenAIModel +from trpc_agent_sdk.skills import SkillToolSet + +from .config import get_model_config +from .fake_model import FakeReviewModel +from .prompts import INSTRUCTION + + +def create_review_agent(repository, dry_run: bool, tool_filters: list) -> LlmAgent: + """Create the review agent. tool_filters guard skill_run via governance.""" + if dry_run: + model = FakeReviewModel() + else: + api_key, url, model_name = get_model_config() + model = OpenAIModel(model_name=model_name, api_key=api_key, base_url=url) + toolset = SkillToolSet(repository=repository, filters=list(tool_filters)) + return LlmAgent( + name="code_review_agent", + description="Automated code review agent combining skill scripts and LLM analysis.", + model=model, + instruction=INSTRUCTION, + tools=[toolset], + skill_repository=repository, + ) diff --git a/examples/skills_code_review_agent/agent/config.py b/examples/skills_code_review_agent/agent/config.py new file mode 100644 index 00000000..7044cd85 --- /dev/null +++ b/examples/skills_code_review_agent/agent/config.py @@ -0,0 +1,23 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Model configuration from environment variables.""" +import os + + +def get_model_config() -> tuple[str, str, str]: + """Return (api_key, base_url, model_name) or raise when unset.""" + api_key = os.getenv("TRPC_AGENT_API_KEY", "") + url = os.getenv("TRPC_AGENT_BASE_URL", "") + model_name = os.getenv("TRPC_AGENT_MODEL_NAME", "") + if not api_key or not url or not model_name: + raise ValueError("TRPC_AGENT_API_KEY, TRPC_AGENT_BASE_URL and " + "TRPC_AGENT_MODEL_NAME must be set for real-model mode") + return api_key, url, model_name + + +def is_dry_run(explicit: bool) -> bool: + """Dry-run when requested explicitly or when no API key is configured.""" + return explicit or not os.getenv("TRPC_AGENT_API_KEY", "") diff --git a/examples/skills_code_review_agent/agent/fake_model.py b/examples/skills_code_review_agent/agent/fake_model.py new file mode 100644 index 00000000..690251a1 --- /dev/null +++ b/examples/skills_code_review_agent/agent/fake_model.py @@ -0,0 +1,34 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Deterministic fake model so the full pipeline runs without any API key.""" +import json +from typing import List + +from trpc_agent_sdk.models import LLMModel, LlmResponse +from trpc_agent_sdk.types import Content, Part + +_FAKE_PAYLOAD = { + "summary": "Dry-run review complete. Static findings are authoritative.", + "findings": [], +} + + +class FakeReviewModel(LLMModel): + """LLMModel returning one canned JSON review response.""" + + def __init__(self, model_name: str = "fake-review-model", **kwargs): + super().__init__(model_name=model_name, **kwargs) + + @classmethod + def supported_models(cls) -> List[str]: + return [r"fake-review-.*"] + + def validate_request(self, request) -> None: + return None + + async def _generate_async_impl(self, request, stream=False, ctx=None): + text = json.dumps(_FAKE_PAYLOAD) + yield LlmResponse(content=Content(role="model", parts=[Part.from_text(text=text)])) diff --git a/examples/skills_code_review_agent/agent/prompts.py b/examples/skills_code_review_agent/agent/prompts.py new file mode 100644 index 00000000..17cdfaf3 --- /dev/null +++ b/examples/skills_code_review_agent/agent/prompts.py @@ -0,0 +1,30 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Prompts for the code review agent.""" + +INSTRUCTION = """You are an automated code reviewer. + +You receive a unified diff plus baseline findings produced by static rule +scripts from the code-review skill. Confirm the baseline findings and add any +extra issues you can justify from the diff alone. + +Reply with a single JSON object and nothing else: +{"summary": "", "findings": [{"severity": "critical|high|medium|low|info", +"category": "security|async_resource_leak|db_lifecycle|missing_test|secret_leak", +"file": "", "line": , "title": "", "evidence": "", +"recommendation": "", "confidence": <0.0-1.0>}]} + +Only report issues visible in the diff. Do not repeat baseline findings. +""" + +REVIEW_REQUEST_TEMPLATE = """Review this change. + +Baseline static findings (JSON): +{findings_json} + +Unified diff (secrets already redacted): +{diff} +""" diff --git a/examples/skills_code_review_agent/review/llm_review.py b/examples/skills_code_review_agent/review/llm_review.py new file mode 100644 index 00000000..5c91e6e3 --- /dev/null +++ b/examples/skills_code_review_agent/review/llm_review.py @@ -0,0 +1,75 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""LLM enrichment step: run the agent once over the diff + static findings.""" +import json +import re +import uuid + +from trpc_agent_sdk.runners import Runner +from trpc_agent_sdk.sessions import InMemorySessionService +from trpc_agent_sdk.types import Content, Part + +from agent.prompts import REVIEW_REQUEST_TEMPLATE + +from .findings import Finding +from .redaction import redact_text + +_FENCE_RE = re.compile(r"```(?:json)?\s*(\{.*?\})\s*```", re.DOTALL) + + +def parse_llm_output(text: str): + """Extract (findings_dicts, summary) from model text; ([], "") on failure.""" + candidates = [text.strip()] + fence = _FENCE_RE.search(text) + if fence: + candidates.insert(0, fence.group(1)) + for candidate in candidates: + try: + payload = json.loads(candidate) + except (json.JSONDecodeError, ValueError): + continue + if isinstance(payload, dict): + return list(payload.get("findings") or []), str(payload.get("summary") or "") + return [], "" + + +async def run_llm_review(agent, diff_text: str, static_findings): + """Run one review turn. Returns (llm_findings, summary, warnings).""" + warnings = [] + runner = Runner(app_name="code_review_agent", agent=agent, + session_service=InMemorySessionService()) + prompt = REVIEW_REQUEST_TEMPLATE.format( + findings_json=json.dumps([f.model_dump() for f in static_findings]), + diff=redact_text(diff_text)) + message = Content(role="user", parts=[Part.from_text(text=prompt)]) + final_text = "" + async for event in runner.run_async(user_id="cr_user", + session_id=uuid.uuid4().hex, + new_message=message): + if event.partial or not event.content or not event.content.parts: + continue + for part in event.content.parts: + if getattr(part, "text", None) and not getattr(part, "thought", None): + final_text += part.text + raw_findings, summary = parse_llm_output(final_text) + if not summary and final_text: + warnings.append("llm output was not valid JSON; ignored") + findings = [] + for raw in raw_findings: + try: + findings.append(Finding( + severity=str(raw.get("severity", "info")), + category=str(raw.get("category", "security")), + file=str(raw.get("file", "")), + line=int(raw.get("line", 0)), + title=str(raw.get("title", "")), + evidence=str(raw.get("evidence", "")), + recommendation=str(raw.get("recommendation", "")), + confidence=float(raw.get("confidence", 0.5)), + source="llm")) + except (TypeError, ValueError): + warnings.append(f"skipped malformed llm finding: {raw!r}") + return findings, summary, warnings diff --git a/examples/skills_code_review_agent/tests/test_llm_review.py b/examples/skills_code_review_agent/tests/test_llm_review.py new file mode 100644 index 00000000..22245056 --- /dev/null +++ b/examples/skills_code_review_agent/tests/test_llm_review.py @@ -0,0 +1,51 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Tests for the fake model and the LLM review step.""" +from pathlib import Path + +from trpc_agent_sdk.skills import create_default_skill_repository + +from agent.agent import create_review_agent +from agent.fake_model import FakeReviewModel +from review.findings import Finding +from review.llm_review import parse_llm_output, run_llm_review + +EXAMPLE_ROOT = Path(__file__).resolve().parents[1] +SKILLS_DIR = EXAMPLE_ROOT / "skills" + + +def test_parse_llm_output_plain_json(): + findings, summary = parse_llm_output( + '{"summary": "ok", "findings": [{"severity": "high", "category": "security",' + ' "file": "a.py", "line": 3, "title": "x", "confidence": 0.8}]}') + assert summary == "ok" + assert findings[0]["line"] == 3 + + +def test_parse_llm_output_fenced_json(): + text = 'Here you go:\n```json\n{"summary": "s", "findings": []}\n```\n' + findings, summary = parse_llm_output(text) + assert summary == "s" and findings == [] + + +def test_parse_llm_output_garbage_returns_empty(): + findings, summary = parse_llm_output("I could not review this.") + assert findings == [] and summary == "" + + +def test_fake_model_supported_models(): + assert FakeReviewModel.supported_models() == [r"fake-review-.*"] + + +async def test_run_llm_review_with_fake_model(): + repository = create_default_skill_repository(str(SKILLS_DIR)) + agent = create_review_agent(repository, dry_run=True, tool_filters=[]) + static = [Finding(severity="high", category="security", file="a.py", line=1, + title="eval", confidence=0.9)] + llm_findings, summary, warnings = await run_llm_review(agent, "diff text", static) + assert llm_findings == [] + assert "Dry-run review complete" in summary + assert warnings == [] From 997a5bddcbede6bf4d426c5f58092e6b685ce5f8 Mon Sep 17 00:00:00 2001 From: DNKYr Date: Thu, 16 Jul 2026 13:16:39 +0800 Subject: [PATCH 12/22] fix(examples): robust fenced-JSON parsing and runner cleanup in llm review --- .../review/llm_review.py | 27 ++++++++++--------- .../tests/test_llm_review.py | 8 ++++++ 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/examples/skills_code_review_agent/review/llm_review.py b/examples/skills_code_review_agent/review/llm_review.py index 5c91e6e3..a8ba7ef1 100644 --- a/examples/skills_code_review_agent/review/llm_review.py +++ b/examples/skills_code_review_agent/review/llm_review.py @@ -17,10 +17,10 @@ from .findings import Finding from .redaction import redact_text -_FENCE_RE = re.compile(r"```(?:json)?\s*(\{.*?\})\s*```", re.DOTALL) +_FENCE_RE = re.compile(r"```(?:json)?\s*(\{.*\})\s*```", re.DOTALL) -def parse_llm_output(text: str): +def parse_llm_output(text: str) -> tuple[list[dict], str]: """Extract (findings_dicts, summary) from model text; ([], "") on failure.""" candidates = [text.strip()] fence = _FENCE_RE.search(text) @@ -36,9 +36,9 @@ def parse_llm_output(text: str): return [], "" -async def run_llm_review(agent, diff_text: str, static_findings): +async def run_llm_review(agent, diff_text: str, static_findings) -> tuple[list[Finding], str, list[str]]: """Run one review turn. Returns (llm_findings, summary, warnings).""" - warnings = [] + warnings: list[str] = [] runner = Runner(app_name="code_review_agent", agent=agent, session_service=InMemorySessionService()) prompt = REVIEW_REQUEST_TEMPLATE.format( @@ -46,14 +46,17 @@ async def run_llm_review(agent, diff_text: str, static_findings): diff=redact_text(diff_text)) message = Content(role="user", parts=[Part.from_text(text=prompt)]) final_text = "" - async for event in runner.run_async(user_id="cr_user", - session_id=uuid.uuid4().hex, - new_message=message): - if event.partial or not event.content or not event.content.parts: - continue - for part in event.content.parts: - if getattr(part, "text", None) and not getattr(part, "thought", None): - final_text += part.text + try: + async for event in runner.run_async(user_id="cr_user", + session_id=uuid.uuid4().hex, + new_message=message): + if event.partial or not event.content or not event.content.parts: + continue + for part in event.content.parts: + if getattr(part, "text", None) and not getattr(part, "thought", None): + final_text += part.text + finally: + await runner.close() raw_findings, summary = parse_llm_output(final_text) if not summary and final_text: warnings.append("llm output was not valid JSON; ignored") diff --git a/examples/skills_code_review_agent/tests/test_llm_review.py b/examples/skills_code_review_agent/tests/test_llm_review.py index 22245056..595bfbeb 100644 --- a/examples/skills_code_review_agent/tests/test_llm_review.py +++ b/examples/skills_code_review_agent/tests/test_llm_review.py @@ -31,6 +31,14 @@ def test_parse_llm_output_fenced_json(): assert summary == "s" and findings == [] +def test_parse_llm_output_fenced_nested_json(): + text = ('```json\n{"summary": "s", "findings": [{"severity": "high", "category": "security",' + ' "file": "a.py", "line": 3, "title": "x", "confidence": 0.9}]}\n```') + findings, summary = parse_llm_output(text) + assert summary == "s" + assert findings[0]["severity"] == "high" + + def test_parse_llm_output_garbage_returns_empty(): findings, summary = parse_llm_output("I could not review this.") assert findings == [] and summary == "" From 1e855bb68ddb72da663ca56a179e480855f3ba75 Mon Sep 17 00:00:00 2001 From: DNKYr Date: Thu, 16 Jul 2026 19:35:25 +0800 Subject: [PATCH 13/22] feat(examples): add review report builder and markdown renderer --- .../skills_code_review_agent/review/report.py | 121 ++++++++++++++++++ .../tests/test_report.py | 74 +++++++++++ 2 files changed, 195 insertions(+) create mode 100644 examples/skills_code_review_agent/review/report.py create mode 100644 examples/skills_code_review_agent/tests/test_report.py diff --git a/examples/skills_code_review_agent/review/report.py b/examples/skills_code_review_agent/review/report.py new file mode 100644 index 00000000..8fac3ca9 --- /dev/null +++ b/examples/skills_code_review_agent/review/report.py @@ -0,0 +1,121 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Report assembly and rendering.""" +import json +import os +from datetime import datetime + +from .findings import severity_distribution +from .redaction import redact_text + +_BLOCKING = {"critical", "high"} + + +def _conclusion(reported, filter_events): + if any(f.severity in _BLOCKING for f in reported): + return "blocked" + intercepted = any(e.decision != "allow" for e in filter_events) + if reported or intercepted: + return "needs_attention" + return "pass" + + +def build_report(task_id, input_ref, runtime, dry_run, diff_summary, reported, + needs_review, deduped_count, filter_events, sandbox_outcomes, + metrics, llm_summary, warnings) -> dict: + """Assemble the full report dict (issue acceptance criterion #8).""" + return { + "task_id": task_id, + "generated_at": datetime.now().isoformat(timespec="seconds"), + "input": {"ref": input_ref, "runtime": runtime, "dry_run": dry_run, + "diff_summary": diff_summary}, + "conclusion": _conclusion(reported, filter_events), + "summary": { + "total_findings": len(reported), + "by_severity": severity_distribution(reported), + "needs_human_review_count": len(needs_review), + "deduplicated": deduped_count, + "intercepts": sum(1 for e in filter_events if e.decision != "allow"), + }, + "findings": [f.model_dump() for f in reported], + "needs_human_review": [f.model_dump() for f in needs_review], + "filter_intercepts": [ + {"target": e.target, "decision": e.decision, "rule": e.rule, "reason": e.reason} + for e in filter_events if e.decision != "allow"], + "sandbox_runs": [ + {"script": o.script, "status": o.status, "exit_code": o.exit_code, + "duration_ms": o.duration_ms, "timed_out": o.timed_out, + "error_type": o.error_type} for o in sandbox_outcomes], + "metrics": metrics, + "llm_summary": llm_summary, + "warnings": list(warnings), + } + + +def _md_findings_table(findings): + if not findings: + return "_none_\n" + lines = ["| Severity | Category | File | Line | Title | Confidence | Source |", + "|---|---|---|---|---|---|---|"] + for f in findings: + lines.append(f"| {f['severity']} | {f['category']} | {f['file']} | {f['line']} " + f"| {f['title']} | {f['confidence']} | {f['source']} |") + return "\n".join(lines) + "\n" + + +def render_markdown(report: dict) -> str: + """Render the human-readable markdown report.""" + s = report["summary"] + parts = [ + "# Code Review Report", + f"- Task: `{report['task_id']}`", + f"- Conclusion: **{report['conclusion']}**", + f"- Input: {report['input']['ref']} (runtime={report['input']['runtime']}, " + f"dry_run={report['input']['dry_run']})", + f"- Findings: {s['total_findings']} (by severity: {s['by_severity']}), " + f"needs human review: {s['needs_human_review_count']}, " + f"deduplicated: {s['deduplicated']}, intercepts: {s['intercepts']}", + "", + "## Findings", + _md_findings_table(report["findings"]), + "### Recommendations", + "\n".join(f"- `{f['file']}:{f['line']}` {f['recommendation']}" + for f in report["findings"]) or "_none_", + "", + "## Needs Human Review", + _md_findings_table(report["needs_human_review"]), + "## Filter Intercepts", + "\n".join(f"- [{e['decision']}] `{e['target']}` ({e['rule']}): {e['reason']}" + for e in report["filter_intercepts"]) or "_none_", + "", + "## Sandbox Runs", + "\n".join(f"- `{o['script']}`: {o['status']} (exit={o['exit_code']}, " + f"{o['duration_ms']}ms, timed_out={o['timed_out']})" + for o in report["sandbox_runs"]) or "_none_", + "", + "## Metrics", + "```json", + json.dumps(report["metrics"], indent=2), + "```", + "", + f"## LLM Summary\n{report['llm_summary'] or '_none_'}", + "", + "## Warnings", + "\n".join(f"- {w}" for w in report["warnings"]) or "_none_", + ] + return "\n".join(parts) + "\n" + + +def write_reports(report: dict, output_dir: str): + """Write review_report.json / review_report.md (final redaction pass).""" + os.makedirs(output_dir, exist_ok=True) + json_path = os.path.join(output_dir, "review_report.json") + md_path = os.path.join(output_dir, "review_report.md") + with open(json_path, "w", encoding="utf-8") as fh: + fh.write(redact_text(json.dumps(report, indent=2, ensure_ascii=False, default=str))) + with open(md_path, "w", encoding="utf-8") as fh: + fh.write(redact_text(render_markdown(report))) + return json_path, md_path diff --git a/examples/skills_code_review_agent/tests/test_report.py b/examples/skills_code_review_agent/tests/test_report.py new file mode 100644 index 00000000..604cdcb7 --- /dev/null +++ b/examples/skills_code_review_agent/tests/test_report.py @@ -0,0 +1,74 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Tests for report building and rendering.""" +import json +from pathlib import Path + +from review.findings import Finding +from review.governance import GovernanceDecision +from review.report import build_report, render_markdown, write_reports +from review.sandbox import SandboxRunOutcome + + +def _report(reported=None, events=None): + return build_report( + task_id="t1", input_ref="x.diff", runtime="local", dry_run=True, + diff_summary={"files_changed": 1}, + reported=reported or [], + needs_review=[Finding(severity="low", category="db_lifecycle", file="b.py", + line=2, title="cursor", confidence=0.5)], + deduped_count=1, + filter_events=events or [GovernanceDecision("curl x", "deny", "network_policy", "no net")], + sandbox_outcomes=[SandboxRunOutcome(script="check_security.py", status="ok", + exit_code=0, duration_ms=10, timed_out=False, + stdout="{}", stderr="")], + metrics={"total_duration_ms": 100, "sandbox_duration_ms": 50, "tool_calls": 6, + "intercepts": 1, "findings_total": 1, + "severity_distribution": {"high": 1}, "error_distribution": {}}, + llm_summary="fine", warnings=["w1"]) + + +def test_conclusion_blocked_on_high(): + rep = _report(reported=[Finding(severity="high", category="security", file="a.py", + line=1, title="eval", confidence=0.9)]) + assert rep["conclusion"] == "blocked" + + +def test_conclusion_needs_attention_on_intercepts_only(): + rep = _report() + assert rep["conclusion"] == "needs_attention" + + +def test_conclusion_pass_when_clean(): + rep = _report(events=[GovernanceDecision("ok", "allow")]) + assert rep["conclusion"] == "pass" + + +def test_report_sections_present(): + rep = _report() + for key in ("summary", "findings", "needs_human_review", "filter_intercepts", + "sandbox_runs", "metrics", "llm_summary", "warnings"): + assert key in rep + assert rep["summary"]["needs_human_review_count"] == 1 + assert rep["summary"]["deduplicated"] == 1 + + +def test_markdown_contains_sections(): + md = render_markdown(_report()) + for heading in ("# Code Review Report", "## Findings", "## Needs Human Review", + "## Filter Intercepts", "## Sandbox Runs", "## Metrics"): + assert heading in md + + +def test_write_reports_redacts(tmp_path): + rep = _report(reported=[Finding( + severity="critical", category="secret_leak", file="s.py", line=1, + title="secret", evidence='k = "sk-abcdefghijklmnopqrstuvwxyz123456"', + confidence=0.95)]) + json_path, md_path = write_reports(rep, str(tmp_path)) + text = Path(json_path).read_text() + Path(md_path).read_text() + assert "sk-abcdefghijklmnopqrstuvwxyz123456" not in text + assert json.loads(Path(json_path).read_text())["task_id"] == "t1" From 4342da90c31969a31d79f2ad4895e83d369bef7a Mon Sep 17 00:00:00 2001 From: DNKYr Date: Thu, 16 Jul 2026 19:37:42 +0800 Subject: [PATCH 14/22] feat(examples): add end-to-end review pipeline with governance, dedup and metrics --- .../review/diff_input.py | 25 +++ .../review/pipeline.py | 177 ++++++++++++++++++ .../tests/test_pipeline.py | 121 ++++++++++++ 3 files changed, 323 insertions(+) create mode 100644 examples/skills_code_review_agent/review/diff_input.py create mode 100644 examples/skills_code_review_agent/review/pipeline.py create mode 100644 examples/skills_code_review_agent/tests/test_pipeline.py diff --git a/examples/skills_code_review_agent/review/diff_input.py b/examples/skills_code_review_agent/review/diff_input.py new file mode 100644 index 00000000..d861213b --- /dev/null +++ b/examples/skills_code_review_agent/review/diff_input.py @@ -0,0 +1,25 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Input resolution: --diff-file, --repo-path or a named fixture.""" +import subprocess +from pathlib import Path + +FIXTURES_DIR = Path(__file__).resolve().parents[1] / "fixtures" + + +def load_diff(diff_file=None, repo_path=None, fixture=None): + """Return (diff_text, input_type, input_ref). Exactly one source required.""" + sources = [s for s in (diff_file, repo_path, fixture) if s] + if len(sources) != 1: + raise ValueError("provide exactly one of --diff-file, --repo-path, --fixture") + if diff_file: + return Path(diff_file).read_text(encoding="utf-8"), "diff_file", str(diff_file) + if repo_path: + out = subprocess.run(["git", "-C", str(repo_path), "diff", "HEAD"], + capture_output=True, text=True, check=True) + return out.stdout, "repo_path", str(repo_path) + name = fixture if str(fixture).endswith(".diff") else f"{fixture}.diff" + return (FIXTURES_DIR / name).read_text(encoding="utf-8"), "fixture", name diff --git a/examples/skills_code_review_agent/review/pipeline.py b/examples/skills_code_review_agent/review/pipeline.py new file mode 100644 index 00000000..26e1753b --- /dev/null +++ b/examples/skills_code_review_agent/review/pipeline.py @@ -0,0 +1,177 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Deterministic review pipeline: parse -> govern -> sandbox -> merge -> persist -> report.""" +import json +import time +from dataclasses import dataclass, field +from pathlib import Path + +from trpc_agent_sdk.skills import create_default_skill_repository + +from agent.agent import create_review_agent +from filters_cr.governance_filter import GovernanceToolFilter +from storage.store import ReviewStore + +from .findings import Finding, dedupe, gate, severity_distribution +from .governance import DEFAULT_ALLOWED_SCRIPTS, GovernanceEngine +from .llm_review import run_llm_review +from .report import build_report, write_reports +from .sandbox import DIFF_WS_PATH, SandboxSession, create_runtime + +DEFAULT_SKILL_ROOT = str(Path(__file__).resolve().parents[1] / "skills" / "code-review") + +CHECKERS = [ + ("check_security.py", "security"), + ("check_async_leak.py", "async_resource_leak"), + ("check_db_lifecycle.py", "db_lifecycle"), + ("check_tests_missing.py", "missing_test"), + ("check_secrets.py", "secret_leak"), +] + + +@dataclass +class ReviewOptions: + diff_text: str + input_type: str + input_ref: str + runtime: str = "local" + dry_run: bool = True + db_url: str = "sqlite:///code_review.db" + output_dir: str = "out" + skill_root: str = DEFAULT_SKILL_ROOT + checkers: list = field(default_factory=lambda: list(CHECKERS)) + allowed_scripts: tuple = DEFAULT_ALLOWED_SCRIPTS + timeout_sec: float = 60.0 + enable_llm: bool = True + + +@dataclass +class ReviewResult: + task_id: str + report: dict + json_path: str + md_path: str + + +def _parse_script_findings(stdout: str, warnings: list) -> list[Finding]: + try: + payload = json.loads(stdout) + except (json.JSONDecodeError, ValueError): + warnings.append("checker produced invalid JSON output") + return [] + findings = [] + for raw in payload.get("findings", []): + try: + findings.append(Finding(**raw)) + except (TypeError, ValueError): + warnings.append(f"skipped malformed finding: {raw!r}") + return findings + + +async def run_review(opts: ReviewOptions) -> ReviewResult: + """Run the full review. Sandbox failures degrade, never crash.""" + start = time.monotonic() + store = ReviewStore(db_url=opts.db_url) + engine = GovernanceEngine(allowed_scripts=opts.allowed_scripts) + task_id = await store.create_task(opts.input_type, opts.input_ref, + opts.runtime, opts.dry_run) + warnings: list[str] = [] + filter_events = [] + sandbox_outcomes = [] + raw_findings: list[Finding] = [] + diff_summary: dict = {} + error_distribution: dict[str, int] = {} + sandbox_ms = 0 + try: + runtime = await create_runtime(opts.runtime) + if opts.runtime == "local": + warnings.append("local runtime is a development fallback; " + "use container or cube in production") + session = SandboxSession(runtime, opts.skill_root, timeout_sec=opts.timeout_sec) + await session.open(f"cr_{task_id[:12]}") + try: + await session.put_diff(opts.diff_text) + scripts = [("parse_diff.py", "parse")] + list(opts.checkers) + for script, category in scripts: + decision = engine.check_script(script, [DIFF_WS_PATH]) + filter_events.append(decision) + await store.add_filter_event(task_id, decision.target, decision.decision, + decision.rule, decision.reason) + if decision.decision != "allow": + continue + outcome = await session.run_script(script) + engine.record_run(outcome.duration_ms / 1000.0) + sandbox_ms += outcome.duration_ms + sandbox_outcomes.append(outcome) + await store.add_sandbox_run( + task_id, script=script, category=category, status=outcome.status, + exit_code=outcome.exit_code, duration_ms=outcome.duration_ms, + timed_out=outcome.timed_out, stdout_summary=outcome.stdout[:4096], + stderr_summary=outcome.stderr[:4096], error_type=outcome.error_type) + if outcome.status != "ok": + key = outcome.error_type or outcome.status + error_distribution[key] = error_distribution.get(key, 0) + 1 + warnings.append(f"{script} did not complete ({outcome.status}); " + "coverage degraded") + continue + if script == "parse_diff.py": + try: + diff_summary = json.loads(outcome.stdout).get("summary", {}) + except (json.JSONDecodeError, ValueError): + warnings.append("parse_diff.py produced invalid JSON") + else: + raw_findings.extend(_parse_script_findings(outcome.stdout, warnings)) + finally: + await session.close() + + llm_findings: list[Finding] = [] + llm_summary = "" + llm_calls = 0 + if opts.enable_llm: + repository = create_default_skill_repository( + str(Path(opts.skill_root).parent), workspace_runtime=runtime) + gov_filter = GovernanceToolFilter(engine, on_event=filter_events.append) + agent = create_review_agent(repository, opts.dry_run, [gov_filter]) + llm_findings, llm_summary, llm_warnings = await run_llm_review( + agent, opts.diff_text, raw_findings) + warnings.extend(llm_warnings) + llm_calls = 1 + + kept, dropped = dedupe(raw_findings + llm_findings) + reported, needs_review = gate(kept) + await store.add_findings(task_id, reported, status="reported") + await store.add_findings(task_id, needs_review, status="needs_human_review") + await store.add_findings(task_id, dropped, status="deduped") + + intercepts = sum(1 for e in filter_events if e.decision != "allow") + metrics = { + "total_duration_ms": int((time.monotonic() - start) * 1000), + "sandbox_duration_ms": sandbox_ms, + "tool_calls": len(sandbox_outcomes) + llm_calls, + "intercepts": intercepts, + "findings_total": len(reported), + "severity_distribution": severity_distribution(reported), + "error_distribution": error_distribution, + } + await store.add_metrics(task_id, metrics) + + report = build_report( + task_id=task_id, input_ref=opts.input_ref, runtime=opts.runtime, + dry_run=opts.dry_run, diff_summary=diff_summary, reported=reported, + needs_review=needs_review, deduped_count=len(dropped), + filter_events=filter_events, sandbox_outcomes=sandbox_outcomes, + metrics=metrics, llm_summary=llm_summary, warnings=warnings) + json_path, md_path = write_reports(report, opts.output_dir) + await store.add_report(task_id, report, Path(md_path).read_text(encoding="utf-8")) + await store.update_task(task_id, status="completed", + diff_summary=diff_summary, finished=True) + return ReviewResult(task_id=task_id, report=report, + json_path=json_path, md_path=md_path) + except Exception: + await store.update_task(task_id, status="failed", finished=True) + raise + finally: + await store.close() diff --git a/examples/skills_code_review_agent/tests/test_pipeline.py b/examples/skills_code_review_agent/tests/test_pipeline.py new file mode 100644 index 00000000..9c9e8aed --- /dev/null +++ b/examples/skills_code_review_agent/tests/test_pipeline.py @@ -0,0 +1,121 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""End-to-end pipeline tests over the 8 fixture scenarios (local runtime, dry-run).""" +import json +import shutil +import time +from pathlib import Path + +from review.pipeline import CHECKERS, ReviewOptions, run_review +from storage.store import ReviewStore + +EXAMPLE_ROOT = Path(__file__).resolve().parents[1] +FIXTURES = EXAMPLE_ROOT / "fixtures" + + +def _opts(tmp_path, fixture, **kw): + return ReviewOptions( + diff_text=(FIXTURES / fixture).read_text(), + input_type="fixture", input_ref=fixture, + runtime="local", dry_run=True, + db_url=f"sqlite:///{tmp_path}/cr.db", + output_dir=str(tmp_path / "out"), **kw) + + +async def _bundle(tmp_path, task_id): + store = ReviewStore(db_url=f"sqlite:///{tmp_path}/cr.db") + bundle = await store.get_task_bundle(task_id) + await store.close() + return bundle + + +async def test_clean_diff_passes(tmp_path): + result = await run_review(_opts(tmp_path, "clean.diff")) + assert result.report["conclusion"] == "pass" + assert result.report["findings"] == [] + assert Path(result.json_path).exists() and Path(result.md_path).exists() + bundle = await _bundle(tmp_path, result.task_id) + assert bundle["task"]["status"] == "completed" + assert len(bundle["sandbox_runs"]) == len(CHECKERS) + 1 # + parse_diff + + +async def test_security_diff_blocked(tmp_path): + result = await run_review(_opts(tmp_path, "security_eval.diff")) + assert result.report["conclusion"] == "blocked" + cats = {f["category"] for f in result.report["findings"]} + assert "security" in cats + + +async def test_async_leak_detected(tmp_path): + result = await run_review(_opts(tmp_path, "async_leak.diff")) + assert any(f["category"] == "async_resource_leak" for f in result.report["findings"]) + + +async def test_db_lifecycle_detected(tmp_path): + result = await run_review(_opts(tmp_path, "db_lifecycle.diff")) + assert any(f["category"] == "db_lifecycle" for f in result.report["findings"]) + assert any(f["confidence"] < 0.6 for f in result.report["needs_human_review"]) + + +async def test_missing_test_detected(tmp_path): + result = await run_review(_opts(tmp_path, "missing_test.diff")) + assert any(f["category"] == "missing_test" for f in result.report["findings"]) + + +async def test_duplicate_finding_deduplicated(tmp_path): + result = await run_review(_opts(tmp_path, "duplicate_finding.diff")) + security = [f for f in result.report["findings"] if f["category"] == "security"] + assert len(security) == 1 + assert result.report["summary"]["deduplicated"] >= 1 + bundle = await _bundle(tmp_path, result.task_id) + assert any(f["status"] == "deduped" for f in bundle["findings"]) + + +async def test_sandbox_failure_does_not_crash(tmp_path): + skill_copy = tmp_path / "code-review" + shutil.copytree(EXAMPLE_ROOT / "skills" / "code-review", skill_copy) + (skill_copy / "scripts" / "check_broken.py").write_text("import sys\nsys.exit(3)\n") + checkers = list(CHECKERS) + [("check_broken.py", "broken")] + allowed = tuple(s for s, _ in checkers) + ("parse_diff.py",) + result = await run_review(_opts(tmp_path, "sandbox_failure.diff", + skill_root=str(skill_copy), checkers=checkers, + allowed_scripts=allowed)) + bundle = await _bundle(tmp_path, result.task_id) + failed = [r for r in bundle["sandbox_runs"] if r["status"] == "failed"] + assert failed and failed[0]["script"] == "check_broken.py" + assert bundle["task"]["status"] == "completed" + + +async def test_secret_leak_redacted_everywhere(tmp_path): + result = await run_review(_opts(tmp_path, "secret_leak.diff")) + assert any(f["category"] == "secret_leak" for f in result.report["findings"]) + secret = "sk-fakefakefakefakefakefake123456" + all_text = Path(result.json_path).read_text() + Path(result.md_path).read_text() + assert secret not in all_text + bundle = await _bundle(tmp_path, result.task_id) + assert secret not in json.dumps(bundle, default=str) + + +async def test_denied_checker_never_reaches_sandbox(tmp_path): + checkers = list(CHECKERS) + [("evil.py", "evil")] + result = await run_review(_opts(tmp_path, "clean.diff", checkers=checkers)) + bundle = await _bundle(tmp_path, result.task_id) + assert all(r["script"] != "evil.py" for r in bundle["sandbox_runs"]) + denies = [e for e in bundle["filter_events"] if e["decision"] == "deny"] + assert any("evil.py" in e["target"] for e in denies) + assert result.report["conclusion"] in ("needs_attention", "pass") + + +async def test_metrics_recorded_and_fast(tmp_path): + start = time.monotonic() + result = await run_review(_opts(tmp_path, "security_eval.diff")) + elapsed = time.monotonic() - start + assert elapsed < 120 + m = result.report["metrics"] + assert m["tool_calls"] >= len(CHECKERS) + 1 + assert m["total_duration_ms"] > 0 + bundle = await _bundle(tmp_path, result.task_id) + assert bundle["metrics"]["findings_total"] == len(result.report["findings"]) From 6ec93f4741341a51e60e020fdb7e5016263c20cd Mon Sep 17 00:00:00 2001 From: DNKYr Date: Thu, 16 Jul 2026 19:43:07 +0800 Subject: [PATCH 15/22] fix(examples): add belt-and-braces redaction to DB-stored report_json --- examples/skills_code_review_agent/review/pipeline.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/examples/skills_code_review_agent/review/pipeline.py b/examples/skills_code_review_agent/review/pipeline.py index 26e1753b..babd6ff6 100644 --- a/examples/skills_code_review_agent/review/pipeline.py +++ b/examples/skills_code_review_agent/review/pipeline.py @@ -18,7 +18,8 @@ from .findings import Finding, dedupe, gate, severity_distribution from .governance import DEFAULT_ALLOWED_SCRIPTS, GovernanceEngine from .llm_review import run_llm_review -from .report import build_report, write_reports +from .redaction import redact_text +from .report import build_report, render_markdown, write_reports from .sandbox import DIFF_WS_PATH, SandboxSession, create_runtime DEFAULT_SKILL_ROOT = str(Path(__file__).resolve().parents[1] / "skills" / "code-review") @@ -165,7 +166,10 @@ async def run_review(opts: ReviewOptions) -> ReviewResult: filter_events=filter_events, sandbox_outcomes=sandbox_outcomes, metrics=metrics, llm_summary=llm_summary, warnings=warnings) json_path, md_path = write_reports(report, opts.output_dir) - await store.add_report(task_id, report, Path(md_path).read_text(encoding="utf-8")) + report_json_text = redact_text(json.dumps(report, indent=2, ensure_ascii=False, default=str)) + report_for_db = json.loads(report_json_text) + md_text = render_markdown(report) + await store.add_report(task_id, report_for_db, md_text) await store.update_task(task_id, status="completed", diff_summary=diff_summary, finished=True) return ReviewResult(task_id=task_id, report=report, From 3a5d1fa3444af78f9b23baf55f18690cd58702d9 Mon Sep 17 00:00:00 2001 From: DNKYr Date: Thu, 16 Jul 2026 19:45:03 +0800 Subject: [PATCH 16/22] test(examples): add opt-in container runtime e2e test for code review --- .../skills_code_review_agent/run_agent.py | 82 +++++++++++++++++++ .../tests/test_cli.py | 32 ++++++++ .../tests/test_container_runtime.py | 30 +++++++ 3 files changed, 144 insertions(+) create mode 100644 examples/skills_code_review_agent/run_agent.py create mode 100644 examples/skills_code_review_agent/tests/test_cli.py create mode 100644 examples/skills_code_review_agent/tests/test_container_runtime.py diff --git a/examples/skills_code_review_agent/run_agent.py b/examples/skills_code_review_agent/run_agent.py new file mode 100644 index 00000000..b06cfecd --- /dev/null +++ b/examples/skills_code_review_agent/run_agent.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Automated code review agent CLI. + +Usage: + python run_agent.py review --fixture security_eval --runtime local --dry-run + python run_agent.py review --diff-file change.diff --runtime container + python run_agent.py show --task-id +""" +import argparse +import asyncio +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from agent.config import is_dry_run # noqa: E402 +from review.diff_input import load_diff # noqa: E402 +from review.pipeline import ReviewOptions, run_review # noqa: E402 +from storage.store import ReviewStore # noqa: E402 + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Automated code review agent") + sub = parser.add_subparsers(dest="command", required=True) + + review = sub.add_parser("review", help="run a code review") + review.add_argument("--diff-file", help="path to a unified diff / PR patch") + review.add_argument("--repo-path", help="git repo; reviews `git diff HEAD`") + review.add_argument("--fixture", help="bundled fixture name (e.g. security_eval)") + review.add_argument("--runtime", default="container", + choices=["local", "container", "cube"], + help="sandbox runtime (default: container; local is dev-only)") + review.add_argument("--dry-run", action="store_true", + help="use the deterministic fake model (no API key needed)") + review.add_argument("--db-url", default="sqlite:///code_review.db") + review.add_argument("--output-dir", default="out") + review.add_argument("--no-llm", action="store_true", help="skip the LLM step") + + show = sub.add_parser("show", help="print a stored review task by id") + show.add_argument("--task-id", required=True) + show.add_argument("--db-url", default="sqlite:///code_review.db") + return parser + + +async def main(argv=None) -> int: + args = _build_parser().parse_args(argv) + if args.command == "review": + diff_text, input_type, input_ref = load_diff( + diff_file=args.diff_file, repo_path=args.repo_path, fixture=args.fixture) + dry_run = is_dry_run(args.dry_run) + if dry_run and not args.dry_run: + print("no TRPC_AGENT_API_KEY configured; falling back to --dry-run mode") + result = await run_review(ReviewOptions( + diff_text=diff_text, input_type=input_type, input_ref=input_ref, + runtime=args.runtime, dry_run=dry_run, db_url=args.db_url, + output_dir=args.output_dir, enable_llm=not args.no_llm)) + print(f"task_id={result.task_id}") + print(f"conclusion={result.report['conclusion']}") + print(f"report_json={result.json_path}") + print(f"report_md={result.md_path}") + return 0 + + store = ReviewStore(db_url=args.db_url) + try: + bundle = await store.get_task_bundle(args.task_id) + finally: + await store.close() + if bundle["task"] is None: + print(f"task {args.task_id!r} not found", file=sys.stderr) + return 1 + print(json.dumps(bundle, indent=2, ensure_ascii=False, default=str)) + return 0 + + +if __name__ == "__main__": + sys.exit(asyncio.run(main())) diff --git a/examples/skills_code_review_agent/tests/test_cli.py b/examples/skills_code_review_agent/tests/test_cli.py new file mode 100644 index 00000000..5ef7e836 --- /dev/null +++ b/examples/skills_code_review_agent/tests/test_cli.py @@ -0,0 +1,32 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""CLI smoke tests (invoke main() in-process).""" +import json + +import run_agent + + +async def test_cli_review_and_show(tmp_path, capsys): + db_url = f"sqlite:///{tmp_path}/cr.db" + code = await run_agent.main([ + "review", "--fixture", "security_eval", "--runtime", "local", "--dry-run", + "--db-url", db_url, "--output-dir", str(tmp_path / "out")]) + assert code == 0 + out = capsys.readouterr().out + assert "task_id=" in out + task_id = out.split("task_id=")[1].split()[0].strip() + + code = await run_agent.main(["show", "--task-id", task_id, "--db-url", db_url]) + assert code == 0 + bundle = json.loads(capsys.readouterr().out) + assert bundle["task"]["id"] == task_id + assert bundle["findings"] + + +async def test_cli_show_unknown_task(tmp_path, capsys): + db_url = f"sqlite:///{tmp_path}/cr.db" + code = await run_agent.main(["show", "--task-id", "missing", "--db-url", db_url]) + assert code == 1 diff --git a/examples/skills_code_review_agent/tests/test_container_runtime.py b/examples/skills_code_review_agent/tests/test_container_runtime.py new file mode 100644 index 00000000..0789ee4a --- /dev/null +++ b/examples/skills_code_review_agent/tests/test_container_runtime.py @@ -0,0 +1,30 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Opt-in container-runtime e2e test. Enable with CR_CONTAINER_TESTS=1.""" +import os +import shutil +from pathlib import Path + +import pytest + +from review.pipeline import ReviewOptions, run_review + +EXAMPLE_ROOT = Path(__file__).resolve().parents[1] + +pytestmark = pytest.mark.skipif( + not (shutil.which("docker") and os.getenv("CR_CONTAINER_TESTS") == "1"), + reason="docker not available or CR_CONTAINER_TESTS != 1") + + +async def test_container_review_security_fixture(tmp_path): + result = await run_review(ReviewOptions( + diff_text=(EXAMPLE_ROOT / "fixtures" / "security_eval.diff").read_text(), + input_type="fixture", input_ref="security_eval.diff", + runtime="container", dry_run=True, + db_url=f"sqlite:///{tmp_path}/cr.db", + output_dir=str(tmp_path / "out"))) + assert result.report["conclusion"] == "blocked" + assert any(f["category"] == "security" for f in result.report["findings"]) From ab8b8c67b8c261e4667f38836dadc2b89ac8ef71 Mon Sep 17 00:00:00 2001 From: DNKYr Date: Thu, 16 Jul 2026 19:48:24 +0800 Subject: [PATCH 17/22] docs(examples): add code review agent README, env template and sample report --- examples/skills_code_review_agent/.env | 7 + examples/skills_code_review_agent/README.md | 135 ++++++++++++++++++ .../sample_output/review_report.json | 130 +++++++++++++++++ .../sample_output/review_report.md | 52 +++++++ 4 files changed, 324 insertions(+) create mode 100644 examples/skills_code_review_agent/.env create mode 100644 examples/skills_code_review_agent/README.md create mode 100644 examples/skills_code_review_agent/sample_output/review_report.json create mode 100644 examples/skills_code_review_agent/sample_output/review_report.md diff --git a/examples/skills_code_review_agent/.env b/examples/skills_code_review_agent/.env new file mode 100644 index 00000000..55794046 --- /dev/null +++ b/examples/skills_code_review_agent/.env @@ -0,0 +1,7 @@ +# Real-model mode (optional). Leave empty to use --dry-run / fake model. +TRPC_AGENT_API_KEY= +TRPC_AGENT_BASE_URL= +TRPC_AGENT_MODEL_NAME= + +# Cube runtime (optional) +CUBE_API_KEY= diff --git a/examples/skills_code_review_agent/README.md b/examples/skills_code_review_agent/README.md new file mode 100644 index 00000000..4e357b58 --- /dev/null +++ b/examples/skills_code_review_agent/README.md @@ -0,0 +1,135 @@ +# Skills Code Review Agent + +Automated code review agent powered by the tRPC-Agent [Skills](https://git.woa.com/trpc-python/trpc-python-agent/trpc-agent/blob/master/docs/skills/skills_zh_CN.md) framework. It demonstrates end-to-end sandboxed execution of review checkers (security, async/resource leaks, DB lifecycle, missing tests, secret leaks), governance via tool-level Filter, SQL-based persistence of findings + metrics + reports, and host-side redaction. The example runs deterministic checker scripts inside a container/workspace sandbox and optionally enriches results with an LLM pass. + +``` + Unified diff (file / repo / fixture) + | + v + +-- Parse diff --+ + | (parse_diff) | + +----------------+ + | + v + +--- Governance Filter ----+ + | script allowlist, paths, | + | network deny, budget, | + | risk → needs_human_review| + +--------------------------+ + | + v + +---- Sandbox (container / local / cube) ----+ + | env -i (PATH+HOME+LANG whitelist) | + | security | async_leak | db_lifecycle | + | tests_missing | secrets | + | timeout: 60s, output cap: 256KB | + +-----------------------------------------------+ + | + v + +--- LLM enrichment (optional) ---+ + | confidence boost, false-positive | + | suppression, prose summary | + +---------------------------------+ + | + v + +--- Dedup + Gating ---+ + | file:line:category | + | confidence >= 0.6 | + +-----------------------+ + | + v + +---- Persist to SQLite ----+ + | 6 tables, task-id indexed | + +---------------------------+ + | + v + +-- Redaction + Reports --+ + | review_report.json/.md | + +-------------------------+ +``` + +## Quick Start + +```bash +cd examples/skills_code_review_agent + +# Dev-fallback runtime, no API key needed: +python run_agent.py review --fixture security_eval --runtime local --dry-run + +# Production default (requires Docker): +python run_agent.py review --diff-file my_change.diff + +# Query a stored review: +python run_agent.py show --task-id +``` + +## CLI Reference + +| Flag | Values | Default | Description | +|---|---|---|---| +| `--diff-file` | path | — | Path to a unified diff / PR patch | +| `--repo-path` | path | — | Git repo; reviews `git diff HEAD` | +| `--fixture` | name | — | Bundled fixture (e.g. `security_eval`) | +| `--runtime` | `local` / `container` / `cube` | `container` | Sandbox runtime; `local` is dev-only | +| `--dry-run` | flag | off | Use deterministic fake model, no API key | +| `--db-url` | URL | `sqlite:///code_review.db` | SQLite path or DB URL | +| `--output-dir` | path | `out` | Directory for JSON + Markdown reports | +| `--no-llm` | flag | off | Skip the LLM enrichment step | + +## Rule Categories + +| Category | Script | Example Patterns | +|---|---|---| +| `security` | `check_security.py` | `eval`, `exec`, `shell=True`, `pickle`, `yaml.load`, SQL injection | +| `async_resource_leak` | `check_async_leak.py` | Unreferenced asyncio tasks, unmanaged sessions/files | +| `db_lifecycle` | `check_db_lifecycle.py` | DB connection/cursor/transaction lifecycle issues | +| `missing_test` | `check_tests_missing.py` | Source files changed without corresponding test changes | +| `secret_leak` | `check_secrets.py` | Hardcoded API keys, tokens, passwords (evidence pre-redacted) | + +## Database Schema + +| Table | Key Columns | +|---|---| +| `cr_review_tasks` | `id`, `status`, `input_type`, `input_ref`, `runtime`, `dry_run`, `diff_summary`, `created_at`, `finished_at` | +| `cr_sandbox_runs` | `id`, `task_id`, `script`, `category`, `status`, `exit_code`, `duration_ms`, `timed_out`, `stdout_summary`, `stderr_summary`, `error_type` | +| `cr_findings` | `id`, `task_id`, `severity`, `category`, `file`, `line`, `title`, `evidence`, `recommendation`, `confidence`, `source`, `status`, `dedup_key` | +| `cr_filter_events` | `id`, `task_id`, `target`, `decision`, `rule`, `reason`, `created_at` | +| `cr_metrics` | `id`, `task_id`, `total_duration_ms`, `sandbox_duration_ms`, `tool_calls`, `intercepts`, `findings_total`, `severity_distribution`, `error_distribution` | +| `cr_reports` | `id`, `task_id`, `report_json`, `report_md`, `created_at` | + +All tables are joinable via `task_id` (foreign key to `cr_review_tasks.id`). + +## Security Boundaries + +- **Environment isolation**: Every checker script runs under `env -i` with only `PATH`, `HOME`, and `LANG` in the environment. No host env vars leak into the sandbox. +- **Timeouts**: Each script execution has a 60-second timeout (configurable). Total sandbox budget is capped at 300 seconds / 20 runs. +- **Output caps**: stdout and stderr are truncated at 256 KB per run. +- **Redaction**: Host-side regex-based redaction (shared pattern library with checkers) is applied to all reports and DB-stored content. No plaintext secrets survive in reports or the database. +- **Filter governance**: The `GovernanceToolFilter` blocks LLM-initiated tool calls outside the script allowlist, denies network tools (`curl`, `wget`, `pip`, `git`, etc.), forbids absolute/relative path escapes, and escalates high-risk commands (`sudo`, `docker`, `rm`, etc.) to `needs_human_review`. +- **Local runtime**: `--runtime local` is a development-only shortcut. Production deployments must use `container` or `cube`. + +## Testing + +```bash +# Run all tests (container tests are skipped by default): +python -m pytest examples/skills_code_review_agent/tests -q + +# Opt into container-based tests (requires Docker): +CR_CONTAINER_TESTS=1 python -m pytest examples/skills_code_review_agent/tests -q +``` + +## 方案设计说明 + +**Skill 设计**:采用 SKILL.md 声明的规则文档 + 脚本架构,每个 checker 脚本以 JSON 契约输出 `{"findings": [...]}`,包含 severity、category、file、line、evidence、recommendation、confidence、source 字段,确保静态分析与 LLM 富化结果统一格式。 + +**沙箱隔离策略**:生产默认使用 container 运行时创建独立工作空间,`env -i` 启动脚本仅注入 PATH/HOME/LANG 白名单,单次超时 60 秒,预算上限 300 秒 / 20 次运行,输出截断 256 KB。local 运行时仅用于开发调试。 + +**Filter 策略**:GovernanceEngine 实施脚本白名单(6 个 checker),禁止网络工具(curl、wget、pip、git 等),拦截绝对路径 / `..` 穿越,高危命令(sudo、docker、rm 等)进入 `needs_human_review` 人工复核,超出预算直接 deny。 + +**监控字段**:`cr_metrics` 表记录 total_duration_ms、sandbox_duration_ms、tool_calls、intercepts、findings_total、severity_distribution(JSON 分布)和 error_distribution,可按 task_id 追溯全链路性能与异常。 + +**数据库 schema**:六表设计 — `cr_review_tasks`(任务),`cr_sandbox_runs`(沙箱运行记录),`cr_findings`(发现项),`cr_filter_events`(拦截事件),`cr_metrics`(监控指标),`cr_reports`(报告),均通过 task_id 外键关联查询。 + +**去重降噪**:file + line + category 三维联合去重,相同键保留最高 severity 与 confidence,置信度 < 0.6 的发现进入 `needs_human_review` 人工确认,避免低质量误报淹没关键问题。 + +**安全边界**:全链路脱敏 — 报告写入前经 host 侧 redaction(与 checker 共享 secret_patterns.py 规则),数据库存储同样经过脱敏,确保报告文件与 SQLite 库中绝无明文密钥泄露。整个方案通过技能化、沙箱化、治理化和持久化四个维度实现生产级代码审查自动化。 diff --git a/examples/skills_code_review_agent/sample_output/review_report.json b/examples/skills_code_review_agent/sample_output/review_report.json new file mode 100644 index 00000000..d1a659ad --- /dev/null +++ b/examples/skills_code_review_agent/sample_output/review_report.json @@ -0,0 +1,130 @@ +{ + "task_id": "03ed666b0d2245f4a68fb83e4987c3a1", + "generated_at": "2026-07-16T19:46:47", + "input": { + "ref": "security_eval.diff", + "runtime": "local", + "dry_run": true, + "diff_summary": { + "files_changed": 2, + "additions": 4, + "deletions": 0, + "files": [ + "app/handler.py", + "tests/test_handler.py" + ] + } + }, + "conclusion": "blocked", + "summary": { + "total_findings": 3, + "by_severity": { + "high": 3 + }, + "needs_human_review_count": 0, + "deduplicated": 0, + "intercepts": 0 + }, + "findings": [ + { + "severity": "high", + "category": "security", + "file": "app/handler.py", + "line": 11, + "title": "Use of eval() on dynamic data", + "evidence": "result = eval(data)", + "recommendation": "Avoid eval(); use ast.literal_eval or explicit parsing.", + "confidence": 0.9, + "source": "static" + }, + { + "severity": "high", + "category": "security", + "file": "app/handler.py", + "line": 12, + "title": "subprocess with shell=True", + "evidence": "subprocess.run(cmd, shell=True)", + "recommendation": "Pass an argument list and shell=False to avoid shell injection.", + "confidence": 0.85, + "source": "static" + }, + { + "severity": "high", + "category": "security", + "file": "app/handler.py", + "line": 13, + "title": "SQL built with f-string (possible injection)", + "evidence": "row = cursor.execute(f\"SELECT * FROM users WHERE name = '{name}'\")", + "recommendation": "Use parameterized queries (placeholders) instead of string interpolation.", + "confidence": 0.85, + "source": "static" + } + ], + "needs_human_review": [], + "filter_intercepts": [], + "sandbox_runs": [ + { + "script": "parse_diff.py", + "status": "ok", + "exit_code": 0, + "duration_ms": 23, + "timed_out": false, + "error_type": "" + }, + { + "script": "check_security.py", + "status": "ok", + "exit_code": 0, + "duration_ms": 25, + "timed_out": false, + "error_type": "" + }, + { + "script": "check_async_leak.py", + "status": "ok", + "exit_code": 0, + "duration_ms": 26, + "timed_out": false, + "error_type": "" + }, + { + "script": "check_db_lifecycle.py", + "status": "ok", + "exit_code": 0, + "duration_ms": 23, + "timed_out": false, + "error_type": "" + }, + { + "script": "check_tests_missing.py", + "status": "ok", + "exit_code": 0, + "duration_ms": 21, + "timed_out": false, + "error_type": "" + }, + { + "script": "check_secrets.py", + "status": "ok", + "exit_code": 0, + "duration_ms": 27, + "timed_out": false, + "error_type": "" + } + ], + "metrics": { + "total_duration_ms": 245, + "sandbox_duration_ms": 145, + "tool_calls": 7, + "intercepts": 0, + "findings_total": 3, + "severity_distribution": { + "high": 3 + }, + "error_distribution": {} + }, + "llm_summary": "Dry-run review complete. Static findings are authoritative.", + "warnings": [ + "local runtime is a development fallback; use container or cube in production" + ] +} \ No newline at end of file diff --git a/examples/skills_code_review_agent/sample_output/review_report.md b/examples/skills_code_review_agent/sample_output/review_report.md new file mode 100644 index 00000000..410864d8 --- /dev/null +++ b/examples/skills_code_review_agent/sample_output/review_report.md @@ -0,0 +1,52 @@ +# Code Review Report +- Task: `03ed666b0d2245f4a68fb83e4987c3a1` +- Conclusion: **blocked** +- Input: security_eval.diff (runtime=local, dry_run=True) +- Findings: 3 (by severity: {'high': 3}), needs human review: 0, deduplicated: 0, intercepts: 0 + +## Findings +| Severity | Category | File | Line | Title | Confidence | Source | +|---|---|---|---|---|---|---| +| high | security | app/handler.py | 11 | Use of eval() on dynamic data | 0.9 | static | +| high | security | app/handler.py | 12 | subprocess with shell=True | 0.85 | static | +| high | security | app/handler.py | 13 | SQL built with f-string (possible injection) | 0.85 | static | + +### Recommendations +- `app/handler.py:11` Avoid eval(); use ast.literal_eval or explicit parsing. +- `app/handler.py:12` Pass an argument list and shell=False to avoid shell injection. +- `app/handler.py:13` Use parameterized queries (placeholders) instead of string interpolation. + +## Needs Human Review +_none_ + +## Filter Intercepts +_none_ + +## Sandbox Runs +- `parse_diff.py`: ok (exit=0, 23ms, timed_out=False) +- `check_security.py`: ok (exit=0, 25ms, timed_out=False) +- `check_async_leak.py`: ok (exit=0, 26ms, timed_out=False) +- `check_db_lifecycle.py`: ok (exit=0, 23ms, timed_out=False) +- `check_tests_missing.py`: ok (exit=0, 21ms, timed_out=False) +- `check_secrets.py`: ok (exit=0, 27ms, timed_out=False) + +## Metrics +```json +{ + "total_duration_ms": 245, + "sandbox_duration_ms": 145, + "tool_calls": 7, + "intercepts": 0, + "findings_total": 3, + "severity_distribution": { + "high": 3 + }, + "error_distribution": {} +} +``` + +## LLM Summary +Dry-run review complete. Static findings are authoritative. + +## Warnings +- local runtime is a development fallback; use container or cube in production From 5d8bb0bb44757772b1e6a8517c7fa4773a2fd4c1 Mon Sep 17 00:00:00 2001 From: DNKYr Date: Thu, 16 Jul 2026 19:53:14 +0800 Subject: [PATCH 18/22] fix(examples): redact report dict before returning from pipeline --- examples/skills_code_review_agent/review/pipeline.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/examples/skills_code_review_agent/review/pipeline.py b/examples/skills_code_review_agent/review/pipeline.py index babd6ff6..c8ad290e 100644 --- a/examples/skills_code_review_agent/review/pipeline.py +++ b/examples/skills_code_review_agent/review/pipeline.py @@ -165,11 +165,10 @@ async def run_review(opts: ReviewOptions) -> ReviewResult: needs_review=needs_review, deduped_count=len(dropped), filter_events=filter_events, sandbox_outcomes=sandbox_outcomes, metrics=metrics, llm_summary=llm_summary, warnings=warnings) + report = json.loads(redact_text(json.dumps(report, indent=2, ensure_ascii=False, default=str))) json_path, md_path = write_reports(report, opts.output_dir) - report_json_text = redact_text(json.dumps(report, indent=2, ensure_ascii=False, default=str)) - report_for_db = json.loads(report_json_text) md_text = render_markdown(report) - await store.add_report(task_id, report_for_db, md_text) + await store.add_report(task_id, report, md_text) await store.update_task(task_id, status="completed", diff_summary=diff_summary, finished=True) return ReviewResult(task_id=task_id, report=report, From ed3de5f272f08ad5ecc71e9877071d53e3c73d71 Mon Sep 17 00:00:00 2001 From: DNKYr Date: Fri, 24 Jul 2026 14:17:02 +0800 Subject: [PATCH 19/22] docs(examples): add design docs in English and Chinese --- .../skills_code_review_agent/doc/design.md | 207 ++++++++++++++++++ .../skills_code_review_agent/doc/design_zh.md | 180 +++++++++++++++ 2 files changed, 387 insertions(+) create mode 100644 examples/skills_code_review_agent/doc/design.md create mode 100644 examples/skills_code_review_agent/doc/design_zh.md diff --git a/examples/skills_code_review_agent/doc/design.md b/examples/skills_code_review_agent/doc/design.md new file mode 100644 index 00000000..7221b527 --- /dev/null +++ b/examples/skills_code_review_agent/doc/design.md @@ -0,0 +1,207 @@ +# Skills Code Review Agent — Design + +## 1. Overview + +The Skills Code Review Agent is an automated code-review prototype built on the tRPC-Agent-Python SDK. It takes a git diff, PR patch, or local working-tree changes, loads review rules and scripts through a `code-review` Skill, executes checker scripts inside an isolated workspace (after Filter governance approval), produces structured findings, and persists tasks, sandbox runs, filter intercepts, monitoring summaries, and reports to a SQL database (SQLite by default). + +## 2. Architecture + +``` + Unified diff (file / repo / fixture) + │ + ▼ + +-- Parse diff --+ + | (parse_diff) | + +----------------+ + │ + ▼ + +--- Governance Filter ----+ + | script allowlist, paths, | + | network deny, budget, | + | risk → needs_human_review| + +--------------------------+ + │ + ▼ + +--- Sandbox (container / local / cube) ---+ + | env -i (PATH+HOME+LANG whitelist) | + | security | async_leak | db_lifecycle | + | tests_missing | secrets | + | timeout: 60s, output cap: 256KB | + +-------------------------------------------+ + │ + ▼ + +--- LLM enrichment (optional) ---+ + | confidence enrichment, | + | false-positive suppression | + +---------------------------------+ + │ + ▼ + +--- Dedup + Gating ---+ + | file:line:category | + | confidence >= 0.6 | + +-----------------------+ + │ + ▼ + +--- Persist to SQLite ---+ + | 6 tables, task-id | + +-------------------------+ + │ + ▼ + +-- Redaction + Reports --+ + | review_report.json/.md | + +-------------------------+ +``` + +The pipeline is deterministic: parse → govern → sandbox → merge → dedup → persist → report. Rule scripts inside the `code-review` skill produce baseline findings (`source: "static"`) — they are deterministic, so the accuracy requirements (≥80% detection, ≤15% false positives) do not depend on an LLM. An optional `LlmAgent` enrichment step can add or confirm findings. In `--dry-run` mode a `FakeReviewModel(LLMModel)` returns deterministic responses so the entire chain runs without any API key. + +## 3. Skill Design + +The `code-review` Skill follows the tRPC-Agent skill layout: + +``` +skills/code-review/ +├── SKILL.md # frontmatter + usage + rules index +├── references/rules/ # one rule doc per category +└── scripts/ + ├── diffparse.py # unified-diff parser (stdlib) + ├── parse_diff.py # CLI: diff → JSON summary + ├── check_security.py # eval/exec, shell=True, pickle, yaml.load, SQL injection + ├── check_async_leak.py # unreferenced tasks, unmanaged sessions/files + ├── check_db_lifecycle.py # connection/cursor/transaction lifecycle + ├── check_tests_missing.py # source changes without test changes + ├── check_secrets.py # hardcoded secrets (evidence pre-redacted) + ├── secret_patterns.py # shared pattern library (sandbox + host) + └── checklib.py # shared helpers: load_files, finding, emit +``` + +Every checker script prints a JSON contract to stdout: `{"findings": [...]}` where each finding has `severity, category, file, line, title, evidence, recommendation, confidence, source`. This contract is the script↔host interface. + +## 4. Sandbox Isolation + +- **Container (production default)**: uses `create_container_workspace_runtime()` with the `python:3-slim` Docker image. The code-review skill directory is staged into the workspace via `stage_directory`. The diff file is staged at `work/inputs/changes.diff`. +- **Local (dev fallback)**: `create_local_workspace_runtime()` — prints a warning; not for production. +- **Cube/E2B**: configured from environment credentials. + +### Safety Boundaries + +| Boundary | Mechanism | +|---|---| +| Environment whitelist | `env -i PATH=/usr/local/bin:/usr/bin:/bin HOME=/tmp LANG=C.UTF-8` — only three environment variables reach the sandbox process. Host environment is completely stripped. | +| Timeout | 60 seconds per script (configurable). Total task budget: 300 seconds / 20 runs. | +| Output cap | 256 KB per stdout/stderr stream. Truncated outputs carry a marker. | +| Failure resilience | Non-zero exit, timeout, or runtime error are recorded as failed `sandbox_runs` rows with `error_type`. The review continues with remaining scripts; sandbox failures never crash the task. | + +## 5. Filter Governance + +The `GovernanceEngine` enforces a layered policy: + +| Policy | Rule | Decision | +|---|---|---| +| Script allowlist | Only 6 known checker scripts may execute | `deny` | +| Forbidden paths | Absolute paths, `..`, `~` escapes | `deny` | +| Network deny | `curl`, `wget`, `pip`, `git`, `ssh`, `apt` etc. | `deny` | +| Risk escalation | `sudo`, `docker`, `chmod`, `rm`, `mkfs` etc. | `needs_human_review` | +| Budget | Exceeding 20 runs or 300s cumulative sandbox time | `deny` | + +Dual enforcement: +1. The deterministic orchestrator in `pipeline.py` consults the engine before every sandbox run. +2. `GovernanceToolFilter` (a `BaseFilter` of type `TOOL`) guards LLM-initiated `skill_run` tool calls via `check_command()`. + +`deny` and `needs_human_review` decisions never reach the sandbox. Every decision is written to `cr_filter_events` and summarized in the report. + +## 6. Finding Processing + +- **Schema**: `Finding` pydantic model — `severity, category, file, line, title, evidence, recommendation, confidence, source`. +- **Dedup**: key = `(file, line, category)`. Duplicates merge: highest severity, highest confidence, union of sources (becomes `"static+llm"`). Dropped rows recorded with status `deduped`. +- **Confidence gating**: findings with `confidence < 0.6` are excluded from main findings and placed in the report's `needs_human_review` section. + +## 7. Database Schema + +Six tables on a dedicated SQLAlchemy `DeclarativeBase`, managed by the SDK's `SqlStorage(is_async=False, db_url=..., metadata=CrBase.metadata)`. Default: `sqlite:///code_review.db`. Any SQLAlchemy `db_url` works. + +| Table | Key columns | +|---|---| +| `cr_review_tasks` | `id` (uuid), `created_at`, `finished_at`, `status`, `input_type`, `input_ref`, `runtime`, `dry_run`, `diff_summary` (JSON) | +| `cr_sandbox_runs` | `id`, `task_id` (FK), `script`, `category`, `status`, `exit_code`, `duration_ms`, `timed_out`, `stdout_summary` (redacted), `stderr_summary`, `error_type` | +| `cr_findings` | `id`, `task_id` (FK), `severity`, `category`, `file`, `line`, `title`, `evidence` (redacted), `recommendation`, `confidence`, `source`, `status`, `dedup_key` | +| `cr_filter_events` | `id`, `task_id` (FK), `target`, `decision`, `rule`, `reason` | +| `cr_metrics` | `id`, `task_id` (FK), `total_duration_ms`, `sandbox_duration_ms`, `tool_calls`, `intercepts`, `findings_total`, `severity_distribution` (JSON), `error_distribution` (JSON) | +| `cr_reports` | `id`, `task_id` (FK), `report_json` (JSON), `report_md` | + +`ReviewStore.get_task_bundle(task_id)` returns all six collections joined by task id. + +## 8. Monitoring + +Every review records into `cr_metrics`: + +- `total_duration_ms`: wall-clock duration of the entire review +- `sandbox_duration_ms`: cumulative sandbox execution time across all scripts +- `tool_calls`: total sandbox runs (+ LLM call if enabled) +- `intercepts`: count of non-`allow` filter decisions +- `findings_total`: count of reported (non-deduped, high-confidence) findings +- `severity_distribution`: JSON dict of `{"critical": N, "high": N, ...}` +- `error_distribution`: JSON dict of `{"timeout": N, "some_exception": N, ...}` + +OTel spans from the SDK (`invocation`, `agent_run`, `execute_tool`) remain active; the custom metrics table provides queryable aggregates without an OTel backend. + +## 9. Redaction + +A shared `secret_patterns.py` module (stdlib-only) serves as the single source of truth. Both `check_secrets.py` (sandbox side) and `review/redaction.py` (host side) import and use it. + +Pattern categories: OpenAI/Anthropic/AWS/GitHub/Slack API keys, Bearer tokens, JWTs, PEM private keys, URL basic-auth, sensitive variable assignments (`password`, `secret`, `token`, `api_key`, etc.). + +Replacement format: `***REDACTED-***` — stable fingerprint per secret value for traceability. + +Applied at every persistence boundary: +1. Checker script evidence (pre-redacted inside the sandbox) +2. Sandbox stdout/stderr summaries (in `ReviewStore.add_sandbox_run`) +3. Finding evidence (in `ReviewStore.add_findings`) +4. Filter event targets (in `ReviewStore.add_filter_event`) +5. Report JSON and Markdown (in `write_reports` and `ReviewStore.add_report`) +6. Report dict returned to callers (in `run_review`) + +Target: ≥95% detection rate; no plaintext secrets in any report file or database row. + +## 10. Fake Model / Dry-Run + +`FakeReviewModel(LLMModel)` provides a deterministic response: `{"summary": "Dry-run review complete. Static findings are authoritative.", "findings": []}`. The `supported_models()` classmethod returns `[r"fake-review-.*"]`. + +In dry-run mode the entire pipeline (parse, govern, sandbox, dedup, persist, report) runs identically; only the model differs. Target: ≤2 minutes (actual: ~5 seconds local, CI-measured). Dry-run auto-activates when `TRPC_AGENT_API_KEY` is unset. + +## 11. Report Output + +Both `review_report.json` and `review_report.md` contain: + +1. Findings summary with severity distribution +2. `needs_human_review` items (low-confidence + filter escalations) +3. Filter intercept summary (decision, rule, reason per event) +4. Monitoring metrics (durations, counts, distributions) +5. Sandbox execution summary (per script: status, duration, failure info) +6. Actionable fix recommendations (per finding) +7. Final conclusion: `pass` / `needs_attention` / `blocked` + +## 12. Directory Layout + +``` +examples/skills_code_review_agent/ +├── README.md +├── run_agent.py # CLI (review / show subcommands) +├── doc/ +│ ├── design.md # this document (English) +│ └── design_zh.md # Chinese version +├── agent/ # LlmAgent wiring + fake model +├── skills/code-review/ # SKILL.md + scripts + rule docs +├── review/ # pipeline, findings, sandbox, governance, redaction, report +├── storage/ # SQLAlchemy models + ReviewStore +├── filters_cr/ # GovernanceToolFilter (BaseFilter) +├── fixtures/ # 8 diff samples +├── tests/ # 65 pytest tests +└── sample_output/ # committed example output +``` + +## 13. Out of Scope + +- Real CI/PR platform integration (GitHub webhooks, etc.) +- Semantic/embedding-based memory of past reviews +- Multi-language checkers — rules target Python sources +- Hidden-sample tuning beyond the documented rule patterns diff --git a/examples/skills_code_review_agent/doc/design_zh.md b/examples/skills_code_review_agent/doc/design_zh.md new file mode 100644 index 00000000..34413eec --- /dev/null +++ b/examples/skills_code_review_agent/doc/design_zh.md @@ -0,0 +1,180 @@ +# Skills Code Review Agent 方案设计说明 + +## 1. 概述 + +Skills Code Review Agent 是基于 tRPC-Agent-Python SDK 构建的自动化代码评审原型。通过 `code-review` Skill 加载评审规则和脚本,经 Filter 治理审批后在隔离工作空间中执行检查,输出结构化发现项,并将任务、沙箱运行记录、拦截事件、监控摘要和报告持久化到 SQL 数据库(默认 SQLite)。 + +## 2. 架构 + +``` + 统一 diff 输入(文件 / 仓库 / 内置样例) + │ + ▼ + +-- 解析 diff --+ + | (parse_diff) | + +--------------+ + │ + ▼ + +--- 治理过滤器 -------+ + | 脚本白名单、路径限制 | + | 网络禁止、预算上限 | + | 高风险 → 人工复核 | + +----------------------+ + │ + ▼ + +-- 沙箱执行 (container / local / cube) --+ + | env -i (仅 PATH+HOME+LANG) | + | security | async_leak | db_lifecycle | + | tests_missing | secrets | + | 超时 60s, 输出截断 256KB | + +------------------------------------------+ + │ + ▼ + +--- LLM 富化(可选)---+ + | 置信度增强、误报抑制 | + | 文字摘要 | + +-----------------------+ + │ + ▼ + +--- 去重 + 置信度过滤 ---+ + | file:line:category | + | confidence >= 0.6 | + +-------------------------+ + │ + ▼ + +--- SQLite 持久化 ---+ + | 6 张表, task-id 索引 | + +----------------------+ + │ + ▼ + +-- 脱敏 + 报告生成 --+ + | json / markdown | + +---------------------+ +``` + +流水线为确定性执行:解析 → 治理 → 沙箱 → 合并 → 去重 → 持久化 → 报告。`code-review` Skill 内部的规则脚本生成基线发现项(`source: "static"`),确保 ≥80% 检出率 / ≤15% 误报率的验收标准不依赖 LLM。可选的 `LlmAgent` 富化步骤用于增强置信度并抑制误报。`--dry-run` 模式下使用 `FakeReviewModel` 返回确定性响应,整个链路无需 API Key 即可运行。 + +## 3. Skill 设计 + +采用 SKILL.md 声明 + 规则文档 + 脚本的架构: + +``` +skills/code-review/ +├── SKILL.md # 前端元数据 + 使用说明 + 规则索引 +├── references/rules/ # 每类规则一篇文档 +└── scripts/ + ├── diffparse.py # unified diff 解析器(纯标准库) + ├── parse_diff.py # CLI: diff → JSON 摘要 + ├── check_security.py # eval/exec, shell=True, pickle, yaml.load, SQL 注入 + ├── check_async_leak.py # 无引用 task、未管理 session/file + ├── check_db_lifecycle.py # 连接/游标/事务生命周期 + ├── check_tests_missing.py # 源文件变更缺少对应测试 + ├── check_secrets.py # 硬编码密钥(证据已预脱敏) + ├── secret_patterns.py # 共享规则库(沙箱 + 宿主共用) + └── checklib.py # 共享工具:load_files, finding, emit +``` + +每个 checker 脚本以 JSON 契约输出 `{"findings": [...]}`,每个发现项包含 `severity, category, file, line, title, evidence, recommendation, confidence, source` 字段,确保静态分析与 LLM 富化结果统一格式。 + +## 4. 沙箱隔离策略 + +- **Container(生产默认)**:`create_container_workspace_runtime()`,使用 `python:3-slim` Docker 镜像。Skill 目录通过 `stage_directory` 阶段化到工作空间,diff 文件阶段化到 `work/inputs/changes.diff`。 +- **Local(仅开发调试)**:`create_local_workspace_runtime()`,输出警告提示不可用于生产。 +- **Cube/E2B**:通过环境变量配置凭证。 + +### 安全边界 + +| 边界 | 机制 | +|---|---| +| 环境隔离 | `env -i PATH=/usr/local/bin:/usr/bin:/bin HOME=/tmp LANG=C.UTF-8` — 仅三个环境变量进入沙箱进程,彻底清除宿主环境 | +| 超时控制 | 单脚本 60 秒超时(可配置),总预算 300 秒 / 20 次运行 | +| 输出上限 | 每路 stdout/stderr 截断至 256 KB,截断标记 | +| 故障容错 | 非零退出码、超时、运行时异常均记录为失败的 `cr_sandbox_runs` 行,带 `error_type`。评审继续执行剩余脚本,绝不因沙箱失败导致整体崩溃 | + +## 5. Filter 治理策略 + +`GovernanceEngine` 实施多层策略: + +| 策略 | 规则 | 决策 | +|---|---|---| +| 脚本白名单 | 仅允许 6 个已知 checker 脚本执行 | `deny` | +| 禁止路径 | 绝对路径、`..`、`~` 逃逸 | `deny` | +| 网络禁止 | `curl`, `wget`, `pip`, `git`, `ssh`, `apt` 等 | `deny` | +| 高风险标记 | `sudo`, `docker`, `chmod`, `rm`, `mkfs` 等 | `needs_human_review` | +| 预算限制 | 超过 20 次运行或 300 秒累计沙箱时间 | `deny` | + +双重执行点: +1. `pipeline.py` 中的确定性编排器在每次沙箱执行前咨询引擎 +2. `GovernanceToolFilter`(`BaseFilter` 子类,`FilterType.TOOL`)守护 LLM 发起的 `skill_run` 工具调用 + +`deny` 和 `needs_human_review` 决策绝不会进入沙箱。每次决策均写入 `cr_filter_events` 并在报告中总结。 + +## 6. 去重降噪 + +- **去重**:键 = `(file, line, category)`。重复项合并:保留最高 severity 和最高 confidence,来源合并为 `"static+llm"`。被丢弃的行记录为 `deduped` 状态。 +- **置信度过滤**:`confidence < 0.6` 的发现从主报告排除,进入 `needs_human_review` 人工确认区域。 + +## 7. 数据库 Schema + +六表设计,基于独立的 SQLAlchemy `DeclarativeBase`,通过 SDK 的 `SqlStorage(is_async=False, db_url=..., metadata=CrBase.metadata)` 管理。默认 `sqlite:///code_review.db`,可切换任意 SQLAlchemy 支持的 `db_url`。 + +| 表 | 关键字段 | +|---|---| +| `cr_review_tasks` | `id` (uuid), `created_at`, `finished_at`, `status`, `input_type`, `input_ref`, `runtime`, `dry_run`, `diff_summary` (JSON) | +| `cr_sandbox_runs` | `id`, `task_id` (FK), `script`, `category`, `status`, `exit_code`, `duration_ms`, `timed_out`, `stdout_summary` (脱敏), `stderr_summary`, `error_type` | +| `cr_findings` | `id`, `task_id` (FK), `severity`, `category`, `file`, `line`, `title`, `evidence` (脱敏), `recommendation`, `confidence`, `source`, `status`, `dedup_key` | +| `cr_filter_events` | `id`, `task_id` (FK), `target`, `decision`, `rule`, `reason` | +| `cr_metrics` | `id`, `task_id` (FK), `total_duration_ms`, `sandbox_duration_ms`, `tool_calls`, `intercepts`, `findings_total`, `severity_distribution` (JSON), `error_distribution` (JSON) | +| `cr_reports` | `id`, `task_id` (FK), `report_json` (JSON), `report_md` | + +`ReviewStore.get_task_bundle(task_id)` 按任务 ID 返回全部六表关联数据。 + +## 8. 监控字段 + +`cr_metrics` 记录: + +- `total_duration_ms`:整个评审的时钟耗时 +- `sandbox_duration_ms`:所有脚本的累计沙箱执行时间 +- `tool_calls`:沙箱运行总次数(含 LLM 调用) +- `intercepts`:非 `allow` 决策的拦截计数 +- `findings_total`:已报告(已去重、高置信度)发现项总数 +- `severity_distribution`:JSON,如 `{"critical": 2, "high": 5}` +- `error_distribution`:JSON,如 `{"timeout": 1, "ValueError": 2}` + +SDK 的 OTel 链路追踪(`invocation`, `agent_run`, `execute_tool`)保持活跃;自定义指标表提供不依赖 OTel 后端的可查询聚合。 + +## 9. 安全边界(脱敏) + +共享的 `secret_patterns.py` 模块(纯标准库)作为规则唯一来源,`check_secrets.py`(沙箱侧)和 `review/redaction.py`(宿主侧)均导入使用。 + +检测范围:OpenAI/Anthropic/AWS/GitHub/Slack API 密钥、Bearer 令牌、JWT、PEM 私钥、URL 基本认证、敏感变量赋值(`password`, `secret`, `token`, `api_key` 等)。 + +替换格式:`***REDACTED-***`——按密钥值稳定计算指纹,可追溯但不可还原。 + +全链路脱敏: +1. checker 脚本证据(沙箱内预脱敏) +2. 沙箱 stdout/stderr 摘要(`ReviewStore.add_sandbox_run` 内) +3. 发现项证据(`ReviewStore.add_findings` 内) +4. Filter 事件目标(`ReviewStore.add_filter_event` 内) +5. 报告 JSON 和 Markdown(`write_reports` 和 `ReviewStore.add_report` 内) +6. 返回调用方的报告字典(`run_review` 内) + +目标:≥95% 检出率;报告文件与数据库行中绝无明文密钥泄露。 + +## 10. Fake Model / Dry-Run + +`FakeReviewModel(LLMModel)` 返回确定性响应:`{"summary": "Dry-run review complete. Static findings are authoritative.", "findings": []}`。`supported_models()` 返回 `[r"fake-review-.*"]`。 + +Dry-run 模式下完整流水线(解析、治理、沙箱、去重、持久化、报告)正常执行,仅模型层替换。目标:≤2 分钟(实测本地约 5 秒)。`TRPC_AGENT_API_KEY` 未设置时自动启用 dry-run。 + +## 11. 报告内容 + +`review_report.json` 和 `review_report.md` 均包含: + +1. 发现项摘要 + 严重级别分布 +2. `needs_human_review` 待人工复核项 +3. Filter 拦截摘要 +4. 监控指标 +5. 沙箱执行摘要 +6. 可执行修复建议 +7. 最终结论:`pass` / `needs_attention` / `blocked` From 30e8c1356ab176662fa0c9d045e57e475f69dcb1 Mon Sep 17 00:00:00 2001 From: DNKYr Date: Fri, 24 Jul 2026 14:58:47 +0800 Subject: [PATCH 20/22] fix(examples): release runtime resources after review to avoid leaks Cube runtimes create sandbox clients with network connections that must be explicitly destroyed. The session.close() handles workspace teardown, but cube sandboxes persist until destroy() is called. Container and local runtimes are no-ops for this check. --- examples/skills_code_review_agent/review/pipeline.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/examples/skills_code_review_agent/review/pipeline.py b/examples/skills_code_review_agent/review/pipeline.py index c8ad290e..87864e82 100644 --- a/examples/skills_code_review_agent/review/pipeline.py +++ b/examples/skills_code_review_agent/review/pipeline.py @@ -141,6 +141,12 @@ async def run_review(opts: ReviewOptions) -> ReviewResult: warnings.extend(llm_warnings) llm_calls = 1 + # Release runtime resources: cube sandbox clients hold network + # connections that must be torn down; container/local have no-op + # cleanup — the workspace was already cleaned by session.close(). + if hasattr(runtime, "destroy"): + await runtime.destroy() + kept, dropped = dedupe(raw_findings + llm_findings) reported, needs_review = gate(kept) await store.add_findings(task_id, reported, status="reported") From 52bcff05d8c118dbd771a747393e5297937afc3f Mon Sep 17 00:00:00 2001 From: DNKYr Date: Fri, 24 Jul 2026 15:01:32 +0800 Subject: [PATCH 21/22] fix(examples): change .env to .env.example --- examples/skills_code_review_agent/{.env => .env.example} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename examples/skills_code_review_agent/{.env => .env.example} (100%) diff --git a/examples/skills_code_review_agent/.env b/examples/skills_code_review_agent/.env.example similarity index 100% rename from examples/skills_code_review_agent/.env rename to examples/skills_code_review_agent/.env.example From b1c7e017fe754db0ccd7962d0a7d9763faf37d09 Mon Sep 17 00:00:00 2001 From: DNKYr Date: Fri, 24 Jul 2026 15:36:10 +0800 Subject: [PATCH 22/22] fix(examples): redact LLM prompt findings, guard runtime.destroy in finally, handle JSON-escaped quotes in sensitive_assign pattern 1. run_llm_review now redacts each finding's evidence/title/recommendation individually before JSON serialization, plus a belt-and-braces pass over the serialized string. This prevents non-secrets checkers from leaking raw source code (potentially containing keys) to the LLM. 2. runtime.destroy() moved into a finally block so cube sandbox client resources are released even when session.open() or run_llm_review() raises an exception. 3. sensitive_assign regex updated to match both raw and JSON-escaped quotes (e.g. password = \"value\" in serialized JSON), so post-serialization redaction still catches password-style secrets. --- .../review/llm_review.py | 14 +- .../review/pipeline.py | 178 +++++++++--------- .../code-review/scripts/secret_patterns.py | 2 +- 3 files changed, 103 insertions(+), 91 deletions(-) diff --git a/examples/skills_code_review_agent/review/llm_review.py b/examples/skills_code_review_agent/review/llm_review.py index a8ba7ef1..31bd3a02 100644 --- a/examples/skills_code_review_agent/review/llm_review.py +++ b/examples/skills_code_review_agent/review/llm_review.py @@ -41,8 +41,20 @@ async def run_llm_review(agent, diff_text: str, static_findings) -> tuple[list[F warnings: list[str] = [] runner = Runner(app_name="code_review_agent", agent=agent, session_service=InMemorySessionService()) + # Redact finding text fields individually before serialization: + # JSON-escaping (\" ) can defeat post-serialization regex on sensitive_assign + # patterns, so we redact each field first, then apply a belt-and-braces pass + # over the serialized JSON string. + redacted_dumps = [] + for f in static_findings: + d = f.model_dump() + for field in ("evidence", "title", "recommendation"): + if d.get(field): + d[field] = redact_text(d[field]) + redacted_dumps.append(d) + findings_json = redact_text(json.dumps(redacted_dumps)) prompt = REVIEW_REQUEST_TEMPLATE.format( - findings_json=json.dumps([f.model_dump() for f in static_findings]), + findings_json=findings_json, diff=redact_text(diff_text)) message = Content(role="user", parts=[Part.from_text(text=prompt)]) final_text = "" diff --git a/examples/skills_code_review_agent/review/pipeline.py b/examples/skills_code_review_agent/review/pipeline.py index 87864e82..5421eeb2 100644 --- a/examples/skills_code_review_agent/review/pipeline.py +++ b/examples/skills_code_review_agent/review/pipeline.py @@ -88,97 +88,97 @@ async def run_review(opts: ReviewOptions) -> ReviewResult: sandbox_ms = 0 try: runtime = await create_runtime(opts.runtime) - if opts.runtime == "local": - warnings.append("local runtime is a development fallback; " - "use container or cube in production") - session = SandboxSession(runtime, opts.skill_root, timeout_sec=opts.timeout_sec) - await session.open(f"cr_{task_id[:12]}") try: - await session.put_diff(opts.diff_text) - scripts = [("parse_diff.py", "parse")] + list(opts.checkers) - for script, category in scripts: - decision = engine.check_script(script, [DIFF_WS_PATH]) - filter_events.append(decision) - await store.add_filter_event(task_id, decision.target, decision.decision, - decision.rule, decision.reason) - if decision.decision != "allow": - continue - outcome = await session.run_script(script) - engine.record_run(outcome.duration_ms / 1000.0) - sandbox_ms += outcome.duration_ms - sandbox_outcomes.append(outcome) - await store.add_sandbox_run( - task_id, script=script, category=category, status=outcome.status, - exit_code=outcome.exit_code, duration_ms=outcome.duration_ms, - timed_out=outcome.timed_out, stdout_summary=outcome.stdout[:4096], - stderr_summary=outcome.stderr[:4096], error_type=outcome.error_type) - if outcome.status != "ok": - key = outcome.error_type or outcome.status - error_distribution[key] = error_distribution.get(key, 0) + 1 - warnings.append(f"{script} did not complete ({outcome.status}); " - "coverage degraded") - continue - if script == "parse_diff.py": - try: - diff_summary = json.loads(outcome.stdout).get("summary", {}) - except (json.JSONDecodeError, ValueError): - warnings.append("parse_diff.py produced invalid JSON") - else: - raw_findings.extend(_parse_script_findings(outcome.stdout, warnings)) + if opts.runtime == "local": + warnings.append("local runtime is a development fallback; " + "use container or cube in production") + session = SandboxSession(runtime, opts.skill_root, timeout_sec=opts.timeout_sec) + await session.open(f"cr_{task_id[:12]}") + try: + await session.put_diff(opts.diff_text) + scripts = [("parse_diff.py", "parse")] + list(opts.checkers) + for script, category in scripts: + decision = engine.check_script(script, [DIFF_WS_PATH]) + filter_events.append(decision) + await store.add_filter_event(task_id, decision.target, decision.decision, + decision.rule, decision.reason) + if decision.decision != "allow": + continue + outcome = await session.run_script(script) + engine.record_run(outcome.duration_ms / 1000.0) + sandbox_ms += outcome.duration_ms + sandbox_outcomes.append(outcome) + await store.add_sandbox_run( + task_id, script=script, category=category, status=outcome.status, + exit_code=outcome.exit_code, duration_ms=outcome.duration_ms, + timed_out=outcome.timed_out, stdout_summary=outcome.stdout[:4096], + stderr_summary=outcome.stderr[:4096], error_type=outcome.error_type) + if outcome.status != "ok": + key = outcome.error_type or outcome.status + error_distribution[key] = error_distribution.get(key, 0) + 1 + warnings.append(f"{script} did not complete ({outcome.status}); " + "coverage degraded") + continue + if script == "parse_diff.py": + try: + diff_summary = json.loads(outcome.stdout).get("summary", {}) + except (json.JSONDecodeError, ValueError): + warnings.append("parse_diff.py produced invalid JSON") + else: + raw_findings.extend(_parse_script_findings(outcome.stdout, warnings)) + finally: + await session.close() + + llm_findings: list[Finding] = [] + llm_summary = "" + llm_calls = 0 + if opts.enable_llm: + repository = create_default_skill_repository( + str(Path(opts.skill_root).parent), workspace_runtime=runtime) + gov_filter = GovernanceToolFilter(engine, on_event=filter_events.append) + agent = create_review_agent(repository, opts.dry_run, [gov_filter]) + llm_findings, llm_summary, llm_warnings = await run_llm_review( + agent, opts.diff_text, raw_findings) + warnings.extend(llm_warnings) + llm_calls = 1 + + kept, dropped = dedupe(raw_findings + llm_findings) + reported, needs_review = gate(kept) + await store.add_findings(task_id, reported, status="reported") + await store.add_findings(task_id, needs_review, status="needs_human_review") + await store.add_findings(task_id, dropped, status="deduped") + + intercepts = sum(1 for e in filter_events if e.decision != "allow") + metrics = { + "total_duration_ms": int((time.monotonic() - start) * 1000), + "sandbox_duration_ms": sandbox_ms, + "tool_calls": len(sandbox_outcomes) + llm_calls, + "intercepts": intercepts, + "findings_total": len(reported), + "severity_distribution": severity_distribution(reported), + "error_distribution": error_distribution, + } + await store.add_metrics(task_id, metrics) + + report = build_report( + task_id=task_id, input_ref=opts.input_ref, runtime=opts.runtime, + dry_run=opts.dry_run, diff_summary=diff_summary, reported=reported, + needs_review=needs_review, deduped_count=len(dropped), + filter_events=filter_events, sandbox_outcomes=sandbox_outcomes, + metrics=metrics, llm_summary=llm_summary, warnings=warnings) + report = json.loads(redact_text(json.dumps(report, indent=2, ensure_ascii=False, default=str))) + json_path, md_path = write_reports(report, opts.output_dir) + md_text = render_markdown(report) + await store.add_report(task_id, report, md_text) + await store.update_task(task_id, status="completed", + diff_summary=diff_summary, finished=True) + return ReviewResult(task_id=task_id, report=report, + json_path=json_path, md_path=md_path) finally: - await session.close() - - llm_findings: list[Finding] = [] - llm_summary = "" - llm_calls = 0 - if opts.enable_llm: - repository = create_default_skill_repository( - str(Path(opts.skill_root).parent), workspace_runtime=runtime) - gov_filter = GovernanceToolFilter(engine, on_event=filter_events.append) - agent = create_review_agent(repository, opts.dry_run, [gov_filter]) - llm_findings, llm_summary, llm_warnings = await run_llm_review( - agent, opts.diff_text, raw_findings) - warnings.extend(llm_warnings) - llm_calls = 1 - - # Release runtime resources: cube sandbox clients hold network - # connections that must be torn down; container/local have no-op - # cleanup — the workspace was already cleaned by session.close(). - if hasattr(runtime, "destroy"): - await runtime.destroy() - - kept, dropped = dedupe(raw_findings + llm_findings) - reported, needs_review = gate(kept) - await store.add_findings(task_id, reported, status="reported") - await store.add_findings(task_id, needs_review, status="needs_human_review") - await store.add_findings(task_id, dropped, status="deduped") - - intercepts = sum(1 for e in filter_events if e.decision != "allow") - metrics = { - "total_duration_ms": int((time.monotonic() - start) * 1000), - "sandbox_duration_ms": sandbox_ms, - "tool_calls": len(sandbox_outcomes) + llm_calls, - "intercepts": intercepts, - "findings_total": len(reported), - "severity_distribution": severity_distribution(reported), - "error_distribution": error_distribution, - } - await store.add_metrics(task_id, metrics) - - report = build_report( - task_id=task_id, input_ref=opts.input_ref, runtime=opts.runtime, - dry_run=opts.dry_run, diff_summary=diff_summary, reported=reported, - needs_review=needs_review, deduped_count=len(dropped), - filter_events=filter_events, sandbox_outcomes=sandbox_outcomes, - metrics=metrics, llm_summary=llm_summary, warnings=warnings) - report = json.loads(redact_text(json.dumps(report, indent=2, ensure_ascii=False, default=str))) - json_path, md_path = write_reports(report, opts.output_dir) - md_text = render_markdown(report) - await store.add_report(task_id, report, md_text) - await store.update_task(task_id, status="completed", - diff_summary=diff_summary, finished=True) - return ReviewResult(task_id=task_id, report=report, - json_path=json_path, md_path=md_path) + # Release runtime resources: cube sandbox clients hold network + # connections that must be torn down even on exception paths. + if hasattr(runtime, "destroy"): + await runtime.destroy() except Exception: await store.update_task(task_id, status="failed", finished=True) raise diff --git a/examples/skills_code_review_agent/skills/code-review/scripts/secret_patterns.py b/examples/skills_code_review_agent/skills/code-review/scripts/secret_patterns.py index 72155f1b..3201dfcf 100644 --- a/examples/skills_code_review_agent/skills/code-review/scripts/secret_patterns.py +++ b/examples/skills_code_review_agent/skills/code-review/scripts/secret_patterns.py @@ -20,7 +20,7 @@ ("url_basic_auth", re.compile(r"[a-z][a-z0-9+.\-]*://[^/\s:@]+:[^@\s]+@")), ("sensitive_assign", re.compile( r"(?i)(password|passwd|secret|token|api_key|apikey|secret_token|db_password)" - r"\s*[:=]\s*[\"'][^\"']{6,}[\"']")), + r"\s*[:=]\s*\\?[\"'][^\"'\\]{6,}\\?[\"']")), ]