Skip to content

Commit 34ce31c

Browse files
committed
examples: add Filter policy gate to code-review agent
Slice 3 (requirement 7). pipeline/policy.py::ReviewPolicy decides allow / deny / needs_human_review for a sandbox action based on the command (high-risk patterns), touched paths (forbidden roots), network hosts (allowlist) and budget. It is enforced at two sites sharing the policy: the deterministic sandbox gate (pipeline/sandbox.py): a denied or human-review action is never launched; a blocked SandboxRunResult is recorded and surfaced in the report's Filter-interception section (completing requirement 8) and in monitoring.block_count. agent/filter.py::ReviewGuardFilter: a TOOL-scoped BaseFilter attached on the review tool that refuses a guarded tool call via FilterResult(is_continue=False). Adds tests for the policy decisions, that a denied action never reaches the sandbox, the guard filter, and the report's block section. Updates #92 RELEASE NOTES: NONE
1 parent 7a9204d commit 34ce31c

7 files changed

Lines changed: 247 additions & 10 deletions

File tree

examples/skills_code_review_agent/README.md

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,5 +83,13 @@ self-test, CLI, the fake-model agent loop (`run_agent.py`), and **sandbox execut
8383
each run; `--runtime container` runs them in a Docker workspace — see `skills/code-review/Dockerfile`
8484
— and is skipped in tests when Docker is absent). Baseline secret redaction is in place.
8585

