Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 32 additions & 0 deletions raven/cli/doctor_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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")

Expand Down Expand Up @@ -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:
Expand Down
26 changes: 17 additions & 9 deletions raven/context_engine/segments/render.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 🐦‍⬛

Expand Down
53 changes: 53 additions & 0 deletions tests/test_cli_doctor_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from __future__ import annotations

import json
import re
from pathlib import Path

import pytest
Expand Down Expand Up @@ -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


Expand Down
20 changes: 20 additions & 0 deletions tests/test_segments.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading