Skip to content

Commit dfc7e57

Browse files
nanobotclaude
andcommitted
merge: 同步上游 HKUDS/nanobot 3 commits
上游新提交: - refactor(setup): enhance SKILL.md for upgrade process clarity - fix: improve media failure diagnostics and token fallback coverage - fix: allow_patterns take priority over deny_patterns in ExecTool (HKUDS#3594) 冲突解决: - shell.py: 保留我们的 rm -rf 移除,采用上游新增的 deny patterns Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2 parents 8936736 + 5853d5d commit dfc7e57

12 files changed

Lines changed: 194 additions & 19 deletions

File tree

nanobot/agent/loop.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,8 @@ def _register_default_tools(self) -> None:
380380
sandbox=self.exec_config.sandbox,
381381
path_append=self.exec_config.path_append,
382382
allowed_env_keys=self.exec_config.allowed_env_keys,
383+
allow_patterns=self.exec_config.allow_patterns,
384+
deny_patterns=self.exec_config.deny_patterns,
383385
)
384386
)
385387
if self.web_config.enable:

nanobot/agent/runner.py

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

814814
# Markers identifying tool results that represent a workspace / safety boundary rejection.
815815
_WORKSPACE_BLOCK_MARKERS: tuple[str, ...] = (
816-
"blocked by safety guard",
817816
"outside the configured workspace",
818817
"outside allowed directory",
819818
"working_dir is outside",
820819
"working_dir could not be resolved",
821820
"path traversal detected",
822821
"path outside working dir",
822+
"internal/private url detected",
823823
)
824824

825825
@classmethod

