Skip to content

Commit c903c6d

Browse files
committed
fix: stabilize tool safety CI checks
1 parent 2d16fe7 commit c903c6d

10 files changed

Lines changed: 549 additions & 340 deletions

File tree

scripts/tool_safety_check.py

Lines changed: 12 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -12,24 +12,18 @@
1212
import sys
1313
from pathlib import Path
1414

15-
try:
16-
from trpc_agent_sdk.tools.safety._cli_helpers import format_mismatches
17-
from trpc_agent_sdk.tools.safety._cli_helpers import load_policy
18-
from trpc_agent_sdk.tools.safety._cli_helpers import load_samples
19-
from trpc_agent_sdk.tools.safety._cli_helpers import scan_file
20-
from trpc_agent_sdk.tools.safety._cli_helpers import scan_samples
21-
from trpc_agent_sdk.tools.safety._cli_helpers import write_audit_log
22-
from trpc_agent_sdk.tools.safety._cli_helpers import write_json_report
23-
except ModuleNotFoundError: # pragma: no cover - exercised by direct script execution in fresh checkouts.
24-
repo_root = Path(__file__).resolve().parents[1]
25-
sys.path.insert(0, str(repo_root))
26-
from trpc_agent_sdk.tools.safety._cli_helpers import format_mismatches
27-
from trpc_agent_sdk.tools.safety._cli_helpers import load_policy
28-
from trpc_agent_sdk.tools.safety._cli_helpers import load_samples
29-
from trpc_agent_sdk.tools.safety._cli_helpers import scan_file
30-
from trpc_agent_sdk.tools.safety._cli_helpers import scan_samples
31-
from trpc_agent_sdk.tools.safety._cli_helpers import write_audit_log
32-
from trpc_agent_sdk.tools.safety._cli_helpers import write_json_report
15+
REPO_ROOT = Path(__file__).resolve().parents[1]
16+
repo_root = str(REPO_ROOT)
17+
sys.path[:] = [path for path in sys.path if path != repo_root]
18+
sys.path.insert(0, repo_root)
19+
20+
from trpc_agent_sdk.tools.safety._cli_helpers import format_mismatches
21+
from trpc_agent_sdk.tools.safety._cli_helpers import load_policy
22+
from trpc_agent_sdk.tools.safety._cli_helpers import load_samples
23+
from trpc_agent_sdk.tools.safety._cli_helpers import scan_file
24+
from trpc_agent_sdk.tools.safety._cli_helpers import scan_samples
25+
from trpc_agent_sdk.tools.safety._cli_helpers import write_audit_log
26+
from trpc_agent_sdk.tools.safety._cli_helpers import write_json_report
3327

3428

3529
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:

trpc_agent_sdk/tools/safety/_audit.py

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -23,16 +23,17 @@ def _ordered_rule_ids(report: SafetyReport) -> list[str]:
2323

2424

2525
def build_safety_audit_event(
26-
report: SafetyReport,
27-
*,
28-
tool_name: str = "",
29-
cwd: str = "",
30-
function_call_id: str = "",
31-
agent_name: str = "",
26+
report: SafetyReport,
27+
*,
28+
tool_name: str = "",
29+
cwd: str = "",
30+
function_call_id: str = "",
31+
agent_name: str = "",
3232
) -> SafetyAuditEvent:
3333
"""Build an audit-safe summary event from a safety report."""
3434

