fix(runner): soft workspace boundary + per-target throttle (#3493 #3599 #3605)

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:
Xubin Ren
2026-05-04 01:18:39 +08:00
committed by Xubin Ren
co-authored by Cursor
parent 7742f8fbdc
commit b8406be215
7 changed files with 585 additions and 117 deletions
+143 -56
View File
@@ -33,6 +33,7 @@ from nanobot.utils.runtime import (
ensure_nonempty_tool_result,
is_blank_text,
repeated_external_lookup_error,
repeated_workspace_violation_error,
)
_DEFAULT_ERROR_MESSAGE = "Sorry, I encountered an error calling the AI model."
@@ -239,6 +240,10 @@ class AgentRunner:
stop_reason = "completed"
tool_events: list[dict[str, str]] = []
external_lookup_counts: dict[str, int] = {}
# Tracks repeated bypass attempts against the same outside-workspace
# target within this turn. See ``repeated_workspace_violation_error``
# in ``nanobot.utils.runtime`` for the throttle policy.
workspace_violation_counts: dict[str, int] = {}
empty_content_retries = 0
length_recovery_count = 0
had_injections = False
@@ -314,6 +319,7 @@ class AgentRunner:
spec,
tool_calls,
external_lookup_counts,
workspace_violation_counts,
)
tool_events.extend(new_events)
context.tool_results = list(results)
@@ -698,20 +704,25 @@ class AgentRunner:
spec: AgentRunSpec,
tool_calls: list[ToolCallRequest],
external_lookup_counts: dict[str, int],
workspace_violation_counts: dict[str, int],
) -> tuple[list[Any], list[dict[str, str]], BaseException | None]:
batches = self._partition_tool_batches(spec, tool_calls)
tool_results: list[tuple[Any, dict[str, str], BaseException | None]] = []
for batch in batches:
if spec.concurrent_tools and len(batch) > 1:
batch_results = await asyncio.gather(*(
self._run_tool(spec, tool_call, external_lookup_counts)
self._run_tool(
spec, tool_call, external_lookup_counts, workspace_violation_counts,
)
for tool_call in batch
))
tool_results.extend(batch_results)
else:
batch_results = []
for tool_call in batch:
result = await self._run_tool(spec, tool_call, external_lookup_counts)
result = await self._run_tool(
spec, tool_call, external_lookup_counts, workspace_violation_counts,
)
tool_results.append(result)
batch_results.append(result)
if isinstance(result[2], AskUserInterrupt):
@@ -734,6 +745,7 @@ class AgentRunner:
spec: AgentRunSpec,
tool_call: ToolCallRequest,
external_lookup_counts: dict[str, int],
workspace_violation_counts: dict[str, int],
) -> tuple[Any, dict[str, str], BaseException | None]:
hint = "\n\n[Analyze the error above and try a different approach.]"
lookup_error = repeated_external_lookup_error(
@@ -763,16 +775,20 @@ class AgentRunner:
"status": "error",
"detail": prep_error.split(": ", 1)[-1][:120],
}
if self._is_workspace_violation(prep_error):
logger.warning(
"Tool {} blocked by workspace/safety guard during preparation; aborting turn: {}",
tool_call.name,
prep_error.replace("\n", " ").strip()[:200],
)
event["detail"] = ("workspace_violation: "
+ prep_error.replace("\n", " ").strip())[:160]
return prep_error, event, RuntimeError(prep_error)
return prep_error + hint, event, RuntimeError(prep_error) if spec.fail_on_tool_error else None
handled = self._classify_violation(
raw_text=prep_error,
soft_payload=prep_error + hint,
ssrf_payload=prep_error,
ssrf_error=RuntimeError(prep_error),
event=event,
tool_call=tool_call,
workspace_violation_counts=workspace_violation_counts,
)
if handled is not None:
return handled
return prep_error + hint, event, (
RuntimeError(prep_error) if spec.fail_on_tool_error else None
)
try:
if tool is not None:
result = await tool.execute(**params)
@@ -789,18 +805,25 @@ class AgentRunner:
if isinstance(exc, AskUserInterrupt):
event["status"] = "waiting"
return "", event, exc
if self._is_workspace_violation(str(exc)):
logger.warning(
"Tool {} blocked by workspace/safety guard; aborting turn: {}",
tool_call.name,
str(exc).replace("\n", " ").strip()[:200],
)
event["detail"] = ("workspace_violation: "
+ str(exc).replace("\n", " ").strip())[:160]
return f"Error: {type(exc).__name__}: {exc}", event, exc
payload = f"Error: {type(exc).__name__}: {exc}"
handled = self._classify_violation(
raw_text=str(exc),
# Match the legacy behavior here: the exception branch never
# appended the "try a different approach" hint, even on the
# workspace-violation path -- preserve that for callers that
# eyeball the exact tool message.
soft_payload=payload,
ssrf_payload=payload,
ssrf_error=exc,
event=event,
tool_call=tool_call,
workspace_violation_counts=workspace_violation_counts,
)
if handled is not None:
return handled
if spec.fail_on_tool_error:
return f"Error: {type(exc).__name__}: {exc}", event, exc
return f"Error: {type(exc).__name__}: {exc}", event, None
return payload, event, exc
return payload, event, None
if isinstance(result, str) and result.startswith("Error"):
event = {
@@ -808,17 +831,17 @@ class AgentRunner:
"status": "error",
"detail": result.replace("\n", " ").strip()[:120],
}
# check the outside workspace error and break loop
if self._is_workspace_violation(result):
logger.warning(
"Tool {} blocked by workspace/safety guard; aborting turn: {}",
tool_call.name,
result.replace("\n", " ").strip()[:200],
)
event["detail"] = ("workspace_violation: "
+ result.replace("\n", " ").strip())[:160]
return result, event, RuntimeError(result)
handled = self._classify_violation(
raw_text=result,
soft_payload=result + hint,
ssrf_payload=result,
ssrf_error=RuntimeError(result),
event=event,
tool_call=tool_call,
workspace_violation_counts=workspace_violation_counts,
)
if handled is not None:
return handled
if spec.fail_on_tool_error:
return result + hint, event, RuntimeError(result)
return result + hint, event, None
@@ -831,39 +854,103 @@ class AgentRunner:
detail = detail[:120] + "..."
return result, {"name": tool_call.name, "status": "ok", "detail": detail}, None
# Markers identifying tool results that represent a *hard* workspace /
# safety boundary rejection -- only these abort the agent loop.
#
# We deliberately keep this list narrow (#3599 / #3605):
# - The first four come from explicit path-resolution checks in
# ``filesystem.py`` and ``shell.py`` that cannot false-positive on user
# payloads -- if you see them, the LLM truly tried to escape the
# workspace.
# - "internal/private url detected" stays fatal because SSRF is a real
# security boundary; allowing the LLM to "retry" would just let it
# poke internal infra with a different URL phrasing.
# - "path traversal detected" and "path outside working dir" are
# intentionally *not* listed: both come from the heuristic
# ``_guard_command`` checks in ``shell.py`` which scan the raw command
# string and routinely false-positive on legitimate constructs (e.g.
# ``2>/dev/null`` redirects, quoted ``..`` arguments to ``sed`` /
# ``find``, paths inside inline scripts). Treating them as fatal
# silently kills user turns (#3599) and prevents the agent from
# self-correcting by trying a different approach (#3605).
_WORKSPACE_BLOCK_MARKERS: tuple[str, ...] = (
# SSRF rejections remain a hard, non-recoverable safety boundary: a single
# successful internal-URL fetch can leak cloud metadata, so we never let
# the LLM "retry" with a different phrasing of the same target.
_SSRF_MARKER: str = "internal/private url detected"
# Markers that identify "tried to access something outside the workspace".
# Unlike SSRF these are intentionally *non-fatal* (#3599 / #3605):
# - The structured error message itself tells the model not to bypass.
# - ``repeated_workspace_violation_error`` throttles the loop reported
# in #3493 by escalating after two attempts against the same target.
# - max_iterations is the ultimate ceiling, so we never need to abort.
_WORKSPACE_VIOLATION_MARKERS: tuple[str, ...] = (
"outside the configured workspace",
"outside allowed directory",
"working_dir is outside",
"working_dir could not be resolved",
"internal/private url detected",
"path outside working dir",
"path traversal detected",
)
@classmethod
def _is_ssrf_violation(cls, text: str) -> bool:
return bool(text) and cls._SSRF_MARKER in text.lower()
@classmethod
def _is_workspace_violation(cls, text: str) -> bool:
"""True when *text* looks like *any* policy boundary rejection.
Kept as a public-ish hook for callers that need a yes/no signal
(logging, telemetry). The runner itself uses the more specific
``_is_ssrf_violation`` to decide what is fatal.
"""
if not text:
return False
lowered = text.lower()
return any(marker in lowered for marker in cls._WORKSPACE_BLOCK_MARKERS)
if cls._SSRF_MARKER in lowered:
return True
return any(marker in lowered for marker in cls._WORKSPACE_VIOLATION_MARKERS)
def _classify_violation(
self,
*,
raw_text: str,
soft_payload: str,
ssrf_payload: str,
ssrf_error: BaseException,
event: dict[str, str],
tool_call: ToolCallRequest,
workspace_violation_counts: dict[str, int],
) -> tuple[Any, dict[str, str], BaseException | None] | None:
"""Apply violation policy to a tool failure, or pass through.
Returns a fully-formed (payload, event, error) triple when *raw_text*
looks like a policy boundary rejection. Returns ``None`` when the
caller should fall through to its generic per-branch handling.
- SSRF stays fatal -- a single successful internal fetch can leak
cloud metadata, so retrying with a different URL phrasing is
never acceptable. We mutate ``event`` in place so the caller's
telemetry stays consistent.
- All other workspace-bound rejections become soft tool errors.
Each repeated attempt against the same outside target bumps a
per-turn counter; after the soft retry budget is exhausted we
replace the message body with an explicit "stop trying to bypass
the policy" instruction (see #3493 for the original bypass-loop
that motivated PR #3493's hard-abort, and #3599 / #3605 for why
the hard-abort backfired).
"""
if self._is_ssrf_violation(raw_text):
logger.warning(
"Tool {} blocked by SSRF guard; aborting turn: {}",
tool_call.name,
raw_text.replace("\n", " ").strip()[:200],
)
event["detail"] = ("workspace_violation: "
+ raw_text.replace("\n", " ").strip())[:160]
return ssrf_payload, event, ssrf_error
if self._is_workspace_violation(raw_text):
escalation = repeated_workspace_violation_error(
tool_call.name,
tool_call.arguments,
workspace_violation_counts,
)
event["detail"] = ("workspace_violation: "
+ raw_text.replace("\n", " ").strip())[:160]
if escalation is not None:
logger.warning(
"Tool {} hit workspace boundary repeatedly; escalating hint",
tool_call.name,
)
event["detail"] = ("workspace_violation_escalated: "
+ raw_text.replace("\n", " ").strip())[:160]
return escalation, event, None
return soft_payload, event, None
return None
async def _emit_checkpoint(
self,