Skip to content

Commit 0305e68

Browse files
lll-peanutclaude
andcommitted
fix: address second-round code review issues in Tool Safety Guard
1. ENV-001 evidence leak: evidence field now stores ***REDACTED*** instead of the raw environment variable value (fixes secrets leaking into JSON reports, CLI output, and SafetyWrapper.last_report). 2. ENV-001 deduplication collapse: _deduplicate_findings now includes matched_pattern in the dedup key for line_number==0 findings, so multiple blocklisted env vars each produce independent findings instead of being collapsed into one. 3. _audit.py docstring: corrected misleading 'thread/process-safe' claim to 'thread-safe' — threading.Lock is not cross-process. 4. _python_scanner._handle_for: range() loop detection now handles 2-arg range(start, stop) and 3-arg range(start, stop, step) forms in addition to the original 1-arg range(stop). 5. _bash_scanner._check_redirects: strip surrounding quotes from redirect targets to reduce false positives from bash test expression comparisons (e.g. [[ "a" > "/etc/passwd" ]]). 6. New tests: env value leak, env dedup collapse, allow_patterns vs blocklist conflict (both pattern and env-based). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 6fec1b3 commit 0305e68

5 files changed

Lines changed: 113 additions & 24 deletions

File tree

tests/test_tool_safety.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2347,3 +2347,82 @@ def test_set_safety_span_attributes_get_current_span_exception(monkeypatch):
23472347

23482348
# Should not raise
23492349
_telemetry.set_safety_span_attributes(report)
2350+
2351+
2352+
# ═══════════════════════════════════════════════════════════════════════════════
2353+
# Tests for code-review fixes
2354+
# ═══════════════════════════════════════════════════════════════════════════════
2355+
2356+
2357+
def test_env_blocklist_no_value_leak_in_evidence():
2358+
"""ENV-001 evidence must NOT contain the raw environment variable value."""
2359+
from trpc_agent_sdk.tools.safety._policy import SafetyPolicy
2360+
policy = SafetyPolicy(blocklist_env_vars=["AZURE_CLIENT_SECRET", "MY_SECRET_KEY"])
2361+
scanner = SafetyScanner(policy=policy)
2362+
report = scanner.scan(
2363+
SafetyScanInput(
2364+
script_content="echo hello",
2365+
script_type=ScriptType.BASH,
2366+
tool_name="env_leak_test",
2367+
environment_variables={
2368+
"AZURE_CLIENT_SECRET": "super-secret-real-value-12345",
2369+
"MY_SECRET_KEY": "another-secret-value-67890",
2370+
},
2371+
))
2372+
env_findings = [f for f in report.findings if f.rule_id == "ENV-001"]
2373+
assert len(env_findings) >= 2, f"Expected ≥2 ENV-001 findings, got {len(env_findings)}"
2374+
for f in env_findings:
2375+
assert "super-secret-real-value-12345" not in f.evidence, \
2376+
f"Evidence leaked raw value: {f.evidence}"
2377+
assert "another-secret-value-67890" not in f.evidence, \
2378+
f"Evidence leaked raw value: {f.evidence}"
2379+
assert "***REDACTED***" in f.evidence, \
2380+
f"Evidence should contain REDACTED: {f.evidence}"
2381+
2382+
2383+
def test_env_blocklist_multiple_not_collapsed():
2384+
"""Multiple blocklist env vars must each produce an independent ENV-001 finding."""
2385+
from trpc_agent_sdk.tools.safety._policy import SafetyPolicy
2386+
policy = SafetyPolicy(blocklist_env_vars=[
2387+
"AWS_SECRET_ACCESS_KEY",
2388+
"GITHUB_TOKEN",
2389+
"DOCKER_PASSWORD",
2390+
"AZURE_CLIENT_ID",
2391+
])
2392+
scanner = SafetyScanner(policy=policy)
2393+
report = scanner.scan(
2394+
SafetyScanInput(
2395+
script_content="echo hello",
2396+
script_type=ScriptType.BASH,
2397+
tool_name="env_collapse_test",
2398+
environment_variables={
2399+
"AWS_SECRET_ACCESS_KEY": "val1",
2400+
"GITHUB_TOKEN": "val2",
2401+
"DOCKER_PASSWORD": "val3",
2402+
"AZURE_CLIENT_ID": "val4",
2403+
},
2404+
))
2405+
env_findings = [f for f in report.findings if f.rule_id == "ENV-001"]
2406+
assert len(env_findings) == 4, \
2407+
f"Expected 4 independent ENV-001 findings, got {len(env_findings)}. " \
2408+
f"matched_patterns: {[f.matched_pattern for f in env_findings]}"
2409+
2410+
2411+
def test_allow_patterns_never_overrides_blocklist_deny():
2412+
"""allow_patterns must NOT override DENY even when pattern matches the script."""
2413+
from trpc_agent_sdk.tools.safety._policy import SafetyPolicy
2414+
# Script matches BOTH a blocklist pattern AND an allow pattern
2415+
policy = SafetyPolicy(
2416+
blocklist_patterns=[r"rm\s+-rf\s+/tmp/x"],
2417+
allow_patterns=[r"rm\s+-rf\s+/tmp/x"],
2418+
)
2419+
scanner = SafetyScanner(policy=policy)
2420+
report = scanner.scan(
2421+
SafetyScanInput(
2422+
script_content="rm -rf /tmp/x",
2423+
script_type=ScriptType.BASH,
2424+
tool_name="blocklist_wins_test",
2425+
))
2426+
# Blocklist must win — allow_patterns only upgrades NEEDS_HUMAN_REVIEW
2427+
assert report.decision == Decision.DENY, \
2428+
f"blocklist must win over allow_patterns, got {report.decision}"

