Skip to content

Commit 5853d5d

Browse files
chengyongruRe-bincursoragent
authored
fix: allow_patterns take priority over deny_patterns in ExecTool (HKUDS#3594)
* fix: allow_patterns take priority over deny_patterns in ExecTool Previously deny_patterns were checked first with no bypass, meaning allow_patterns could never exempt commands from the built-in deny list. This made it impossible to whitelist destructive commands for specific directories (e.g. build/cleanup tasks). Changes: - shell.py: check allow_patterns first; if matched, skip deny check - shell.py: deny_patterns now appends to built-in list (not replaces) - schema.py: add allow_patterns/deny_patterns to ExecToolConfig - loop.py/subagent.py: pass allow_patterns/deny_patterns to ExecTool - Add test_exec_allow_patterns.py covering priority semantics * fix: separate deny pattern errors from workspace violation detection The deny pattern error message "Command blocked by safety guard" was included in _WORKSPACE_BLOCK_MARKERS, causing deny_pattern blocks to be misclassified as fatal workspace violations. This meant LLMs had no chance to retry with a different command — the turn was aborted immediately. Changes: - shell.py: deny/allowlist error messages now use distinct phrasing ("blocked by deny pattern filter" / "blocked by allowlist filter") - runner.py: remove "blocked by safety guard" from _WORKSPACE_BLOCK_MARKERS so deny_pattern errors are treated as normal tool errors (LLM can retry) instead of fatal violations - workspace path errors still use "blocked by safety guard" and remain fatal as intended * fix: update test assertions to match new deny pattern error message * fix: indentation error in test file * fix: restore SSRF fatal classification and tidy exec pattern plumbing Address review feedback on the deny/allow_patterns rework: - runner.py: re-add "internal/private url detected" to _WORKSPACE_BLOCK_MARKERS. The earlier marker removal also stripped fatal classification from SSRF / internal-URL rejections (whose message still says "blocked by safety guard"), turning a hard security boundary into something the LLM could retry. - loop.py / subagent.py: drop `or None` between ExecToolConfig and ExecTool. The schema default is an empty list and ExecTool already normalizes None back to [], so the indirection was a no-op. - shell.py: extract `explicitly_allowed` flag in _guard_command so allow_patterns are scanned once instead of twice and the control flow no longer relies on a no-op `pass + else` branch. - tests/agent/test_runner.py: add a regression test asserting that the SSRF block message is treated as fatal, while deny/allowlist filter messages are deliberately non-fatal. * fix: remove unused exec allow-pattern test import Keep the new ExecTool allow-pattern coverage clean under ruff. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Xubin Ren <xubinrencs@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 2fa15cc commit 5853d5d

8 files changed

Lines changed: 100 additions & 9 deletions

File tree

nanobot/agent/loop.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,8 @@ def _register_default_tools(self) -> None:
374374
sandbox=self.exec_config.sandbox,
375375
path_append=self.exec_config.path_append,
376376
allowed_env_keys=self.exec_config.allowed_env_keys,
377+
allow_patterns=self.exec_config.allow_patterns,
378+
deny_patterns=self.exec_config.deny_patterns,
377379
)
378380
)
379381
if self.web_config.enable:

