Replaces PR #3493's blanket fatal abort with a "tell the model + throttle the bypass loop" policy. Workspace-bound rejections are now ordinary recoverable tool errors enriched with a structured "this is a hard policy boundary" instruction; SSRF stays the only marker that aborts the turn. Why the fatal-abort approach broke ---------------------------------- PR #3493 promoted every shell `_guard_command` and filesystem path-resolution rejection to a turn-fatal RuntimeError. Two of those messages (`path outside working dir` and `path traversal detected`) are heuristic substring scans on the raw command, so legitimate commands like `rm <ws>/x.txt 2>/dev/null` or `find . -type f` killed the user's turn (#3599). On channels with outbound dedupe (Telegram) the user just saw silence (#3605), and the noise polluted the LLM's context until it started hallucinating guard rejections on plain relative paths (#3597). Why we still need *some* throttle --------------------------------- The original #3493 pain point was real: the LLM, refused once, would swap tools and try again -- read_file -> exec cat -> exec cp -> bash -c -> ln -sf -> python -c open(...). Just removing the fatal escape lets that loop run wild until max_iterations. What this commit does --------------------- - `nanobot/utils/runtime.py`: add `workspace_violation_signature` and `repeated_workspace_violation_error`. The signature normalizes filesystem `path` arguments and the first absolute path inside an exec command, so swapping tools against the same outside target hits the same throttle bucket. Two soft attempts are allowed; the third attempt's tool result is replaced with a hard "stop trying to bypass" message that quotes the target path and tells the model to ask the user for help. - `nanobot/agent/runner.py`: split classification into `_is_ssrf_violation` (still fatal) and `_is_workspace_violation` (now soft). All three failure branches in `_run_tool` (prep_error / exception / Error result) route through a shared `_classify_violation` that bumps the per-turn workspace_violation_counts dict and either keeps the tool's own message or substitutes the throttle escalation. `_execute_tools` now threads that dict alongside the existing external_lookup_counts. - `nanobot/agent/tools/shell.py`: append a structured boundary note to every workspace-bound guard rejection (`working_dir could not be resolved`, `working_dir is outside`, `path outside working dir`, `path traversal detected`). SSRF errors stay short and direct so the model doesn't try to "phrase around" them. Existing `2>/dev/null` allow-list and benign device passthrough from the previous commit remain. - `nanobot/agent/tools/filesystem.py`: append the same boundary note to the `outside allowed directory` PermissionError so read_file / write_file / list_dir errors give the LLM the same explicit hint. Tests ----- - `tests/utils/test_workspace_violation_throttle.py` (new): signature collapses across read_file/exec/python -c against the same path, different paths get independent budgets, escalation only fires after the third attempt. - `tests/agent/test_runner.py`: - `test_runner_does_not_abort_on_workspace_violation_anymore` -- v2 contract: filesystem PermissionError is now soft, runner moves to the next iteration and finalizes cleanly. - `test_is_ssrf_violation_remains_fatal` + the existing `test_runner_aborts_on_ssrf_violation` -- SSRF still aborts on the first attempt. - `test_runner_lets_llm_recover_from_shell_guard_path_outside` -- end to end recovery from `path outside working dir`. - `test_runner_throttles_repeated_workspace_bypass_attempts` -- four bypass attempts against the same outside target produce at least one `workspace_violation_escalated` event and the run completes naturally without aborting the turn. - The two `_execute_tools` direct-call tests now pass the new workspace_violation_counts dict. - `tests/tools/test_tool_validation.py`: relax three `==` assertions to `startswith` + "hard policy boundary" substring check to match the new structured error messages. - `tests/tools/test_exec_security.py` keeps the prior `2>/dev/null` regression and the `> /etc/issue` negative case from the previous commit on this branch -- they still pass under the new policy. Coverage status: full pytest 2648 passed / 2 skipped (was 2638 / 2 on origin/main). Ruff is clean for every file touched in this commit. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
committed by
Xubin Ren
co-authored by
Cursor
parent
7742f8fbdc
commit
b8406be215
@@ -2,6 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
@@ -10,6 +12,14 @@ from nanobot.utils.helpers import stringify_text_blocks
|
||||
|
||||
_MAX_REPEAT_EXTERNAL_LOOKUPS = 2
|
||||
|
||||
# Workspace-violation throttle: how many times the LLM is allowed to bump
|
||||
# against the same outside-workspace target *within a single turn* before the
|
||||
# tool result is escalated with a hard "stop trying to bypass the policy"
|
||||
# instruction. Two free attempts give the model room to e.g. read_file then
|
||||
# fall back to exec without immediately escalating; the third attempt at the
|
||||
# same target is treated as a clear bypass loop.
|
||||
_MAX_REPEAT_WORKSPACE_VIOLATIONS = 2
|
||||
|
||||
EMPTY_FINAL_RESPONSE_MESSAGE = (
|
||||
"I completed the tool steps but couldn't produce a final answer. "
|
||||
"Please try again or narrow the task."
|
||||
@@ -95,3 +105,108 @@ def repeated_external_lookup_error(
|
||||
"Error: repeated external lookup blocked. "
|
||||
"Use the results you already have to answer, or try a meaningfully different source."
|
||||
)
|
||||
|
||||
|
||||
# --- Workspace-violation throttle --------------------------------------------
|
||||
#
|
||||
# When ``restrict_to_workspace`` is on and the LLM tries to read or exec
|
||||
# something outside of the workspace, we want to *tell* the model that it
|
||||
# hit a hard policy boundary -- not silently abort the whole turn and not
|
||||
# allow it to spin forever swapping ``read_file`` for ``exec cat`` for
|
||||
# ``python -c open(...)`` (the actual loop reported in #3493). The strategy
|
||||
# is two-fold:
|
||||
#
|
||||
# 1. Each individual guard error already includes structured instructions
|
||||
# that tell the model "don't try to bypass this; ask the user for help".
|
||||
# 2. We additionally count how many times the *same outside target* has
|
||||
# been refused within the current turn. After two free attempts the
|
||||
# third refusal swaps in a much more forceful message that quotes the
|
||||
# target path and explicitly orders the model to stop and surface the
|
||||
# boundary back to the user. The model is still free to do something
|
||||
# else (different target, different question) -- only the bypass loop
|
||||
# is interrupted.
|
||||
#
|
||||
# This intentionally does *not* fatal-abort the turn: max_iterations and
|
||||
# the empty-final-response retries already provide the ultimate ceiling
|
||||
# for runaway loops, and aborting is what produced the silent-hang bug
|
||||
# in #3605 in the first place.
|
||||
|
||||
_OUTSIDE_PATH_PATTERN = re.compile(r"(?:^|[\s|>'\"])((?:/[^\s\"'>;|<]+)|(?:~[^\s\"'>;|<]+))")
|
||||
|
||||
|
||||
def workspace_violation_signature(
|
||||
tool_name: str,
|
||||
arguments: dict[str, Any],
|
||||
) -> str | None:
|
||||
"""Return a stable signature for the outside-workspace target a tool tried.
|
||||
|
||||
The signature is shared across tool names so that the LLM cannot bypass
|
||||
the throttle by switching from ``read_file`` to ``exec cat`` to
|
||||
``python -c open(...)`` against the same path. Returns ``None`` when
|
||||
the call has no obvious outside target (e.g. SSRF rejections, deny
|
||||
pattern hits, or tools whose argument shape we don't understand).
|
||||
"""
|
||||
for key in ("path", "file_path", "target", "source", "destination"):
|
||||
val = arguments.get(key)
|
||||
if isinstance(val, str) and val.strip():
|
||||
return _normalize_violation_target(val.strip())
|
||||
|
||||
if tool_name in {"exec", "shell"}:
|
||||
cmd = str(arguments.get("command") or "").strip()
|
||||
if cmd:
|
||||
match = _OUTSIDE_PATH_PATTERN.search(cmd)
|
||||
if match:
|
||||
return _normalize_violation_target(match.group(1))
|
||||
cwd = str(arguments.get("working_dir") or "").strip()
|
||||
if cwd:
|
||||
return _normalize_violation_target(cwd)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_violation_target(raw: str) -> str:
|
||||
"""Normalize *raw* path so that equivalent spellings collide on the same key."""
|
||||
try:
|
||||
normalized = str(Path(raw).expanduser().resolve())
|
||||
except Exception:
|
||||
normalized = raw
|
||||
return f"violation:{normalized}".lower()
|
||||
|
||||
|
||||
def repeated_workspace_violation_error(
|
||||
tool_name: str,
|
||||
arguments: dict[str, Any],
|
||||
seen_counts: dict[str, int],
|
||||
) -> str | None:
|
||||
"""Return an escalated error string after repeated bypass attempts.
|
||||
|
||||
Returns ``None`` while the LLM is still within the soft retry budget --
|
||||
callers should fall back to the tool's own error message in that case.
|
||||
Once the budget is exceeded, returns a hard "stop trying" instruction
|
||||
that quotes the offending target. Throttle state lives in
|
||||
*seen_counts* (a per-turn dict), so the budget naturally resets across
|
||||
turns without persisting LLM-controlled keys.
|
||||
"""
|
||||
signature = workspace_violation_signature(tool_name, arguments)
|
||||
if signature is None:
|
||||
return None
|
||||
count = seen_counts.get(signature, 0) + 1
|
||||
seen_counts[signature] = count
|
||||
if count <= _MAX_REPEAT_WORKSPACE_VIOLATIONS:
|
||||
return None
|
||||
logger.warning(
|
||||
"Escalating repeated workspace bypass attempt {} (attempt {})",
|
||||
signature[:160],
|
||||
count,
|
||||
)
|
||||
target = signature.split("violation:", 1)[1] if "violation:" in signature else signature
|
||||
return (
|
||||
"Error: refusing repeated workspace-bypass attempts.\n"
|
||||
f"You have tried to access '{target}' (or an equivalent path) "
|
||||
f"{count} times in this turn. This is a hard policy boundary -- "
|
||||
"switching tools, shell tricks, working_dir overrides, symlinks, "
|
||||
"or base64 piping will NOT change the answer. Stop retrying. "
|
||||
"If the user genuinely needs this resource, tell them you cannot "
|
||||
"access it and ask how they want to proceed (e.g. copy the file "
|
||||
"into the workspace, or disable restrict_to_workspace for this run)."
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user