35-
resolved_tool_name = tool_name or str(report.metadata.get("target_tool", "") or "")
35+
resolved_tool_name = tool_name or str(
36+
report.metadata.get("target_tool", "") or "")
3637
return SafetyAuditEvent(
3738
tool_name=resolved_tool_name,
3839
decision=report.decision,
@@ -53,7 +54,6 @@ def build_safety_audit_event(
5354

5455
class SafetyAuditLogger:
5556
"""Optional JSONL writer for safety audit events."""
56-
5757
def __init__(self, path: str | Path | None = None, enabled: bool = True):
5858
self.path = Path(path) if path is not None else None
5959
self.enabled = enabled
@@ -73,11 +73,13 @@ def emit(self, event: SafetyAuditEvent) -> None:
7373
logger.warning("Failed to write safety audit event: %s", ex)
7474

7575

76-
def set_safety_span_attributes(report: SafetyReport, *, tool_name: str = "") -> None:
76+
def set_safety_span_attributes(report: SafetyReport, *,
77+
tool_name: str = "") -> None:
7778
"""Best-effort write of safety summary fields onto the current OTel span."""
7879

7980
rule_ids = _ordered_rule_ids(report)
80-
resolved_tool_name = tool_name or str(report.metadata.get("target_tool", "") or "")
81+
resolved_tool_name = tool_name or str(
82+
report.metadata.get("target_tool", "") or "")
8183
attributes: dict[str, str | bool | int | float] = {
8284
"tool.safety.decision": report.decision.value,
8385
"tool.safety.risk_level": report.risk_level.value,

trpc_agent_sdk/tools/safety/_cli_helpers.py

Lines changed: 80 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@ def load_samples(path: str | Path) -> list[dict[str, Any]]:
3535
sample_path = Path(path)
3636
loaded = yaml.safe_load(sample_path.read_text(encoding="utf-8"))
3737
if not isinstance(loaded, list):
38-
raise ValueError("samples YAML must contain a list of sample mappings.")
38+
raise ValueError(
39+
"samples YAML must contain a list of sample mappings.")
3940

4041
samples: list[dict[str, Any]] = []
4142
seen_ids: set[str] = set()
@@ -44,23 +45,32 @@ def load_samples(path: str | Path) -> list[dict[str, Any]]:
4445
raise ValueError(f"sample at index {index} must be a mapping.")
4546
sample_id = _string_field(item, "id")
4647
if not sample_id:
47-
raise ValueError(f"sample at index {index} must have a non-empty id.")
48+
raise ValueError(
49+
f"sample at index {index} must have a non-empty id.")
4850
if sample_id in seen_ids:
4951
raise ValueError(f"duplicate sample id: {sample_id}")
5052
seen_ids.add(sample_id)
5153

5254
if not _string_field(item, "description"):
53-
raise ValueError(f"sample {sample_id} must have a non-empty description.")
55+
raise ValueError(
56+
f"sample {sample_id} must have a non-empty description.")
5457
if not _string_field(item, "language"):
55-
raise ValueError(f"sample {sample_id} must have a non-empty language.")
56-
if not (_string_field(item, "content") or _string_field(item, "command")):
57-
raise ValueError(f"sample {sample_id} must define content or command.")
58+
raise ValueError(
59+
f"sample {sample_id} must have a non-empty language.")
60+
if not (_string_field(item, "content")
61+
or _string_field(item, "command")):
62+
raise ValueError(
63+
f"sample {sample_id} must define content or command.")
5864
if not _string_field(item, "expected_decision"):
59-
raise ValueError(f"sample {sample_id} must define expected_decision.")
65+
raise ValueError(
66+
f"sample {sample_id} must define expected_decision.")
6067

6168
expected_rules = item.get("expected_rules", [])
62-
if not isinstance(expected_rules, list) or not all(isinstance(rule, str) for rule in expected_rules):
63-
raise ValueError(f"sample {sample_id} expected_rules must be a list of strings.")
69+
if not isinstance(expected_rules, list) or not all(
70+
isinstance(rule, str) for rule in expected_rules):
71+
raise ValueError(
72+
f"sample {sample_id} expected_rules must be a list of strings."
73+
)
6474

6575
samples.append(dict(item))
6676
return samples
@@ -77,11 +87,11 @@ def load_policy(path: str | Path | None) -> SafetyPolicy:
7787

7888

7989
def scan_samples(
80-
samples: list[dict[str, Any]],
81-
policy: SafetyPolicy,
82-
*,
83-
generated_at: str = FIXTURE_GENERATED_AT,
84-
stable_elapsed_ms: float | None = None,
90+
samples: list[dict[str, Any]],
91+
policy: SafetyPolicy,
92+
*,
93+
generated_at: str = FIXTURE_GENERATED_AT,
94+
stable_elapsed_ms: float | None = None,
8595
) -> tuple[dict[str, Any], list[SafetyAuditEvent], list[str]]:
8696
"""Scan sample mappings and return aggregate report, audit events, and mismatches."""
8797

@@ -93,10 +103,14 @@ def scan_samples(
93103
for sample in samples:
94104
report = scanner.scan(build_target_from_sample(sample))
95105
if stable_elapsed_ms is not None:
96-
report = report.model_copy(update={"elapsed_ms": stable_elapsed_ms})
106+
report = report.model_copy(
107+
update={"elapsed_ms": stable_elapsed_ms})
97108
result = build_sample_result(sample, report)
98109
results.append(result)
99-
audit_events.append(build_audit_event(result["sample_id"], report, generated_at=generated_at))
110+
audit_events.append(
111+
build_audit_event(result["sample_id"],
112+
report,
113+
generated_at=generated_at))
100114
if not result["match"]:
101115
mismatches.append(_mismatch_message(result))
102116

@@ -108,11 +122,11 @@ def scan_samples(
108122

109123

110124
def scan_file(
111-
path: str | Path,
112-
policy: SafetyPolicy,
113-
*,
114-
language: str = "unknown",
115-
generated_at: str = FIXTURE_GENERATED_AT,
125+
path: str | Path,
126+
policy: SafetyPolicy,
127+
*,
128+
language: str = "unknown",
129+
generated_at: str = FIXTURE_GENERATED_AT,
116130
) -> tuple[dict[str, Any], list[SafetyAuditEvent]]:
117131
"""Scan one local file and return an aggregate report with one result."""
118132

@@ -122,7 +136,10 @@ def scan_file(
122136
content=content,
123137
language=parse_language(language),
124138
tool_name=TOOL_NAME,
125-
tool_metadata={"source": "file", "file_name": file_path.name},
139+
tool_metadata={
140+
"source": "file",
141+
"file_name": file_path.name
142+
},
126143
)
127144
report = SafetyScanner(policy).scan(target)
128145
sample = {
@@ -132,7 +149,9 @@ def scan_file(
132149
"expected_rules": [finding.rule_id for finding in report.findings],
133150
}
134151
result = build_sample_result(sample, report)
135-
event = build_audit_event(result["sample_id"], report, generated_at=generated_at)
152+
event = build_audit_event(result["sample_id"],
153+
report,
154+
generated_at=generated_at)
136155
aggregate = build_aggregate_report(
137156
policy_name=policy.name,
138157
results=[result],
@@ -147,14 +166,18 @@ def build_target_from_sample(sample: dict[str, Any]) -> ScanTarget:
147166
env_keys = sample.get("env_keys", [])
148167
if env_keys is None:
149168
env_keys = []
150-
if not isinstance(env_keys, list) or not all(isinstance(key, str) for key in env_keys):
151-
raise ValueError(f"sample {sample.get('id', '<unknown>')} env_keys must be a list of strings.")
169+
if not isinstance(env_keys, list) or not all(
170+
isinstance(key, str) for key in env_keys):
171+
raise ValueError(
172+
f"sample {sample.get('id', '<unknown>')} env_keys must be a list of strings."
173+
)
152174

153175
return ScanTarget(
154176
content=_string_field(sample, "content"),
155177
command=_string_field(sample, "command"),
156178
language=parse_language(_string_field(sample, "language")),
157-
env={key: "" for key in env_keys},
179+
env={key: ""
180+
for key in env_keys},
158181
tool_name=TOOL_NAME,
159182
tool_metadata={"sample_id": _string_field(sample, "id")},
160183
)
@@ -173,41 +196,53 @@ def parse_language(value: str) -> ScriptLanguage:
173196
return ScriptLanguage.UNKNOWN
174197

175198

176-
def build_sample_result(sample: dict[str, Any], report: SafetyReport) -> dict[str, Any]:
199+
def build_sample_result(sample: dict[str, Any],
200+
report: SafetyReport) -> dict[str, Any]:
177201
"""Build the report entry for one sample without including full source text."""
178202

179203
expected_decision = _string_field(sample, "expected_decision")
180204
expected_rules = list(sample.get("expected_rules") or [])
181205
actual_rules = [finding.rule_id for finding in report.findings]
182206
return {
183-
"sample_id": _string_field(sample, "id"),
184-
"description": _string_field(sample, "description"),
185-
"expected_decision": expected_decision,
186-
"expected_rules": expected_rules,
187-
"match": expected_decision == report.decision.value and _is_subset(expected_rules, actual_rules),
188-
"report": report.model_dump(mode="json"),
207+
"sample_id":
208+
_string_field(sample, "id"),
209+
"description":
210+
_string_field(sample, "description"),
211+
"expected_decision":
212+
expected_decision,
213+
"expected_rules":
214+
expected_rules,
215+
"match":
216+
expected_decision == report.decision.value
217+
and _is_subset(expected_rules, actual_rules),
218+
"report":
219+
report.model_dump(mode="json"),
189220
}
190221

191222

192223
def build_aggregate_report(
193-
*,
194-
policy_name: str,
195-
results: list[dict[str, Any]],
196-
generated_at: str = FIXTURE_GENERATED_AT,
224+
*,
225+
policy_name: str,
226+
results: list[dict[str, Any]],
227+
generated_at: str = FIXTURE_GENERATED_AT,
197228
) -> dict[str, Any]:
198229
"""Build the stable public JSON report shape."""
199230

200-
decisions = Counter(str(result["report"]["decision"]) for result in results)
231+
decisions = Counter(
232+
str(result["report"]["decision"]) for result in results)
201233
return {
202234
"policy_name": policy_name,
203235
"generated_at": generated_at,
204236
"sample_count": len(results),
205-
"decision_summary": {decision: decisions.get(decision, 0) for decision in DECISION_VALUES},
237+
"decision_summary":
238+
{decision: decisions.get(decision, 0)
239+
for decision in DECISION_VALUES},
206240
"results": results,
207241
}
208242

209243

210-
def build_audit_event(sample_id: str, report: SafetyReport, *, generated_at: str) -> SafetyAuditEvent:
244+
def build_audit_event(sample_id: str, report: SafetyReport, *,
245+
generated_at: str) -> SafetyAuditEvent:
211246
"""Build a stable audit event for sample/file scans."""
212247

213248
event = build_safety_audit_event(
@@ -223,10 +258,8 @@ def write_json_report(report: dict[str, Any], path: str | Path) -> None:
223258

224259
report_path = Path(path)
225260
report_path.parent.mkdir(parents=True, exist_ok=True)
226-
report_path.write_text(
227-
json.dumps(report, ensure_ascii=False, indent=2, sort_keys=False) + "\n",
228-
encoding="utf-8",
229-
)
261+
payload = json.dumps(report, ensure_ascii=False, indent=2, sort_keys=False)
262+
report_path.write_text(f"{payload}\n", encoding="utf-8")
230263

231264

232265
def write_audit_log(events: list[SafetyAuditEvent], path: str | Path) -> None:
@@ -258,7 +291,9 @@ def _is_subset(expected_rules: list[str], actual_rules: list[str]) -> bool:
258291

259292
def _mismatch_message(result: dict[str, Any]) -> str:
260293
report = result["report"]
261-
actual_rules = [finding["rule_id"] for finding in report.get("findings", [])]
294+
actual_rules = [
295+
finding["rule_id"] for finding in report.get("findings", [])
296+
]
262297
return (
263298
f"{result['sample_id']}: expected decision={result['expected_decision']} "
264299
f"rules={result['expected_rules']}, got decision={report['decision']} rules={actual_rules}"

0 commit comments

Comments
 (0)