From 86d4a77fc0563bdc4d60e74cbb1a599907f66155 Mon Sep 17 00:00:00 2001 From: ZuyiZhou <144661423+ZuyiZhou@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:17:38 +0800 Subject: [PATCH] fix: warn when permission mode is full or the sandbox is off Doctor names permissions.mode full and tools.sandbox.backend none. Neither warning changes the exit code. The identity prompt tells the agent to install a package into the project virtual environment, or a temporary one, and to leave the global environment alone. --- CHANGELOG.md | 7 ++++ raven/cli/doctor_commands.py | 32 +++++++++++++++ raven/context_engine/segments/render.py | 26 +++++++----- tests/test_cli_doctor_commands.py | 53 +++++++++++++++++++++++++ tests/test_segments.py | 20 ++++++++++ 5 files changed, 129 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 67baef92f..ea207b543 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -173,6 +173,13 @@ All notable changes to Raven are documented here. ### Fixed +- `raven doctor` warns when `permissions.mode` is `full` and when + `tools.sandbox.backend` is `none`. Ask-tier calls then run without asking, + and commands run on the host with no isolation. The exit code stays 0. + The identity prompt tells the agent to install a package into the project's + virtual environment, or a temporary one, and to leave the global environment + alone. + - An unattended turn that asks a question now leaves a record of it. A one-shot run lists those questions after the reply, beside the refused calls, and a session opened later shows the same questions as their own notice. Previously diff --git a/raven/cli/doctor_commands.py b/raven/cli/doctor_commands.py index 0a019503c..04072c477 100644 --- a/raven/cli/doctor_commands.py +++ b/raven/cli/doctor_commands.py @@ -66,6 +66,10 @@ class FeaturesInfo: channels_enabled: list[str] = field(default_factory=list) channels_missing_deps: list[str] = field(default_factory=list) skill_forge_enabled: bool = False + # The global config, which is the file this command reads. A conversation + # can override the mode for its own turns; that override is not here. + permission_mode: str = "" + sandbox_backend: str = "" @dataclass @@ -641,6 +645,8 @@ def _gather_static_checks() -> DoctorReport: channels_enabled=enabled, channels_missing_deps=missing_dependency_channels(config), skill_forge_enabled=skill_forge_on, + permission_mode=config.permissions.mode, + sandbox_backend=config.tools.sandbox.backend, ) report.external_tools = _gather_external_tools() @@ -831,6 +837,31 @@ def _describe_window(routing) -> str: return f"auto -> {DEFAULT_CONTEXT_WINDOW_TOKENS:,} default [yellow](no catalogue knows this model)[/yellow]" +def _render_permission_and_sandbox(features: FeaturesInfo) -> None: + """Say how the ask tier is read, and whether commands run on this machine. + + ``full`` skips the ask tier only: built-in denials and user deny rules + still hold. ``none`` runs commands here; ``auto`` and ``boxlite`` do not, + and the remedy is the one the startup log already gives. + """ + if features.permission_mode == "full": + console.print( + f" Permissions: {features.permission_mode} " + "[yellow]⚠ ask-tier calls run without asking; " + "built-in denials and deny rules still hold[/yellow]" + ) + elif features.permission_mode: + console.print(f" Permissions: {features.permission_mode}") + if features.sandbox_backend == "none": + console.print( + f" Sandbox: {features.sandbox_backend} " + "[yellow]⚠ commands run on this machine with no isolation[/yellow] " + "[dim]set tools.sandbox.backend to auto or boxlite[/dim]" + ) + elif features.sandbox_backend: + console.print(f" Sandbox: {features.sandbox_backend}") + + def _render_human_output(report: DoctorReport) -> None: console.print(f"\n{__logo__} Raven Doctor\n") @@ -903,6 +934,7 @@ def _render_human_output(report: DoctorReport) -> None: console.print(f" [yellow]⚠ SDK missing: {names}[/yellow] [dim]{missing_dep_hint()}[/dim]") sf_label = "enabled" if features.skill_forge_enabled else "[dim]disabled[/dim]" console.print(f" Skill forge: {sf_label}") + _render_permission_and_sandbox(features) external = report.external_tools if external is not None: diff --git a/raven/context_engine/segments/render.py b/raven/context_engine/segments/render.py index 8bb1983ec..52eab790f 100644 --- a/raven/context_engine/segments/render.py +++ b/raven/context_engine/segments/render.py @@ -364,17 +364,25 @@ def identity_text( delegation, delegation_rule = _delegation_block(specialists, dispatch_tools) model_line = f"\nYou are running on model: {resolved_model}." if resolved_model else "" + # One sentence for both policies. A package installed into the interpreter + # Raven itself runs on, or into a user-wide prefix, changes every later run. + package_install = ( + "- When you install a package, install it into this project's virtual environment, " + "or into a temporary one. Do not install it into the global environment.\n" + ) if system == "Windows": - platform_policy = """## Platform Policy (Windows) -- You are running on Windows. Do not assume GNU tools like `grep`, `sed`, or `awk` exist. -- Prefer Windows-native commands or file tools when they are more reliable. -- If terminal output is garbled, retry with UTF-8 output enabled. -""" + platform_policy = ( + "## Platform Policy (Windows)\n" + "- You are running on Windows. Do not assume GNU tools like `grep`, `sed`, or `awk` exist.\n" + "- Prefer Windows-native commands or file tools when they are more reliable.\n" + "- If terminal output is garbled, retry with UTF-8 output enabled.\n" + package_install + ) else: - platform_policy = """## Platform Policy (POSIX) -- You are running on a POSIX system. Prefer UTF-8 and standard shell tools. -- Use file tools when they are simpler or more reliable than shell commands. -""" + platform_policy = ( + "## Platform Policy (POSIX)\n" + "- You are running on a POSIX system. Prefer UTF-8 and standard shell tools.\n" + "- Use file tools when they are simpler or more reliable than shell commands.\n" + package_install + ) return f"""# Raven 🐦‍⬛ diff --git a/tests/test_cli_doctor_commands.py b/tests/test_cli_doctor_commands.py index feee43069..57d02e46f 100644 --- a/tests/test_cli_doctor_commands.py +++ b/tests/test_cli_doctor_commands.py @@ -9,6 +9,7 @@ from __future__ import annotations import json +import re from pathlib import Path import pytest @@ -227,6 +228,58 @@ def test_doctor_json_with_probe_structure(healthy_config: Path, monkeypatch: pyt assert data["probe"]["tokens"] == 10 +def _save_safety(mode: str, backend: str) -> None: + from raven.config.loader import load_config + + cfg = load_config() + cfg.permissions.mode = mode + cfg.tools.sandbox.backend = backend + save_config(cfg) + + +def test_doctor_warns_for_the_default_sandbox_and_not_for_smart_mode(healthy_config: Path) -> None: + """A fresh config leaves the sandbox off. That is a warning, not a failure, + and the default permission mode is not the one that skips asking.""" + r = runner.invoke(app, ["doctor"]) + assert r.exit_code == 0, r.stdout + assert "commands run on this machine with no isolation" in r.stdout + assert "tools.sandbox.backend" in r.stdout + assert "ask-tier calls run without asking" not in r.stdout + assert "Permissions: smart" in r.stdout + + +def test_doctor_warns_for_full_permission_and_not_for_an_enabled_sandbox(healthy_config: Path) -> None: + """``full`` skips the ask tier. ``auto`` is a sandbox, so only the first warns.""" + _save_safety("full", "auto") + r = runner.invoke(app, ["doctor"]) + plain = re.sub(r"\s+", " ", re.sub(r"\x1b\[[0-9;]*m", "", r.stdout)) + assert r.exit_code == 0, plain + assert "ask-tier calls run without asking" in plain + assert "built-in denials and deny rules still hold" in plain + assert "commands run on this machine with no isolation" not in plain + assert "Sandbox: auto" in plain + + +def test_doctor_is_quiet_when_permission_mode_asks_and_sandbox_is_boxlite(healthy_config: Path) -> None: + _save_safety("ask", "boxlite") + r = runner.invoke(app, ["doctor"]) + assert r.exit_code == 0, r.stdout + assert "ask-tier calls run without asking" not in r.stdout + assert "commands run on this machine with no isolation" not in r.stdout + assert "Permissions: ask" in r.stdout + assert "Sandbox: boxlite" in r.stdout + + +def test_the_safety_settings_reach_the_json_output(healthy_config: Path) -> None: + _save_safety("full", "none") + r = runner.invoke(app, ["doctor", "--json"]) + plain = re.sub(r"\x1b\[[0-9;]*m", "", r.stdout) + assert r.exit_code == 0, plain + data = json.loads(plain) + assert data["features"]["permission_mode"] == "full" + assert data["features"]["sandbox_backend"] == "none" + + # --------------------------------------------------------------------------- memory diff --git a/tests/test_segments.py b/tests/test_segments.py index b73f21001..88526a9b0 100644 --- a/tests/test_segments.py +++ b/tests/test_segments.py @@ -164,6 +164,26 @@ def test_identity_contains_model_id(self, tmp_path: Path) -> None: prompt = render.identity_text(tmp_path, model="openrouter/some-model") assert "openrouter/some-model" in prompt + def test_identity_installs_packages_into_a_virtual_environment(self, tmp_path: Path, monkeypatch) -> None: + """Both platform policies carry the same install rule, inside the policy.""" + import platform + + sentence = "Do not install it into the global environment." + + def policy(system: str) -> str: + monkeypatch.setattr(platform, "system", lambda: system) + text = render.identity_text(tmp_path, model="openrouter/some-model") + assert text.count(sentence) == 1 + return text.split("## Platform Policy", 1)[1].split("## ", 1)[0] + + for system in ("Darwin", "Windows"): + body = policy(system) + assert sentence in body, system + assert "this project's virtual environment" in body + assert "a temporary one" in body + assert "GNU tools" in policy("Windows") + assert "standard shell tools" in policy("Linux") + def test_identity_default_model_resolved_lazily(self, tmp_path: Path, monkeypatch) -> None: monkeypatch.setattr(render, "_resolved_model_id", lambda: "openrouter/acme/lazy-model") assert "openrouter/acme/lazy-model" in render.identity_text(tmp_path)