trpc_agent_sdk/tools/safety/_audit.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,9 @@
3131

3232
_AUDIT_LOGGER = logging.getLogger("trpc_agent_sdk.tools.safety.audit")
3333

34-
# Per-path locks for thread/process-safe concurrent writes.
35-
# Each JSONL file gets its own lock so different audit files can be written
36-
# concurrently without contention.
34+
# Per-path locks for thread-safe concurrent writes (threading.Lock is NOT
35+
# process-safe; multi-process deployments should use a dedicated audit daemon
36+
# or file-locking via fcntl/msvcrt).
3737
_FILE_LOCKS: dict[str, threading.Lock] = {}
3838
_FILE_LOCKS_LOCK = threading.Lock()
3939

trpc_agent_sdk/tools/safety/_bash_scanner.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -386,7 +386,9 @@ def _check_redirects(self, line_no: int, raw_line: str, tokens: List[str]) -> No
386386
# Look for > or >> patterns
387387
for i, t in enumerate(tokens):
388388
if t in (">", ">>", ">" + ">") and i + 1 < len(tokens):
389-
target = tokens[i + 1]
389+
# Strip surrounding quotes so that "/etc/passwd" in
390+
# test expressions ([[ … ]]) is treated consistently.
391+
target = tokens[i + 1].strip("'\"")
390392
if _is_sensitive_path(target) or target.startswith("/dev/"):
391393
self._findings.append(
392394
BashScanFinding(
@@ -400,7 +402,7 @@ def _check_redirects(self, line_no: int, raw_line: str, tokens: List[str]) -> No
400402
# Also handle inline redirect: 2>/dev/null, >/etc/passwd
401403
if ">" in t and len(t) > 1:
402404
parts = t.split(">", 1)
403-
target = parts[1].strip()
405+
target = parts[1].strip().strip("'\"")
404406
if target and (_is_sensitive_path(target) or target.startswith("/dev/sd")):
405407
self._findings.append(
406408
BashScanFinding(

trpc_agent_sdk/tools/safety/_python_scanner.py

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -252,7 +252,6 @@ class PythonScanFinding:
252252
"importlib": "dynamic",
253253
}
254254

255-
256255
# ═══════════════════════════════════════════════════════════════════════════
257256
# Python AST Scanner
258257
# ═══════════════════════════════════════════════════════════════════════════
@@ -553,7 +552,7 @@ def _handle_assign(self, node: ast.Assign) -> None:
553552
# Reading HOME/USER/PATH is normal and should not trigger alerts.
554553
if isinstance(node.value, ast.Subscript):
555554
canonical = self._resolve_canonical(node.value.value)
556-
if canonical in ("os.environ",):
555+
if canonical in ("os.environ", ):
557556
key = self._get_subscript_key(node.value)
558557
if key and _is_sensitive_env_key(key):
559558
self._tainted[var_name] = f"os.environ:{key}"
@@ -600,8 +599,7 @@ def _handle_aug_assign(self, node: ast.AugAssign) -> None:
600599
if node.target.id in self._tainted:
601600
pass # already tainted
602601

603-
def _check_output_taint(self, node: ast.Call, canonical: str, line_no: int,
604-
evidence: str) -> None:
602+
def _check_output_taint(self, node: ast.Call, canonical: str, line_no: int, evidence: str) -> None:
605603
"""Check if a tainted variable is being printed/logged."""
606604
if len(node.args) < 1:
607605
return
@@ -654,20 +652,26 @@ def _handle_while(self, node: ast.While) -> None:
654652
))
655653

656654
def _handle_for(self, node: ast.For) -> None:
657-
"""Detect range(very_large) patterns."""
655+
"""Detect range(very_large) patterns in 1-/2-/3-arg forms."""
658656
line_no = node.lineno or 0
659657
if isinstance(node.iter, ast.Call):
660658
canonical = self._resolve_canonical(node.iter.func)
661-
if canonical == "range" and len(node.iter.args) == 1:
662-
val = self._get_arg_value(node.iter, 0)
659+
if canonical == "range" and 1 <= len(node.iter.args) <= 3:
660+
# The stop-value argument index: arg 0 for range(stop),
661+
# arg 1 for range(start, stop) or range(start, stop, step).
662+
stop_idx = 0 if len(node.iter.args) == 1 else 1
663+
val = self._get_arg_value(node.iter, stop_idx)
663664
if isinstance(val, int) and val > 10_000_000:
664665
self._findings.append(
665666
PythonScanFinding(
666667
kind="loop",
667668
canonical_name="large_range",
668669
line_number=line_no,
669670
evidence=self._get_line(line_no),
670-
extra={"range_value": val},
671+
extra={
672+
"range_value": val,
673+
"arg_count": len(node.iter.args)
674+
},
671675
))
672676

673677
def _handle_with(self, node: ast.With) -> None:
@@ -858,9 +862,7 @@ def has_python_call(findings: List[PythonScanFinding], canonical_prefix: str) ->
858862

859863
def get_python_urls(findings: List[PythonScanFinding]) -> List[Tuple[str, str, int]]:
860864
"""Return (url, domain, line_number) tuples for all URL findings."""
861-
return [(f.extra.get("url", ""), f.extra.get("domain", ""), f.line_number)
862-
for f in findings
863-
if f.kind == "url"]
865+
return [(f.extra.get("url", ""), f.extra.get("domain", ""), f.line_number) for f in findings if f.kind == "url"]
864866

865867

866868
def get_python_file_reads(findings: List[PythonScanFinding]) -> List[PythonScanFinding]:
@@ -900,8 +902,7 @@ def get_python_sleep(findings: List[PythonScanFinding]) -> List[PythonScanFindin
900902

901903
def get_python_concurrency(findings: List[PythonScanFinding]) -> List[PythonScanFinding]:
902904
"""Return all concurrency/fork findings."""
903-
return [f for f in findings if f.kind == "fork" or
904-
(f.kind == "call" and f.extra.get("risk") == "concurrency")]
905+
return [f for f in findings if f.kind == "fork" or (f.kind == "call" and f.extra.get("risk") == "concurrency")]
905906

906907

907908
# ═══════════════════════════════════════════════════════════════════════════
@@ -926,8 +927,7 @@ def _is_credential_path(path: str) -> bool:
926927
_SECRET_LOOKALIKE_RE = re.compile(
927928
r"(?:sk-[a-zA-Z0-9]{20,}|ghp_[a-zA-Z0-9]{20,}|AKIA[0-9A-Z]{16}|"
928929
r"eyJ[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,}|"
929-
r"xox[baprs]-[a-zA-Z0-9-]+|AIza[0-9A-Za-z\-_]{35})",
930-
)
930+
r"xox[baprs]-[a-zA-Z0-9-]+|AIza[0-9A-Za-z\-_]{35})", )
931931

932932

933933
def _looks_like_secret(value: str) -> bool:

trpc_agent_sdk/tools/safety/_scanner.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,7 @@ def scan(self, scan_input: SafetyScanInput) -> SafetyScanReport:
193193
rule_id="ENV-001",
194194
category=RiskCategory.SENSITIVE_INFO_LEAK,
195195
risk_level=RiskLevel.HIGH,
196-
evidence=f"env: {blocked_var}={scan_input.environment_variables[blocked_var][:50]}",
196+
evidence=f"env: {blocked_var}=***REDACTED***",
197197
message=f"Blocklisted environment variable set: {blocked_var}",
198198
recommendation="Do not pass sensitive environment variables to tools.",
199199
line_number=0,
@@ -873,10 +873,18 @@ def quick_scan(
873873

874874

875875
def _deduplicate_findings(findings: List[SafetyFinding]) -> List[SafetyFinding]:
876-
"""Remove duplicates: keep only the highest-risk finding per (rule_id, line_number)."""
877-
seen: dict[tuple[str, int], SafetyFinding] = {}
876+
"""Remove duplicates: keep only the highest-risk finding per (rule_id, line_number).
877+
878+
For line_number==0 findings (e.g. ENV-001 for blocklisted env vars), the
879+
``matched_pattern`` is included in the key so that multiple distinct
880+
hits are not collapsed into a single record.
881+
"""
882+
seen: dict[tuple[str, int, str], SafetyFinding] = {}
878883
for f in findings:
879-
key = (f.rule_id, f.line_number)
884+
# Include matched_pattern for line-0 findings to avoid collapsing
885+
# distinct hits (e.g. multiple blocklisted environment variables).
886+
discriminator = f.matched_pattern if f.line_number == 0 else ""
887+
key = (f.rule_id, f.line_number, discriminator)
880888
if key not in seen or f.risk_level > seen[key].risk_level:
881889
seen[key] = f
882890
return list(seen.values())

0 commit comments

Comments
 (0)