nanobot/agent/runner.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -833,13 +833,13 @@ async def _run_tool(
833833

834834
# Markers identifying tool results that represent a workspace / safety boundary rejection.
835835
_WORKSPACE_BLOCK_MARKERS: tuple[str, ...] = (
836-
"blocked by safety guard",
837836
"outside the configured workspace",
838837
"outside allowed directory",
839838
"working_dir is outside",
840839
"working_dir could not be resolved",
841840
"path traversal detected",
842841
"path outside working dir",
842+
"internal/private url detected",
843843
)
844844

845845
@classmethod

nanobot/agent/subagent.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,8 @@ async def _on_checkpoint(payload: dict) -> None:
188188
sandbox=self.exec_config.sandbox,
189189
path_append=self.exec_config.path_append,
190190
allowed_env_keys=self.exec_config.allowed_env_keys,
191+
allow_patterns=self.exec_config.allow_patterns,
192+
deny_patterns=self.exec_config.deny_patterns,
191193
))
192194
if self.web_config.enable:
193195
tools.register(

nanobot/agent/tools/shell.py

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ def __init__(
5252
self.timeout = timeout
5353
self.working_dir = working_dir
5454
self.sandbox = sandbox
55-
self.deny_patterns = deny_patterns or [
55+
self.deny_patterns = (deny_patterns or []) + [
5656
r"\brm\s+-[rf]{1,2}\b", # rm -r, rm -rf, rm -fr
5757
r"\bdel\s+/[fq]\b", # del /f, del /q
5858
r"\brmdir\s+/s\b", # rmdir /s
@@ -273,13 +273,19 @@ def _guard_command(self, command: str, cwd: str) -> str | None:
273273
cmd = command.strip()
274274
lower = cmd.lower()
275275

276-
for pattern in self.deny_patterns:
277-
if re.search(pattern, lower):
278-
return "Error: Command blocked by safety guard (dangerous pattern detected)"
276+
# allow_patterns take priority over deny_patterns so that users can
277+
# exempt specific commands (e.g. "rm -rf" inside a build directory)
278+
# from the hardcoded deny list via configuration.
279+
explicitly_allowed = bool(self.allow_patterns) and any(
280+
re.search(p, lower) for p in self.allow_patterns
281+
)
282+
if not explicitly_allowed:
283+
for pattern in self.deny_patterns:
284+
if re.search(pattern, lower):
285+
return "Error: Command blocked by deny pattern filter"
279286

280-
if self.allow_patterns:
281-
if not any(re.search(p, lower) for p in self.allow_patterns):
282-
return "Error: Command blocked by safety guard (not in allowlist)"
287+
if self.allow_patterns:
288+
return "Error: Command blocked by allowlist filter (not in allowlist)"
283289

284290
from nanobot.security.network import contains_internal_url
285291
if contains_internal_url(cmd):

nanobot/config/schema.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,8 @@ class ExecToolConfig(Base):
223223
path_append: str = ""
224224
sandbox: str = "" # sandbox backend: "" (none) or "bwrap"
225225
allowed_env_keys: list[str] = Field(default_factory=list) # Env var names to pass through to subprocess (e.g. ["GOPATH", "JAVA_HOME"])
226+
allow_patterns: list[str] = Field(default_factory=list) # Regex patterns that bypass deny_patterns (e.g. [r"rm\s+-rf\s+/tmp/"])
227+
deny_patterns: list[str] = Field(default_factory=list) # Extra regex patterns to block (appended to built-in list)
226228

227229
class MCPServerConfig(Base):
228230
"""MCP server connection configuration (stdio or HTTP)."""

tests/agent/test_runner.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,27 @@ async def test_runner_stops_on_workspace_violation_without_fail_on_tool_error():
352352
]
353353

354354

355+
def test_is_workspace_violation_recognizes_ssrf_block():
356+
"""Internal/private URL block must be classified as a fatal workspace violation.
357+
358+
Regression guard: the deny/allowlist filter messages were intentionally split
359+
out of `_WORKSPACE_BLOCK_MARKERS` so the LLM can retry, but SSRF rejections
360+
are a hard security boundary and must remain fatal.
361+
"""
362+
from nanobot.agent.runner import AgentRunner
363+
364+
ssrf_msg = "Error: Command blocked by safety guard (internal/private URL detected)"
365+
assert AgentRunner._is_workspace_violation(ssrf_msg) is True
366+
367+
# Sanity: deny/allowlist filter messages are deliberately *not* fatal.
368+
assert AgentRunner._is_workspace_violation(
369+
"Error: Command blocked by deny pattern filter"
370+
) is False
371+
assert AgentRunner._is_workspace_violation(
372+
"Error: Command blocked by allowlist filter (not in allowlist)"
373+
) is False
374+
375+
355376
@pytest.mark.asyncio
356377
async def test_runner_persists_large_tool_results_for_follow_up_calls(tmp_path):
357378
from nanobot.agent.runner import AgentRunSpec, AgentRunner
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
"""Tests for allow_patterns priority over deny_patterns."""
2+
3+
from __future__ import annotations
4+
5+
from nanobot.agent.tools.shell import ExecTool
6+
7+
8+
def test_deny_patterns_block_rm_rf():
9+
"""Baseline: rm -rf is blocked by default deny list."""
10+
tool = ExecTool()
11+
result = tool._guard_command("rm -rf /tmp/build", "/tmp")
12+
assert result is not None
13+
assert "deny pattern filter" in result.lower()
14+
15+
16+
def test_allow_patterns_bypass_deny():
17+
"""allow_patterns take priority: matching command skips deny check."""
18+
tool = ExecTool(allow_patterns=[r"rm\s+-rf\s+/tmp/"])
19+
result = tool._guard_command("rm -rf /tmp/build", "/tmp")
20+
assert result is None
21+
22+
23+
def test_allow_patterns_must_match_to_bypass():
24+
"""Non-matching allow_patterns do NOT bypass deny."""
25+
tool = ExecTool(allow_patterns=[r"rm\s+-rf\s+/opt/"])
26+
result = tool._guard_command("rm -rf /tmp/build", "/tmp")
27+
assert result is not None
28+
assert "deny pattern filter" in result.lower()
29+
30+
31+
def test_extra_deny_patterns_from_config():
32+
"""User-supplied deny patterns are appended to built-in list."""
33+
tool = ExecTool(deny_patterns=[r"\bping\b"])
34+
# ping is blocked by extra deny
35+
assert tool._guard_command("ping example.com", "/tmp") is not None
36+
# rm -rf still blocked by built-in deny
37+
assert tool._guard_command("rm -rf /tmp/x", "/tmp") is not None
38+
39+
40+
def test_allow_patterns_bypass_extra_deny():
41+
"""allow_patterns also bypasses user-supplied deny patterns."""
42+
tool = ExecTool(
43+
deny_patterns=[r"\bping\b"],
44+
allow_patterns=[r"\bping\s+example\.com\b"],
45+
)
46+
result = tool._guard_command("ping example.com", "/tmp")
47+
assert result is None
48+
49+
50+
def test_allow_patterns_is_whitelist_only():
51+
"""When allow_patterns is set, non-matching non-denied commands are blocked."""
52+
tool = ExecTool(allow_patterns=[r"\becho\b"])
53+
# echo matches allow → ok
54+
assert tool._guard_command("echo hello", "/tmp") is None
55+
# ls does not match allow and is not in deny → blocked by allowlist
56+
result = tool._guard_command("ls /tmp", "/tmp")
57+
assert result is not None
58+
assert "allowlist" in result.lower()

tests/tools/test_exec_security.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ def test_exec_blocks_writes_to_history_jsonl(command):
9494
tool = ExecTool()
9595
result = tool._guard_command(command, "/tmp")
9696
assert result is not None
97-
assert "dangerous pattern" in result.lower()
97+
assert "deny pattern filter" in result.lower()
9898

9999

100100
@pytest.mark.parametrize(

0 commit comments

Comments
 (0)