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)