Tool Script Safety Guard is a static script security scanning module in the tRPC-Agent-Python SDK that performs static security analysis on scripts before they are executed by Agent Tools / Skills / CodeExecutor. It produces Allow / Deny / NeedsReview decisions and intercepts high-risk scripts at the front of the execution chain.
Tool Script Safety Guard is a static pre-execution script scanner, not a runtime sandbox. It reduces security risks by analyzing script content before execution through:
- Static Analysis: Detects dangerous patterns (dangerous file operations, network egress, sensitive information leakage) without running the script
- Zero Intrusion: Integrates via Filter or Wrapper without modifying core source code, fully backward compatible
- Dual Language Support: Supports both Python (AST + import-as alias tracking) and Bash (shlex + quote state machine)
- Conservative Policy: Defaults to conservative decision-making, tending to block rather than allow uncertain cases
Important: This mechanism is "pre-execution static policy judgment" and cannot replace sandbox isolation. Runtime resource limits and environment isolation must still rely on the CodeExecutor's container or sandbox mechanisms. This is exactly why we chose the wrapper approach without modifying core source code.
from trpc_agent_sdk.tools.safety import ToolSafetyFilter
# Add safety filter when defining a Tool
class MyTool(BaseTool):
# Method 1: Attach via filters_name parameter
filters_name = ["tool_safety"] # Auto-registered filter name
# Method 2: Dynamically attach via add_one_filter
def __init__(self):
super().__init__()
self.add_one_filter("tool_safety")
async def _run_async_impl(self, **kwargs):
# When kwargs contains script/code/command fields
# Safety scan runs first, only executes here if decision is ALLOW
code = kwargs.get("code", "")
return await execute_some_script(code)from trpc_agent_sdk.code_executors import UnsafeLocalCodeExecutor
from trpc_agent_sdk.tools.safety import SafetyGuardedCodeExecutor
# Wrap any CodeExecutor, automatically scan each code block
original_executor = UnsafeLocalCodeExecutor()
guarded_executor = SafetyGuardedCodeExecutor(
delegate=original_executor,
block_on_review=True # Whether to block NEEDS_REVIEW decisions, defaults to True
)
# Use guarded_executor, dangerous code blocks will be skipped
result = await guarded_executor.execute_code(
invocation_context=context,
input_data=CodeExecutionInput(
code="import os; os.system('rm -rf /')", # Dangerous code
language="python"
)
)
# result.stderr will contain "TOOL_SAFETY_BLOCKED [python] DENY (...)"The system covers 7 major risk domains with 20 rules (aligning with trpc-agent-go/tool/safety style):
| Domain Prefix | Risk Coverage | Example rule_ids |
|---|---|---|
tool-code-* |
Code execution | tool-code-unsafe-eval, tool-code-unsafe-exec, tool-code-unsafe-import |
tool-fs-* |
Dangerous file operations | tool-fs-recursive-delete, tool-fs-read-credentials, tool-fs-system-dir-write |
tool-net-* |
Network egress | tool-net-http, tool-net-socket |
tool-proc-* |
Process/system commands | tool-proc-subprocess, tool-proc-shell-pipe, tool-proc-privilege-escalation |
tool-pkg-* |
Dependency installation | tool-pkg-install (pip/npm/apt) |
tool-res-* |
Resource abuse | tool-res-infinite-loop, tool-res-fork-bomb, tool-res-long-sleep, tool-res-large-write, tool-res-concurrent-flood |
tool-secret-* |
Sensitive information leakage | tool-secret-logging, tool-secret-private-key |
class Decision(IntEnum):
UNDECIDED = 0 # Undecided (rule not covered)
ALLOW = 1 # Allow execution
DENY = 2 # Deny execution
NEEDS_REVIEW = 3 # Needs manual reviewclass RiskLevel(IntEnum):
NONE = 0
LOW = 1
MEDIUM = 2
HIGH = 3The system uses dual-track judgment: rule-level judgment priority + policy threshold fallback.
- Any Finding with
rule_decision == DENY, orrisk_level >= policy.deny_risk_level→Decision.DENY - Otherwise any
rule_decision == NEEDS_REVIEW, orrisk_level >= policy.review_risk_level→Decision.NEEDS_REVIEW - Otherwise →
Decision.ALLOW
Default thresholds:
deny_risk_level: HIGH# Risk ≥ HIGH → DENYreview_risk_level: MEDIUM# Risk ≥ MEDIUM → NEEDS_REVIEW
Configure policies via YAML file. Editing the configuration file changes behavior without modifying code (satisfying issue acceptance #6).
Specify the path via environment variable TRPC_AGENT_TOOL_SAFETY_POLICY. If not specified, the built-in default policy is used:
export TRPC_AGENT_TOOL_SAFETY_POLICY=/path/to/custom_policy.yamlname: default
description: Default tool script safety policy for tRPC-Agent.
# Risk level thresholds (used when rule's decision is UNDECIDED)
deny_risk_level: HIGH # findings >= HIGH -> DENY
review_risk_level: MEDIUM # findings >= MEDIUM (and < deny) -> NEEDS_REVIEW
# Global whitelists
whitelisted_domains: # Network whitelist domains
- pypi.org
- github.com
- example.com
allowed_commands: # Allowed commands
- ls
- cat
- echo
- python
denied_paths: # Denied access paths
- /etc
- /root
- ~/.ssh
- ~/.env
- ~/.aws/credentials
# Resource limits (informational for static scan; enforced by executor runtime)
max_timeout_seconds: 30
max_output_bytes: 1048576
max_evidence_chars: 200 # Maximum evidence fragment length
# Rule-level overrides (optional)
rule_overrides:
# tool-net-http:
# risk_level: HIGH
# decision: DENYTool Script Safety Guard cannot replace sandbox isolation:
- This mechanism: Static analysis, intercept before execution, based on pattern matching
- Sandbox: Runtime isolation, restrict during execution, based on resource/permission control
- Complementarity: Static interception reduces sandbox escape risk; sandbox prevents actual damage from statically missed code
Why it cannot replace sandbox:
- Inherent static analysis limitations: obfuscation/encoding bypasses (
base64 -d | sh), dynamic concatenation, indirect calls can leak - Runtime behavior is unpredictable: in-memory code injection, reflection calls cannot be statically detected
- Resource abuse requires runtime limits: infinite loops, memory exhaustion need timeout/resource quota control
ToolSafetyFilter is a subclass of BaseFilter, registered as "tool_safety":
- Execution timing: Before Tool's
_run_async_impl - Interception behavior: When decision is not
ALLOW, returnsFilterResult(is_continue=False), does not callhandle() - Applicable scenarios: Intercept single Tool/Skill execution
SafetyGuardedCodeExecutor is a wrapper for BaseCodeExecutor:
- Execution timing: Before CodeExecutor's
execute_code - Interception behavior: Scan each CodeBlock individually, skip dangerous blocks, only execute safe blocks
- Applicable scenarios: Protect any CodeExecutor (including
UnsafeLocalCodeExecutor)
- Audit log: Every scan appends one JSON Lines audit record to
tool_safety_audit.jsonl(path configurable via envTRPC_AGENT_TOOL_SAFETY_AUDIT), containing all issue #90 mandatory fields:tool_name,decision,risk_level,rule_ids,scan_duration_ms,sanitized,intercepted,timestamp,recommendation. Allowed scripts are summarized; denied scripts record the block reason. Seetrpc_agent_sdk/tools/safety/examples/tool_safety_audit.jsonl. - OpenTelemetry: When the host has OTel enabled and a scan runs inside a span, it automatically sets span attributes
tool.safety.decision/tool.safety.risk_level/tool.safety.rule_id/tool.safety.scan_duration_ms/tool.safety.sanitized/tool.safety.blocked/tool.safety.tool_name(no-op when OTel is absent or no span is active). - Structured report: See
trpc_agent_sdk/tools/safety/examples/tool_safety_report.json; the CLIscripts/tool_safety_check.pyprints exactly this JSON shape.
-
Inherent Static Analysis Limitations
- Obfuscation/encoding bypasses:
base64 -d | sh,eval(base64.b64decode("..."))can leak - Dynamic concatenation:
getattr(os, "system")("rm -rf /"), indirect calls may leak - Runtime code injection: In-memory modifications,
__import__dynamic loading cannot be statically detected
- Obfuscation/encoding bypasses:
-
False Positives and False Negatives
- False positives: Legitimate scripts match dangerous patterns (e.g., legitimate
subprocess.callflagged) - False negatives: New bypass techniques not covered (e.g., new obfuscation methods)
- Tuning methods: Adjust via
whitelisted_domains,allowed_commands,rule_overrides
- False positives: Legitimate scripts match dangerous patterns (e.g., legitimate
-
Bash Parsing is Heuristic
- Uses
shlex+ state machine, not a complete POSIX shell parser - Complex quote/escape boundaries may be misjudged (e.g.,
$'..."..."'nesting)
- Uses
-
Python AST Parsing Failures
- Falls back to string heuristics when AST parsing fails (logs but does not block)
- Depends on syntax correctness; syntax-error scripts may bypass detection
Adding new rules requires modifications in two places:
# trpc_agent_sdk/tools/safety/_rules.py
# Add new rule ID
R_MY_CUSTOM_RULE = "tool-custom-my-rule"
# Define default behavior in DEFAULT_RULE_POLICIES
DEFAULT_RULE_POLICIES: dict[str, tuple[RiskLevel, Decision]] = {
# ... existing rules ...
R_MY_CUSTOM_RULE: (RiskLevel.MEDIUM, Decision.NEEDS_REVIEW),
}Python Scanner (_python_scanner.py):
# Add detection branch in scan function
def _scan_python_script(policy: Policy, script: str) -> list[Finding]:
findings = []
# ... existing detection logic ...
# Add new detection
if "dangerous_pattern" in script:
findings.append(Finding(
rule_id=R_MY_CUSTOM_RULE,
risk_level=RiskLevel.MEDIUM,
rule_decision=Decision.NEEDS_REVIEW,
evidence="...",
recommendation="...",
language="python"
))
return findingsBash Scanner (_bash_scanner.py):
# Add detection branch in Bash scan function
def _scan_bash_script(policy: Policy, script: str) -> list[Finding]:
findings = []
# ... existing detection logic ...
# Add new detection
if "dangerous_command" in tokens:
findings.append(Finding(
rule_id=R_MY_CUSTOM_RULE,
risk_level=RiskLevel.MEDIUM,
rule_decision=Decision.NEEDS_REVIEW,
evidence="...",
recommendation="...",
language="bash"
))
return findings# tool_safety_policy.yaml
rule_overrides:
tool-custom-my-rule:
risk_level: HIGH
decision: DENY- Design Document:
docs/superpowers/specs/2026-07-09-tool-safety-guard-design.md - Implementation Code:
trpc_agent_sdk/tools/safety/ - Test Cases:
tests/tools/safety/samples/manifest.yaml - Corresponding Issue: trpc-group/trpc-agent-python#90