nanobot/agent/subagent.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,8 @@ async def _on_checkpoint(payload: dict) -> None:
191191
sandbox=self.exec_config.sandbox,
192192
path_append=self.exec_config.path_append,
193193
allowed_env_keys=self.exec_config.allowed_env_keys,
194+
allow_patterns=self.exec_config.allow_patterns,
195+
deny_patterns=self.exec_config.deny_patterns,
194196
))
195197
if self.web_config.enable:
196198
tools.register(

nanobot/agent/tools/shell.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -272,13 +272,19 @@ def _guard_command(self, command: str, cwd: str) -> str | None:
272272
cmd = command.strip()
273273
lower = cmd.lower()
274274

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

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

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

nanobot/channels/slack.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -435,9 +435,9 @@ async def _download_slack_file(self, file_info: dict[str, Any]) -> tuple[str | N
435435
marker = f"[{marker_type}: {name}]"
436436
url = str(file_info.get("url_private_download") or file_info.get("url_private") or "")
437437
if not url:
438-
return None, f"[{marker_type}: {name}: missing download url]"
438+
return None, self._download_failure_marker(marker_type, name, "missing download url")
439439
if not self.config.bot_token:
440-
return None, f"[{marker_type}: {name}: missing bot token]"
440+
return None, self._download_failure_marker(marker_type, name, "missing bot token")
441441

442442
filename = safe_filename(f"{file_id}_{name}")
443443
path = Path(get_media_dir("slack")) / filename
@@ -454,7 +454,14 @@ async def _download_slack_file(self, file_info: dict[str, Any]) -> tuple[str | N
454454
return str(path), marker
455455
except Exception as e:
456456
logger.warning("Failed to download Slack file {}: {}", file_id, e)
457-
return None, f"[{marker_type}: {name}: download failed]"
457+
return None, self._download_failure_marker(marker_type, name, "download failed")
458+
459+
@staticmethod
460+
def _download_failure_marker(marker_type: str, name: str, reason: str) -> str:
461+
return (
462+
f"[{marker_type}: {name}: {reason}; not available to nanobot. "
463+
"Check Slack files:read scope, reinstall the Slack app, and ensure the bot can access the file.]"
464+
)
458465

459466
@staticmethod
460467
def _looks_like_html_download(response: httpx.Response) -> bool:

nanobot/config/schema.py

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

236238
class MCPServerConfig(Base):
237239
"""MCP server connection configuration (stdio or HTTP)."""

nanobot/skills/update-setup/SKILL.md

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,21 +13,45 @@ Use `read_file` to check if `skills/update/SKILL.md` already exists in the works
1313

1414
If it exists, use `ask_user` to ask: "An upgrade skill already exists. Reconfigure?" with options ["yes", "no"]. If no, stop here.
1515

16-
## Step 2: Current Version
16+
## Step 2: Current Version and Install Clues
1717

1818
Use `exec` to run `nanobot --version`. Tell the user the current version.
1919

20-
## Step 3: Ask Questions
20+
Then collect install clues with `exec`. These commands are best-effort; if one fails,
21+
keep going and show the useful output:
2122

22-
Use `ask_user` for the questions below, one question per call.
23+
```
24+
command -v nanobot || true
25+
python -m pip show nanobot-ai || true
26+
pipx list | sed -n '/nanobot-ai/,+3p' || true
27+
uv tool list | sed -n '/nanobot-ai/,+3p' || true
28+
```
29+
30+
Summarize what you found in one short paragraph. Use the clues only to suggest a
31+
likely install method. Do not treat them as confirmation.
32+
33+
## Step 3: Confirm Required Inputs
34+
35+
CRITICAL: Do not write `skills/update/SKILL.md` until the install method is
36+
explicitly confirmed by the user. The install method must come from a user
37+
answer or confirmation, not from inference alone. If you cannot get a clear
38+
answer, stop and ask the user to rerun this setup when they know how nanobot was
39+
installed.
40+
41+
Use `ask_user` for the questions below, one question per call. If `ask_user` is
42+
not available or cannot collect the answer, ask in normal chat and stop without
43+
writing the skill.
2344

2445
**Question 1 — Install method:**
2546

2647
```
27-
question: "How did you install nanobot?"
28-
options: ["uv", "pipx", "pip", "source (git clone)"]
48+
question: "I found these install clues: <SUMMARY>. Which update method should this workspace use?"
49+
options: ["uv", "pipx", "pip", "source (git clone)", "not sure"]
2950
```
3051

52+
If the user selected `not sure`, explain the difference between the options and
53+
stop. Do not generate the upgrade skill.
54+
3155
If the user selected `source (git clone)`, ask for the local checkout path:
3256
`question: "Where is your nanobot source checkout? Enter an absolute path or a path relative to this workspace:"`.
3357

@@ -63,6 +87,18 @@ Determine the upgrade command from the install method:
6387

6488
For source installs, include extras in the editable install command when selected. Quote the source checkout path if it contains spaces.
6589

90+
Determine the preflight check from the install method:
91+
92+
| Method | Preflight check |
93+
|--------|-----------------|
94+
| uv | `command -v uv` |
95+
| pipx | `command -v pipx` |
96+
| pip | `python -m pip --version` |
97+
| source | `test -d <SOURCE_CHECKOUT> && test -d <SOURCE_CHECKOUT>/.git && test -f <SOURCE_CHECKOUT>/pyproject.toml` |
98+
99+
For source installs, quote the source checkout path in the preflight check if it
100+
contains spaces.
101+
66102
Build the skill content. If proxy is configured, add `export http_proxy=URL` and `export https_proxy=URL` lines before the upgrade command.
67103

68104
Use `write_file` to write `skills/update/SKILL.md` with this content:
@@ -76,9 +112,10 @@ description: "Upgrade nanobot to the latest version. Triggers: upgrade nanobot,
76112
# Update Nanobot
77113
78114
1. (If proxy configured) Set proxy: `export http_proxy=URL && export https_proxy=URL`
79-
2. Use `exec` to run the upgrade command: <UPGRADE_COMMAND>
80-
3. Use `exec` to verify: `nanobot --version`
81-
4. Tell the user the new version. Say: "Run `/restart` to restart nanobot and apply the update. If `/restart` is unavailable in this channel, restart the nanobot process manually."
115+
2. Use `exec` to run the preflight check: <PREFLIGHT_CHECK>. If it fails, stop and tell the user to rerun `update-setup` because the saved install method no longer matches this environment.
116+
3. Use `exec` to run the upgrade command: <UPGRADE_COMMAND>
117+
4. Use `exec` to verify: `nanobot --version`
118+
5. Tell the user the new version. Say: "Run `/restart` to restart nanobot and apply the update. If `/restart` is unavailable in this channel, restart the nanobot process manually."
82119
```
83120

84121
## Step 5: Confirm

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

tests/channels/test_slack_channel.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -643,6 +643,14 @@ def test_slack_download_rejects_login_html() -> None:
643643
assert SlackChannel._looks_like_html_download(markdown_response) is False
644644

645645

646+
def test_slack_download_failure_marker_is_actionable() -> None:
647+
marker = SlackChannel._download_failure_marker("image", "screenshot.png", "download failed")
648+
649+
assert "not available to nanobot" in marker
650+
assert "files:read" in marker
651+
assert "reinstall the Slack app" in marker
652+
653+
646654
def test_slack_channel_uses_channel_aware_allow_policy() -> None:
647655
channel = SlackChannel(SlackConfig(enabled=True, allow_from=[]), MessageBus())
648656
assert channel.is_allowed("U1") is True
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()

0 commit comments

Comments
 (0)