86-
Planned follow-up slices: the tool-level Filter gate (criterion 7), redaction hardening to ≥95%
87-
(criterion 5), and OpenTelemetry metrics wiring (criterion 9).
86+
The **Filter gate** (criterion 7) is in place: `pipeline/policy.py::ReviewPolicy` decides
87+
allow / deny / needs-human-review for a sandbox action (high-risk command, forbidden path,
88+
non-whitelisted network, over-budget). It is enforced at two sites sharing that policy — the
89+
deterministic sandbox gate (a denied action never launches; the block is recorded and surfaced in
90+
the report's Filter-interception section) and the framework `agent/filter.py::ReviewGuardFilter`
91+
(TOOL-scoped, attached on the review tool).
92+
93+
Planned follow-up slices: redaction hardening to ≥95% (criterion 5) and OpenTelemetry metrics wiring
94+
(criterion 9). Note: the default runtime is `inprocess` for fast dry-runs — pass `--runtime local`
95+
or `--runtime container` to exercise the sandbox path.
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# Tencent is pleased to support the open source community by making tRPC-Agent-Python available.
2+
#
3+
# Copyright (C) 2026 Tencent. All rights reserved.
4+
#
5+
# tRPC-Agent-Python is licensed under Apache-2.0.
6+
7+
"""ReviewGuardFilter — the framework enforcement site for the review policy (issue #92, req 7).
8+
9+
A TOOL-scoped filter (``register_tool_filter``): it inspects a tool call's args before the tool
10+
runs and refuses high-risk sandbox actions, so ``deny`` / ``needs_human_review`` never reach
11+
execution. Attach it on the tool (``FunctionTool(fn, filters_name=["review_guard"])``), NOT on the
12+
agent — the agent resolves ``filters_name`` in the AGENT namespace and would raise.
13+
14+
It shares its decision logic with the deterministic sandbox gate via ``pipeline.policy.ReviewPolicy``.
15+
"""
16+
from __future__ import annotations
17+
18+
from typing import Any
19+
20+
from trpc_agent_sdk.filter import BaseFilter, FilterResult, register_tool_filter
21+
22+
from pipeline.policy import ReviewPolicy
23+
24+
_GUARDED_ARG_KEYS = ("command", "script", "cmd")
25+
26+
27+
@register_tool_filter("review_guard")
28+
class ReviewGuardFilter(BaseFilter):
29+
"""Blocks a guarded tool call whose command the policy denies or flags for human review."""
30+
31+
policy = ReviewPolicy()
32+
33+
def _command(self, req: Any) -> str:
34+
if isinstance(req, dict):
35+
for key in _GUARDED_ARG_KEYS:
36+
if req.get(key):
37+
return str(req[key])
38+
return ""
39+
40+
async def _before(self, ctx: Any, req: Any, rsp: FilterResult) -> None:
41+
command = self._command(req)
42+
if not command:
43+
return # nothing risky to gate (e.g. review_code(diff_text=...))
44+
decision = self.policy.evaluate(command=command)
45+
if not decision.allowed:
46+
rsp.is_continue = False
47+
rsp.error = PermissionError(f"review_guard blocked ({decision.category}): {decision.reason}")

examples/skills_code_review_agent/agent/tools.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
# Copyright (C) 2026 Tencent. All rights reserved.
44
#
55
# tRPC-Agent-Python is licensed under Apache-2.0.
6-
76
"""The review tool the agent calls — a thin wrapper over the deterministic pipeline."""
87
from __future__ import annotations
98

@@ -13,6 +12,8 @@
1312

1413
from pipeline.engine import run_review
1514

15+
from . import filter as _guard # noqa: F401 - importing registers the "review_guard" tool filter
16+
1617

1718
def review_code(diff_text: str) -> dict[str, Any]:
1819
"""Run the code-review pipeline on a unified diff and return a findings summary.
@@ -29,4 +30,5 @@ def review_code(diff_text: str) -> dict[str, Any]:
2930

3031

3132
def build_review_tool() -> FunctionTool:
32-
return FunctionTool(review_code)
33+
# The guard is TOOL-scoped: attach on the tool, not on the agent.
34+
return FunctionTool(review_code, filters_name=["review_guard"])

examples/skills_code_review_agent/pipeline/engine.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424

2525
from . import diff_parser, report as report_mod, scanners
2626
from .dedup import dedup_and_denoise
27+
from .policy import ReviewPolicy
2728
from .types import DiffSummary, Finding, ReviewReport
2829

2930

@@ -60,6 +61,7 @@ def run_review(
6061
runtime: str = "inprocess",
6162
sandbox_timeout: float | None = None,
6263
max_output_bytes: int | None = None,
64+
policy: ReviewPolicy | None = None,
6365
warn_threshold: float | None = None,
6466
review_threshold: float | None = None,
6567
) -> ReviewResult:
@@ -92,9 +94,10 @@ def run_review(
9294
raw, run = sandbox_mod.run_local(
9395
scan_dir,
9496
timeout=sandbox_timeout if sandbox_timeout is not None else sandbox_mod.DEFAULT_TIMEOUT_SEC,
95-
max_bytes=max_output_bytes if max_output_bytes is not None else sandbox_mod.MAX_OUTPUT_BYTES)
97+
max_bytes=max_output_bytes if max_output_bytes is not None else sandbox_mod.MAX_OUTPUT_BYTES,
98+
policy=policy if policy is not None else ReviewPolicy())
9699
sandbox_runs = [run]
97-
if run.timed_out or run.exit_code not in (0, 1): # 1 = scanners found issues (normal)
100+
if run.timed_out or (not run.blocked and run.exit_code not in (0, 1)): # 1 = issues found (normal)
98101
exception_dist["sandbox_failure"] = exception_dist.get("sandbox_failure", 0) + 1
99102
else: # "inprocess"
100103
try:
@@ -124,17 +127,27 @@ def _assemble(task_id, summary, raw, sandbox_runs, source_type, source_ref, star
124127
for f in active:
125128
severity_dist[f.severity] = severity_dist.get(f.severity, 0) + 1
126129

130+
filter_blocks = [{
131+
"script": r.script,
132+
"reason": r.block_reason,
133+
"category": r.block_category
134+
} for r in sandbox_runs if r.blocked]
135+
127136
monitoring = {
128137
"total_sec": round(time.monotonic() - started, 3),
129138
"sandbox_sec": round(sum(r.duration_sec for r in sandbox_runs), 3),
130139
"tool_calls": len(scanners.ADAPTERS),
131-
"block_count": 0, # populated in slice 3 (Filter gate)
140+
"block_count": len(filter_blocks),
132141
"finding_count": len(active),
133142
"severity_dist": severity_dist,
134143
"exception_dist": exception_dist,
135144
}
136145

137-
report = report_mod.build_report(task_id, findings, sandbox_runs=sandbox_runs, monitoring=monitoring)
146+
report = report_mod.build_report(task_id,
147+
findings,
148+
sandbox_runs=sandbox_runs,
149+
filter_blocks=filter_blocks,
150+
monitoring=monitoring)
138151
return ReviewResult(task_id=task_id,
139152
report=report,
140153
findings=findings,
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
# Tencent is pleased to support the open source community by making tRPC-Agent-Python available.
2+
#
3+
# Copyright (C) 2026 Tencent. All rights reserved.
4+
#
5+
# tRPC-Agent-Python is licensed under Apache-2.0.
6+
7+
"""Review policy — the Filter decision logic (issue #92, requirement 7 & 8).
8+
9+
Shared by two enforcement sites (plan decision #5): the framework ``ReviewGuardFilter`` on the agent
10+
path, and a direct gate in the sandbox runner on the deterministic path. Both call ``evaluate`` and
11+
must refuse to execute anything that comes back ``deny`` or ``needs_human_review``.
12+
"""
13+
from __future__ import annotations
14+
15+
import os
16+
import re
17+
from dataclasses import dataclass
18+
from typing import Iterable, Literal
19+
20+
Decision = Literal["allow", "deny", "needs_human_review"]
21+
22+
# High-risk command patterns → hard deny.
23+
_DANGEROUS = [
24+
(re.compile(r"\brm\s+-[rfRF]"), "recursive/force delete"),
25+
(re.compile(r"\b(mkfs|shred)\b|\bdd\s+if="), "disk-destructive command"),
26+
(re.compile(r":\s*\(\s*\)\s*\{.*\}\s*;"), "fork bomb"),
27+
(re.compile(r"(curl|wget)\b[^\n|]*\|\s*(sh|bash)"), "pipe-to-shell"),
28+
(re.compile(r"\bchmod\s+-?R?\s*777\b"), "world-writable chmod"),
29+
(re.compile(r"\bsudo\b"), "privilege escalation"),
30+
(re.compile(r">\s*/dev/sd|/etc/passwd|/etc/shadow"), "write to sensitive target"),
31+
]
32+
33+
# Sensitive roots a review must never touch (temp dirs under /var/folders are intentionally allowed).
34+
_FORBIDDEN_PATHS = ("/etc", "/root", "/boot", os.path.expanduser("~/.ssh"))
35+
36+
37+
@dataclass
38+
class PolicyDecision:
39+
decision: Decision
40+
reason: str = ""
41+
category: str = "ok" # script | path | network | budget | ok
42+
43+
@property
44+
def allowed(self) -> bool:
45+
return self.decision == "allow"
46+
47+
def as_block(self, *, script: str = "") -> dict:
48+
return {"script": script, "decision": self.decision, "reason": self.reason, "category": self.category}
49+
50+
51+
class ReviewPolicy:
52+
"""Decides whether a sandbox action may run. Deny/needs_human_review must NOT reach the sandbox."""
53+
54+
def __init__(self, network_allowlist: Iterable[str] | None = None, max_budget_sec: float = 120.0) -> None:
55+
self.network_allowlist = set(network_allowlist or ())
56+
self.max_budget_sec = max_budget_sec
57+
58+
def evaluate(
59+
self,
60+
*,
61+
command: str = "",
62+
touched_paths: Iterable[str] = (),
63+
network_hosts: Iterable[str] = (),
64+
budget_sec: float = 0.0,
65+
) -> PolicyDecision:
66+
for pat, why in _DANGEROUS:
67+
if pat.search(command):
68+
return PolicyDecision("deny", f"high-risk command: {why}", "script")
69+
70+
for p in touched_paths:
71+
ap = os.path.abspath(p)
72+
if any(ap == fp or ap.startswith(fp + os.sep) for fp in _FORBIDDEN_PATHS):
73+
return PolicyDecision("deny", f"forbidden path: {p}", "path")
74+
75+
unlisted = [h for h in network_hosts if h not in self.network_allowlist]
76+
if unlisted:
77+
return PolicyDecision("needs_human_review", f"non-whitelisted network: {unlisted}", "network")
78+
79+
if budget_sec and budget_sec > self.max_budget_sec:
80+
return PolicyDecision("needs_human_review", f"over budget: {budget_sec}s > {self.max_budget_sec}s",
81+
"budget")
82+
83+
return PolicyDecision("allow")

examples/skills_code_review_agent/pipeline/sandbox.py

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
# Copyright (C) 2026 Tencent. All rights reserved.
44
#
55
# tRPC-Agent-Python is licensed under Apache-2.0.
6-
76
"""Sandboxed execution of the review scanners (issue #92, requirement 4 & slice 2).
87
98
Runs the skill's ``run_checks.py`` outside the review process, with a timeout and an output-size cap,
@@ -24,14 +23,36 @@
2423
import tempfile
2524
import time
2625
from pathlib import Path
26+
from typing import TYPE_CHECKING
2727

2828
from .types import Finding, SandboxRunResult
2929

30+
if TYPE_CHECKING:
31+
from .policy import ReviewPolicy
32+
3033
_SKILL_SCRIPT = Path(__file__).resolve().parents[3] / "skills" / "code-review" / "scripts" / "run_checks.py"
3134
DEFAULT_TIMEOUT_SEC = 60.0
3235
MAX_OUTPUT_BYTES = 1_048_576 # 1 MiB per stream
3336

3437

38+
def _gate(policy: "ReviewPolicy | None", cmd: list[str], scan_dir: str, timeout: float) -> SandboxRunResult | None:
39+
"""Return a blocked result if the policy refuses the action, else None (allowed to run)."""
40+
if policy is None:
41+
return None
42+
decision = policy.evaluate(command=" ".join(cmd), touched_paths=[scan_dir], budget_sec=timeout)
43+
if decision.allowed:
44+
return None
45+
return SandboxRunResult(script="run_checks.py",
46+
exit_code=0,
47+
duration_sec=0.0,
48+
timed_out=False,
49+
stdout_bytes=0,
50+
stderr_bytes=0,
51+
blocked=True,
52+
block_reason=decision.reason,
53+
block_category=decision.category)
54+
55+
3556
def _truncate(text: str | bytes | None, cap: int) -> tuple[str, int]:
3657
"""Return (possibly-truncated text, original byte length). The cap bounds what we persist."""
3758
if text is None:
@@ -69,11 +90,20 @@ def run_local(
6990
*,
7091
timeout: float = DEFAULT_TIMEOUT_SEC,
7192
max_bytes: int = MAX_OUTPUT_BYTES,
93+
policy: "ReviewPolicy | None" = None,
7294
) -> tuple[list[Finding], SandboxRunResult]:
73-
"""Run the scanners in a subprocess against ``scan_dir``; never raises."""
95+
"""Run the scanners in a subprocess against ``scan_dir``; never raises.
96+
97+
If ``policy`` denies the action (or requires human review), the subprocess is NOT launched and a
98+
blocked ``SandboxRunResult`` is returned instead (requirement 7).
99+
"""
74100
out_file = Path(tempfile.mkdtemp(prefix="cr_out_")) / "findings.json"
75101
cmd = [sys.executable, str(_SKILL_SCRIPT), "--target", scan_dir, "--out", str(out_file)]
76102

103+
blocked = _gate(policy, cmd, scan_dir, timeout)
104+
if blocked is not None:
105+
return [], blocked
106+
77107
started = time.monotonic()
78108
timed_out = False
79109
exit_code = 0

tests/examples/test_skills_code_review_agent.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,3 +168,57 @@ def test_sandbox_output_byte_accounting() -> None:
168168
text, n = _truncate("x" * 5000, 10)
169169
assert n == 5000 # records the true size
170170
assert len(text.encode()) <= 10 + len("\n...[truncated]")
171+
172+
173+
def test_policy_decisions() -> None:
174+
from pipeline.policy import ReviewPolicy
175+
176+
p = ReviewPolicy()
177+
assert p.evaluate(command="rm -rf /tmp/x").decision == "deny"
178+
assert p.evaluate(command="python run_checks.py").decision == "allow"
179+
assert p.evaluate(command="cat x", touched_paths=["/etc/passwd"]).decision == "deny"
180+
assert p.evaluate(command="fetch", network_hosts=["evil.com"]).decision == "needs_human_review"
181+
182+
183+
def test_denied_action_never_reaches_sandbox() -> None:
184+
from pipeline.policy import ReviewPolicy
185+
186+
# A policy that refuses everything (tiny budget) must block before execution (requirement 7).
187+
result = run_review(diff_text=(_FIXTURES / "0001_insecure.diff").read_text(),
188+
runtime="local",
189+
policy=ReviewPolicy(max_budget_sec=1e-6),
190+
sandbox_timeout=60)
191+
run = result.report.sandbox_summary[0]
192+
assert run.blocked is True
193+
assert run.duration_sec == 0.0 # never executed
194+
assert result.report.findings_summary["total"] == 0
195+
assert result.report.filter_blocks and result.report.filter_blocks[0]["category"] == "budget"
196+
assert result.monitoring["block_count"] == 1
197+
198+
199+
@pytest.mark.asyncio
200+
async def test_guard_filter_blocks_dangerous_command() -> None:
201+
from trpc_agent_sdk.filter import FilterResult
202+
203+
from agent.filter import ReviewGuardFilter
204+
205+
guard = ReviewGuardFilter()
206+
dangerous = FilterResult()
207+
await guard._before(None, {"command": "rm -rf /"}, dangerous)
208+
assert dangerous.is_continue is False
209+
210+
safe = FilterResult()
211+
await guard._before(None, {"diff_text": "some diff"}, safe)
212+
assert safe.is_continue is True # review_code has no command arg -> passes
213+
214+
215+
def test_report_renders_filter_block_section() -> None:
216+
from pipeline.policy import ReviewPolicy
217+
218+
result = run_review(diff_text=(_FIXTURES / "0001_insecure.diff").read_text(),
219+
runtime="local",
220+
policy=ReviewPolicy(max_budget_sec=1e-6),
221+
sandbox_timeout=60)
222+
md = report_mod.render_md(result.report)
223+
assert "## 4. Filter interception summary" in md
224+
assert "over budget" in md

0 commit comments

Comments
 (0)