Skip to content

Commit b45ef14

Browse files
committed
fix: harden tool safety guard enforcement
1 parent d7e79ed commit b45ef14

16 files changed

Lines changed: 720 additions & 207 deletions

docs/tool_safety_guard.md

Lines changed: 27 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -51,10 +51,10 @@ permissions, and runtime resource limits.
5151

5252
| Layer | Owns |
5353
|---|---|
54-
| **ToolScriptSafetyFilter** | Pre-execution static policy decision and redaction |
55-
| **SafetyCheckedExecutor** | Applies the same decision before delegated execution |
54+
| **SafetyWrappedCallable / SafetyCheckedExecutor** | Current enforcement path: scan, await audit, then delegate only when allowed |
55+
| **ToolScriptSafetyFilter** | Normalizes and records decisions for wrappers and a future SDK terminal hook |
5656
| **Wrapper / Sandbox / Runtime** | Runtime isolation: CPU, memory, PID, FS, network hard limits |
57-
| **Audit / Telemetry** | Evidence that a decision occurred; **not** enforcement |
57+
| **Audit / Telemetry** | Decision evidence; required audit persistence is part of the wrapper's fail-closed gate |
5858

5959
## Quick start
6060

@@ -119,6 +119,13 @@ safe_run = SafetyWrappedCallable(
119119
safe_run("ls -la") # raises BlockedExecutionError if policy denies
120120
```
121121

122+
Call ``await safe_run.call_async(...)`` instead when the delegate or caller
123+
already runs inside an event loop; this preserves the required-audit
124+
before-delegate guarantee.
125+
For a tool-style callable, set ``argv_kw``, ``cwd_kw``, ``env_kw``,
126+
``metadata_kw``, and ``output_bytes_kw`` to the corresponding argument
127+
names so the normalized request contains every available execution field.
128+
122129
### Wrapping a code executor
123130

124131
```python
@@ -148,6 +155,7 @@ change between releases so policy overrides remain stable.
148155
| `FILE004_DOTENV_READ` | file | deny | Reads of `.env` files |
149156
| `NET001_DOMAIN_NOT_ALLOWED` | network | deny | Requests/curl/wget to non-allowlisted hosts |
150157
| `NET002_DYNAMIC_TARGET` | network | review | Computed network destination |
158+
| `NET003_IP_LITERAL` | network | deny | IP literal when `deny_ip_literals` is enabled |
151159
| `PROC001_PROCESS_EXEC` | process | review | Subprocess or command not on allow list |
152160
| `PROC002_SHELL_INJECTION` | process | deny | `shell=True` with shell grammar |
153161
| `PROC003_SHELL_OPERATOR` | process | review | `;`, `&&`, `\|`, `&`, command substitution |
@@ -156,13 +164,13 @@ change between releases so policy overrides remain stable.
156164
| `RES001_UNBOUNDED_LOOP` | resource | deny | `while True` without break |
157165
| `RES002_FORK_BOMB` | resource | deny | Classic `:(){ :\|:& };:` pattern |
158166
| `RES003_LONG_SLEEP` | resource | deny | Sleeps exceeding policy limit |
159-
| `RES004_CONCURRENCY` | resource | deny | Fan-out exceeding `max_parallel_tasks` |
167+
| `RES004_CONCURRENCY` | resource | deny | Fan-out exceeding `max_parallel_tasks` or `max_processes` |
160168
| `RES005_LARGE_WRITE` | resource | deny | Writes exceeding `max_file_write_bytes` |
161169
| `SECRET001_LOG_SINK` | secret | deny | Tainted value into print/log |
162170
| `SECRET002_FILE_SINK` | secret | deny | Tainted value into file write |
163171
| `SECRET003_NETWORK_SINK` | secret | deny | Tainted value into network payload |
164172
| `PARSE001_UNCERTAIN` | analysis | review | Syntax error or unknown construct |
165-
| `OBF001_DYNAMIC_EXEC` | analysis | review | `eval`, `exec`, `importlib`, reflective calls |
173+
| `OBF001_DYNAMIC_EXEC` | analysis | review | `eval`, `exec`, indirect Bash execution, interpreter payloads |
166174
| `SAFE000` | safe | allow | No findings |
167175
| `GUARD001_INTERNAL_ERROR` | analysis | deny | Internal guard failure (fail closed) |
168176

@@ -272,19 +280,20 @@ never emitted as span attributes or metric labels.
272280
| 0 | Final decision was ``allow`` |
273281
| 2 | Final decision was ``deny`` |
274282
| 3 | Final decision was ``needs_human_review`` |
275-
| 4 | Invalid input / policy error |
283+
| 4 | Invalid input, policy, or required-audit error |
276284

277285
## Integration with the SDK
278286

279-
The standalone package is duck-typed to ``BaseFilter`` so it can be
280-
composed with the framework's filter runner once the terminal-phase
281-
ordering seam is exposed. Until then, wrap the executor or callable
282-
explicitly via ``tool.wrapper``.
287+
The current SDK does not expose a terminal filter phase after
288+
``ToolCallbackFilter``. A configured ``filters=`` instance can therefore
289+
scan arguments that a later callback changes; it is not a secure enforcement
290+
point. Use ``SafetyWrappedCallable`` or ``SafetyCheckedExecutor`` today:
291+
both scan, await the audit event, and only then invoke their delegate.
283292

284-
The :class:`ToolScriptSafetyFilter` carries a ``terminal_before_handler``
285-
attribute (always ``True``). When the framework adopts the terminal
286-
phase marker, the filter will automatically run after
287-
``ToolCallbackFilter`` and prevent TOCTOU mutations.
293+
``ToolScriptSafetyFilter`` provides matching ``_before``/``_after`` hooks
294+
and a ``terminal_before_handler`` marker for a future SDK terminal phase.
295+
That marker is metadata only until the framework implements ordering after
296+
all argument-mutating callbacks. The wrapper remains mandatory until then.
288297

289298
## Custom rules
290299

@@ -324,6 +333,10 @@ Rules must be pure: no file I/O, network access, or process creation.
324333
* **Runtime downloads.** A script that downloads and executes a payload
325334
in two stages defeats static analysis. Network egress policy and
326335
sandboxing are mandatory.
336+
* **Runtime limits.** The wrapper validates the declared timeout and caps
337+
returned output, but it cannot impose CPU, memory, PID, file-size, or
338+
network limits on an arbitrary executor. Configure those in the sandbox
339+
or CodeExecutor runtime as well.
327340
* **Shell grammar holes.** The bash lexer-lite is conservative: any
328341
unbalanced quote or unsupported substitution becomes
329342
``PARSE001_UNCERTAIN``.

scripts/tool_safety_check.py

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
sys.path.insert(0, str(_REPO_ROOT))
3838

3939
from tool.safety._audit import InMemoryAuditSink, JsonlAuditSink # noqa: E402
40+
from tool.safety._exceptions import SafetyAuditError # noqa: E402
4041
from tool.safety._guard import ToolSafetyGuard # noqa: E402
4142
from tool.safety._models import ( # noqa: E402
4243
SafetyDecision,
@@ -59,11 +60,15 @@ def main(argv: Sequence[str] | None = None) -> int:
5960
return 4
6061
guard = ToolSafetyGuard(policy)
6162
audit_sink = _resolve_audit_sink(args, policy)
62-
if args.manifest:
63-
return _run_manifest(guard, audit_sink, args)
64-
if args.request_json:
65-
return _run_request_json(guard, audit_sink, args)
66-
return _run_single(guard, audit_sink, args)
63+
try:
64+
if args.manifest:
65+
return _run_manifest(guard, audit_sink, args)
66+
if args.request_json:
67+
return _run_request_json(guard, audit_sink, args)
68+
return _run_single(guard, audit_sink, args)
69+
except SafetyAuditError as exc:
70+
print(f"audit error: {exc}", file=sys.stderr)
71+
return 4
6772

6873

6974
def _build_parser() -> argparse.ArgumentParser:
@@ -165,7 +170,13 @@ def _run_manifest(guard: ToolSafetyGuard,
165170
"matches_expected": _decision_matches(report, expected),
166171
})
167172
blocked = report.decision != SafetyDecision.ALLOW
168-
asyncio.run(_emit_audit(audit_sink, report, request, blocked=blocked))
173+
asyncio.run(_emit_audit(
174+
audit_sink,
175+
report,
176+
request,
177+
blocked=blocked,
178+
required=guard.policy.audit.required,
179+
))
169180
exit_code = max(exit_code, _exit_for_decision(report.decision))
170181
if args.manifest_output:
171182
with open(args.manifest_output, "w", encoding="utf-8") as handle:
@@ -182,8 +193,13 @@ def _emit(guard: ToolSafetyGuard,
182193
args: argparse.Namespace,
183194
request: SafetyScanRequest) -> int:
184195
report = guard.scan(request)
185-
asyncio.run(_emit_audit(audit_sink, report, request,
186-
blocked=report.decision != SafetyDecision.ALLOW))
196+
asyncio.run(_emit_audit(
197+
audit_sink,
198+
report,
199+
request,
200+
blocked=report.decision != SafetyDecision.ALLOW,
201+
required=guard.policy.audit.required,
202+
))
187203
payload = report.model_dump_json(indent=2)
188204
if args.output:
189205
with open(args.output, "w", encoding="utf-8") as handle:
@@ -193,7 +209,8 @@ def _emit(guard: ToolSafetyGuard,
193209

194210

195211
async def _emit_audit(audit_sink: Any, report: SafetyReport,
196-
request: SafetyScanRequest, *, blocked: bool) -> None:
212+
request: SafetyScanRequest, *, blocked: bool,
213+
required: bool) -> None:
197214
import datetime as _dt
198215
event = build_audit_event(
199216
report=report,
@@ -205,8 +222,11 @@ async def _emit_audit(audit_sink: Any, report: SafetyReport,
205222
try:
206223
await audit_sink.emit(event)
207224
except Exception as exc:
208-
# Audit failures should not crash the CLI; surface them on stderr.
209225
print(f"audit emit warning: {exc}", file=sys.stderr)
226+
if required:
227+
if isinstance(exc, SafetyAuditError):
228+
raise
229+
raise SafetyAuditError("unexpected audit emit failure") from exc
210230

211231

212232
def _build_request(args: argparse.Namespace) -> SafetyScanRequest:

tests/tool_safety/test_bash_scanner.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,12 @@ def test_pip_install_denies(guard):
6060
assert report.decision == SafetyDecision.DENY
6161

6262

63+
def test_python_module_pip_install_denies(guard):
64+
report = scan(guard, "python -m pip install numpy\n")
65+
assert "DEP001_ENV_MUTATION" in report.rule_ids
66+
assert report.decision == SafetyDecision.DENY
67+
68+
6369
def test_apt_install_denies(guard):
6470
report = scan(guard, "apt-get install -y curl\n")
6571
assert "DEP001_ENV_MUTATION" in report.rule_ids
@@ -101,6 +107,29 @@ def test_dynamic_eval_review(guard):
101107
assert "OBF001_DYNAMIC_EXEC" in report.rule_ids
102108

103109

110+
@pytest.mark.parametrize("script", [
111+
"source ./payload.sh\n",
112+
"xargs -a commands.txt sh\n",
113+
r"find . -exec sh {} \;\n",
114+
])
115+
def test_indirect_execution_requires_review(guard, script):
116+
report = scan(guard, script)
117+
assert "OBF001_DYNAMIC_EXEC" in report.rule_ids
118+
assert report.decision == SafetyDecision.NEEDS_HUMAN_REVIEW
119+
120+
121+
@pytest.mark.parametrize("script", [
122+
"python payload.py\n",
123+
"node payload.js\n",
124+
"bash -x payload.sh\n",
125+
"python -m untrusted_module\n",
126+
])
127+
def test_interpreter_payload_requires_review(guard, script):
128+
report = scan(guard, script)
129+
assert "OBF001_DYNAMIC_EXEC" in report.rule_ids
130+
assert report.decision == SafetyDecision.NEEDS_HUMAN_REVIEW
131+
132+
104133
def test_shebang_and_comment_ignored(guard):
105134
report = scan(guard, "#!/usr/bin/env bash\n# comment\necho hi\n")
106135
assert report.decision == SafetyDecision.ALLOW

tests/tool_safety/test_cli.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,16 @@ def test_invalid_policy_exit_4(tmp_path):
5959
assert proc.returncode == 4
6060

6161

62+
def test_required_audit_failure_exits_4(tmp_path):
63+
rc, out, err = _run_cli(
64+
"--language", "python",
65+
"--script", "print('hello')",
66+
"--audit-file", str(tmp_path),
67+
)
68+
assert rc == 4
69+
assert "audit error:" in err
70+
71+
6272
def test_manifest_writes_output(tmp_path):
6373
manifest = REPO_ROOT / "examples" / "tool_safety" / "samples" / "manifest.yaml"
6474
output = tmp_path / "out.json"

tests/tool_safety/test_cross_field_scanner.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,16 +61,16 @@ def test_denied_executable_in_argv_blocks(guard):
6161
assert report.decision == SafetyDecision.DENY
6262

6363

64-
def test_sensitive_env_triggers_review(guard):
64+
def test_unused_sensitive_env_does_not_trigger_review(guard):
6565
request = SafetyScanRequest(
6666
tool_name="t",
6767
language=ScriptLanguage.PYTHON,
6868
script="print('hi')",
6969
env={"API_TOKEN": "abc"},
7070
)
7171
report = guard.scan(request)
72-
assert "SECRET001_LOG_SINK" in report.rule_ids
73-
assert report.decision == SafetyDecision.NEEDS_HUMAN_REVIEW
72+
assert "SECRET001_LOG_SINK" not in report.rule_ids
73+
assert report.decision == SafetyDecision.ALLOW
7474

7575

7676
def test_output_budget_enforced(guard):

tests/tool_safety/test_filter.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
from __future__ import annotations
44

5+
import asyncio
6+
from types import SimpleNamespace
7+
58
import pytest
69

710
from tool.safety._audit import InMemoryAuditSink
@@ -56,6 +59,30 @@ def test_audit_event_one_per_call(flt):
5659
assert len(flt.audit_sink.events) == 3 # type: ignore[attr-defined]
5760

5861

62+
def test_check_async_persists_audit_before_returning(flt):
63+
async def run():
64+
decision, _ = await flt.check_async(
65+
"workspace_exec", {"command": "echo hi"})
66+
assert decision == SafetyDecision.ALLOW
67+
assert len(flt.audit_sink.events) == 1 # type: ignore[attr-defined]
68+
69+
asyncio.run(run())
70+
71+
72+
def test_filter_audits_request_build_failure(flt):
73+
rsp = {}
74+
75+
async def run():
76+
await flt._before(
77+
SimpleNamespace(tool_name="workspace_exec"), {}, rsp)
78+
79+
asyncio.run(run())
80+
81+
assert rsp["is_continue"] is False
82+
assert len(flt.audit_sink.events) == 1 # type: ignore[attr-defined]
83+
assert flt.audit_sink.events[0].decision == SafetyDecision.DENY # type: ignore[attr-defined]
84+
85+
5986
def test_filter_redacts_env_in_trace(flt):
6087
flt.check("workspace_exec", {
6188
"command": "echo hi",

tests/tool_safety/test_python_scanner.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,26 @@ def test_allowlist_network_allows(guard):
6363
assert report.decision == SafetyDecision.ALLOW
6464

6565

66+
def test_ip_literal_policy_can_block_an_allowlisted_ip(strict_policy_dict):
67+
policy_dict = strict_policy_dict.copy()
68+
policy_dict["network"] = {
69+
"allow_domains": ["127.0.0.1"],
70+
"deny_ip_literals": True,
71+
}
72+
blocked_guard = ToolSafetyGuard(load_safety_policy_dict(policy_dict))
73+
script = "import requests\nrequests.get('http://127.0.0.1:8080')\n"
74+
blocked = scan(blocked_guard, script)
75+
assert "NET003_IP_LITERAL" in blocked.rule_ids
76+
assert blocked.decision == SafetyDecision.DENY
77+
78+
policy_dict["network"] = {
79+
"allow_domains": ["127.0.0.1"],
80+
"deny_ip_literals": False,
81+
}
82+
allowed_guard = ToolSafetyGuard(load_safety_policy_dict(policy_dict))
83+
assert scan(allowed_guard, script).decision == SafetyDecision.ALLOW
84+
85+
6686
def test_fstring_allowlist_network_allows(guard):
6787
script = "import requests\nrequests.get(f'https://api.github.com/users/{name}')\n"
6888
report = scan(guard, script)
@@ -108,13 +128,36 @@ def test_long_sleep_denies(guard):
108128
assert report.decision == SafetyDecision.DENY
109129

110130

131+
def test_process_limit_uses_max_processes(strict_policy_dict):
132+
policy_dict = strict_policy_dict.copy()
133+
policy_dict["limits"] = dict(policy_dict["limits"])
134+
policy_dict["limits"]["max_parallel_tasks"] = 10
135+
policy_dict["limits"]["max_processes"] = 2
136+
g = ToolSafetyGuard(load_safety_policy_dict(policy_dict))
137+
report = scan(
138+
g,
139+
"import multiprocessing\nmultiprocessing.Pool(processes=3)\n",
140+
)
141+
assert "RES004_CONCURRENCY" in report.rule_ids
142+
assert report.decision == SafetyDecision.DENY
143+
144+
111145
def test_secret_to_print_denies(guard):
112146
script = "import os\nv=os.environ['API_TOKEN']\nprint(v)\n"
113147
report = scan(guard, script)
114148
assert "SECRET001_LOG_SINK" in report.rule_ids
115149
assert report.decision == SafetyDecision.DENY
116150

117151

152+
def test_literal_api_key_to_print_denies_and_redacts(guard):
153+
secret = "sk-ABCDEFGHIJKLMNOPQRSTUVWXYZ123456"
154+
report = scan(guard, f"print('{secret}')\n")
155+
assert "SECRET001_LOG_SINK" in report.rule_ids
156+
assert report.decision == SafetyDecision.DENY
157+
assert report.redacted is True
158+
assert secret not in report.model_dump_json()
159+
160+
118161
def test_secret_to_log_denies(guard):
119162
script = (
120163
"import os, logging\n"

0 commit comments

Comments
 (0)