From 347e2efefc151ebeea061f27ba56dcf74320f1ca Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Thu, 15 Oct 2026 14:33:20 +0800 Subject: [PATCH] fix(rlm): support approved shell escalation and writable Devo home --- Cargo.lock | 60 ++--- Cargo.toml | 2 +- apps/desktop/package.json | 2 +- apps/tui/package-lock.json | 4 +- apps/tui/package.json | 2 +- crates/core/src/rlm_prompts.rs | 6 +- crates/core/src/tools/handlers/ipython.rs | 94 +------ .../tools/handlers/ipython/kernel_fence.rs | 95 +++++++ crates/core/src/tools/router.rs | 5 +- crates/kernel/rlm-runtime/src/rlm/__init__.py | 3 + crates/kernel/rlm-runtime/src/rlm/shell.py | 38 +++ .../rlm-runtime/tests/test_rlm_shell.py | 50 ++++ .../server/src/runtime/kernel_host_bridge.rs | 77 +----- .../src/runtime/kernel_host_bridge/shell.rs | 244 ++++++++++++++++++ crates/windows-sandbox/src/setup.rs | 35 ++- docs/devo-home-permissions.md | 29 +++ docs/rlm-shell-escalation.md | 51 ++++ 17 files changed, 581 insertions(+), 216 deletions(-) create mode 100644 crates/core/src/tools/handlers/ipython/kernel_fence.rs create mode 100644 crates/kernel/rlm-runtime/src/rlm/shell.py create mode 100644 crates/kernel/rlm-runtime/tests/test_rlm_shell.py create mode 100644 crates/server/src/runtime/kernel_host_bridge/shell.rs create mode 100644 docs/devo-home-permissions.md create mode 100644 docs/rlm-shell-escalation.md diff --git a/Cargo.lock b/Cargo.lock index a38ea0f47..3a9e846d5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1375,7 +1375,7 @@ checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" [[package]] name = "devo-arg0" -version = "0.2.12" +version = "0.2.13" dependencies = [ "anyhow", "clap", @@ -1395,7 +1395,7 @@ dependencies = [ [[package]] name = "devo-cli" -version = "0.2.12" +version = "0.2.13" dependencies = [ "anyhow", "async-trait", @@ -1424,7 +1424,7 @@ dependencies = [ [[package]] name = "devo-client" -version = "0.2.12" +version = "0.2.13" dependencies = [ "anyhow", "chrono", @@ -1442,7 +1442,7 @@ dependencies = [ [[package]] name = "devo-config" -version = "0.2.12" +version = "0.2.13" dependencies = [ "anyhow", "devo-protocol", @@ -1458,7 +1458,7 @@ dependencies = [ [[package]] name = "devo-core" -version = "0.2.12" +version = "0.2.13" dependencies = [ "anyhow", "async-trait", @@ -1508,7 +1508,7 @@ dependencies = [ [[package]] name = "devo-execpolicy" -version = "0.2.12" +version = "0.2.13" dependencies = [ "anyhow", "clap", @@ -1525,7 +1525,7 @@ dependencies = [ [[package]] name = "devo-file-search" -version = "0.2.12" +version = "0.2.13" dependencies = [ "anyhow", "clap", @@ -1541,7 +1541,7 @@ dependencies = [ [[package]] name = "devo-harness" -version = "0.2.12" +version = "0.2.13" dependencies = [ "chrono", "pretty_assertions", @@ -1554,7 +1554,7 @@ dependencies = [ [[package]] name = "devo-kernel" -version = "0.2.12" +version = "0.2.13" dependencies = [ "anyhow", "devo-sandbox", @@ -1576,7 +1576,7 @@ dependencies = [ [[package]] name = "devo-keyring-store" -version = "0.2.12" +version = "0.2.13" dependencies = [ "keyring", "tracing", @@ -1584,7 +1584,7 @@ dependencies = [ [[package]] name = "devo-linux-sandbox" -version = "0.2.12" +version = "0.2.13" dependencies = [ "anyhow", "clap", @@ -1596,7 +1596,7 @@ dependencies = [ [[package]] name = "devo-mcp" -version = "0.2.12" +version = "0.2.13" dependencies = [ "async-trait", "chrono", @@ -1612,7 +1612,7 @@ dependencies = [ [[package]] name = "devo-network-proxy" -version = "0.2.12" +version = "0.2.13" dependencies = [ "anyhow", "pretty_assertions", @@ -1624,7 +1624,7 @@ dependencies = [ [[package]] name = "devo-protocol" -version = "0.2.12" +version = "0.2.13" dependencies = [ "agent-client-protocol", "chrono", @@ -1643,7 +1643,7 @@ dependencies = [ [[package]] name = "devo-provider" -version = "0.2.12" +version = "0.2.13" dependencies = [ "anyhow", "async-stream", @@ -1666,7 +1666,7 @@ dependencies = [ [[package]] name = "devo-rmcp-client" -version = "0.2.12" +version = "0.2.13" dependencies = [ "anyhow", "axum", @@ -1701,7 +1701,7 @@ dependencies = [ [[package]] name = "devo-safety" -version = "0.2.12" +version = "0.2.13" dependencies = [ "async-trait", "devo-config", @@ -1721,7 +1721,7 @@ dependencies = [ [[package]] name = "devo-sandbox" -version = "0.2.12" +version = "0.2.13" dependencies = [ "anyhow", "chrono", @@ -1747,7 +1747,7 @@ dependencies = [ [[package]] name = "devo-sandbox-network-proxy" -version = "0.2.12" +version = "0.2.13" dependencies = [ "anyhow", "devo-sandbox", @@ -1759,7 +1759,7 @@ dependencies = [ [[package]] name = "devo-server" -version = "0.2.12" +version = "0.2.13" dependencies = [ "anyhow", "async-trait", @@ -1810,7 +1810,7 @@ dependencies = [ [[package]] name = "devo-skills" -version = "0.2.12" +version = "0.2.13" dependencies = [ "chrono", "include_dir", @@ -1827,7 +1827,7 @@ dependencies = [ [[package]] name = "devo-stress-tools" -version = "0.2.12" +version = "0.2.13" dependencies = [ "anyhow", "chrono", @@ -1842,7 +1842,7 @@ dependencies = [ [[package]] name = "devo-tasks" -version = "0.2.12" +version = "0.2.13" dependencies = [ "anyhow", "async-trait", @@ -1858,7 +1858,7 @@ dependencies = [ [[package]] name = "devo-tools" -version = "0.2.12" +version = "0.2.13" dependencies = [ "async-trait", "devo-kernel", @@ -1878,14 +1878,14 @@ dependencies = [ [[package]] name = "devo-util-fuzzy" -version = "0.2.12" +version = "0.2.13" dependencies = [ "pretty_assertions", ] [[package]] name = "devo-util-git" -version = "0.2.12" +version = "0.2.13" dependencies = [ "assert_matches", "devo-util-paths", @@ -1903,7 +1903,7 @@ dependencies = [ [[package]] name = "devo-util-paths" -version = "0.2.12" +version = "0.2.13" dependencies = [ "dirs", "pretty_assertions", @@ -1916,7 +1916,7 @@ dependencies = [ [[package]] name = "devo-util-process" -version = "0.2.12" +version = "0.2.13" dependencies = [ "anyhow", "devo-sandbox", @@ -1934,7 +1934,7 @@ dependencies = [ [[package]] name = "devo-util-shell-command" -version = "0.2.12" +version = "0.2.13" dependencies = [ "anyhow", "base64 0.22.1", @@ -1954,7 +1954,7 @@ dependencies = [ [[package]] name = "devo-windows-sandbox" -version = "0.2.12" +version = "0.2.13" dependencies = [ "anyhow", "base64 0.22.1", diff --git a/Cargo.toml b/Cargo.toml index e5c8b3d5c..f33d2999c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,7 +38,7 @@ resolver = "3" edition = "2024" license = "MIT" rust-version = "1.88.0" -version = "0.2.12" +version = "0.2.13" [workspace.dependencies] agent-client-protocol = "0.14.0" diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 871839fe1..e5680b5f3 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@devo/desktop", "productName": "Devo", - "version": "0.2.12", + "version": "0.2.13", "description": "AI-powered coding assistant desktop app", "author": "Devo", "license": "MIT", diff --git a/apps/tui/package-lock.json b/apps/tui/package-lock.json index 06c5cf5d1..4e05fedc2 100644 --- a/apps/tui/package-lock.json +++ b/apps/tui/package-lock.json @@ -1,12 +1,12 @@ { "name": "@devo/tui", - "version": "0.2.12", + "version": "0.2.13", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@devo/tui", - "version": "0.2.12", + "version": "0.2.13", "dependencies": { "@earendil-works/pi-agent-core": "file:./lib/agent", "@earendil-works/pi-ai": "file:./lib/ai", diff --git a/apps/tui/package.json b/apps/tui/package.json index b9ca0d55d..de313bb9f 100644 --- a/apps/tui/package.json +++ b/apps/tui/package.json @@ -1,6 +1,6 @@ { "name": "@devo/tui", - "version": "0.2.12", + "version": "0.2.13", "private": true, "description": "Devo product TUI — InteractiveMode + Native AgentConnection", "type": "module", diff --git a/crates/core/src/rlm_prompts.rs b/crates/core/src/rlm_prompts.rs index 5c9f618a2..8bed03b5e 100644 --- a/crates/core/src/rlm_prompts.rs +++ b/crates/core/src/rlm_prompts.rs @@ -196,10 +196,10 @@ pub fn build_rlm_base_prompt(options: &RlmPromptOptions) -> String { } if can_run_shell_skills { - // The Python helper is fenced but cannot request the shell tool's extra permissions. + // Local bash stays fenced; rlm.shell mediates explicit grants through the host. parts.push(String::new()); parts.push( - "Sandbox and approvals: shell commands launched through Python's `bash()` helper run inside the kernel's OS sandbox and cannot request additional grants or escalation. Treat a fence refusal as final; explain the limit and ask the user to change permission settings through the product flow. If a dedicated shell tool appears in the current tool list, only that tool can request `sandbox_permissions`: prefer `sandbox_permissions: \"with_additional_permissions\"` for narrow, still-sandboxed grants; use `sandbox_permissions: \"require_escalated\"` only with a short `justification` phrased as a question for the user. Never attach these fields to an `ipython` call or route a denied operation through another tool to bypass the fence." + "Sandbox and approvals: ordinary Python `bash()` runs inside the kernel fence. If a command needs additional access, use `await rlm.shell(command, sandbox_permissions=\"with_additional_permissions\", additional_permissions={\"network\": {\"enabled\": True}}, justification=\"May I ...?\")` for narrow grants, or `await rlm.shell(command, sandbox_permissions=\"require_escalated\", justification=\"May I ...?\")` for an approved unsandboxed command. This calls the host shell approval pipeline; the kernel stays fenced and denial is final. Do not retry a denied approval through another route. Never attach these fields to an `ipython` call itself. The command uses the host platform shell (PowerShell on Windows), so use native shell syntax rather than nesting another shell. The result is a dict with command output and status." .to_string(), ); } @@ -452,7 +452,7 @@ mod tests { assert!(prompt.contains("You are Devo")); assert!(prompt.contains("ipython")); assert!(prompt.contains("bash()")); - assert!(prompt.contains("cannot request additional grants or escalation")); + assert!(prompt.contains("await rlm.shell(command")); assert!(prompt.contains("Never attach these fields to an `ipython` call")); assert!(!prompt.contains("Request it on the tool call itself")); assert!(prompt.contains("16 MiB")); diff --git a/crates/core/src/tools/handlers/ipython.rs b/crates/core/src/tools/handlers/ipython.rs index 06da364e8..96f4894aa 100644 --- a/crates/core/src/tools/handlers/ipython.rs +++ b/crates/core/src/tools/handlers/ipython.rs @@ -20,6 +20,9 @@ use devo_tools::python_cell_watch::{ PythonCellWatchDecision, PythonCellWatchInput, effective_first_wait_ms, output_tail, }; +mod kernel_fence; +pub(crate) use kernel_fence::resolve_kernel_fence; + pub struct IpythonHandler { spec: ToolSpec, } @@ -391,97 +394,6 @@ fn build_ipython_result( result } -/// Ensure a kernel exists for RLM sessions (created on the turn task, not the actor). -/// -/// `session_dir` is the session **artifact** directory -/// (`session-artifacts//` for roots), not the parent of the rollout JSONL. -/// When provided, the kernel receives harness env so `rlm.harness` and -/// `await refine.run()` share the host's local store. -/// -/// Build the kernel OS-fence spec on the `rlm-kernel` base profile (design doc -/// §5.1, P4): **no default read** — the kernel reads only the system/ -/// interpreter base trees plus the roots granted here (workspace read-only in -/// Plan mode, read-write in Build; temp and session artifacts rw, global -/// harness rw, devo home r). Reads and writes anywhere else are OS-refused, -/// so model code escalates through the rlm front door (`rlm.read`/`rlm.write`) -/// and the approval pipeline instead of the fence silently allowing it. -pub(crate) fn resolve_kernel_fence( - cwd: &std::path::Path, - session_dir: Option<&std::path::Path>, - collaboration_mode: devo_protocol::CollaborationMode, -) -> Option { - let mut writable_roots = Vec::new(); - if collaboration_mode == devo_protocol::CollaborationMode::Build { - writable_roots.push(cwd.to_path_buf()); - } - // Unix's base profile grants shared scratch roots too: retain them here - // so the Plan overlap check sees every writable root in the actual fence. - // Windows session kernels instead use owned scratch below the artifact - // root, avoiding recursive ACL propagation across large shared temp trees. - if session_dir.is_none() || !cfg!(windows) { - writable_roots.extend(devo_sandbox::temp_writable_paths()); - } - if let Some(dir) = session_dir { - writable_roots.push(dir.to_path_buf()); - } - // Global harness state is agent-owned memory/prompt/skill data — the same - // trust class as session artifacts. The kernel harness API writes it - // directly (`rlm.harness.create_memory(..., global_=True)` and friends), - // and its own guidance points models there, so refusing it broke every - // global create/update/delete with EACCES. Create the directory host-side - // first: fence roots resolve to path-beneath rules against real - // directories, and a missing root would downgrade the whole fence. - let global_harness = global_harness_state_dir(); - if std::fs::create_dir_all(&global_harness).is_ok() && global_harness.is_dir() { - writable_roots.push(global_harness); - } - Some(devo_kernel::KernelFenceSpec { - // Devo home is READ-granted so the kernel can inspect other agents' - // rollouts/artifacts directly. The grant is composed per-subdirectory - // instead of `~/.devo` wholesale so credentials (`auth.json` — a - // root-level file) stay OUTSIDE it; root-level files are not granted - // at all because fence read roots must be directories (the capability - // builder maps them to path-beneath rules; a file root would fail - // resolution and downgrade the whole fence to unfenced). - readable_roots: { - let mut roots = devo_home_read_roots(); - if collaboration_mode == devo_protocol::CollaborationMode::Plan { - roots.push(cwd.to_path_buf()); - } - roots - }, - writable_roots, - deny_read: Vec::new(), - // RLM kernel fence: network is restricted by default (§5.2 — the fence - // spec carries the RLM default, not the shell profile's; kernel network - // goes through the proxy when granted, not through profile inheritance). - restrict_network: true, - }) -} - -/// Read roots inside the Devo home for the kernel fence: every subdirectory. -/// Root-level files (notably `auth.json` with the API keys, but also -/// config.toml / providers.json) are excluded — read roots are directory -/// grants, and credential files must stay unreadable by the kernel. -fn devo_home_read_roots() -> Vec { - let Ok(home) = devo_util_paths::find_devo_home() else { - return Vec::new(); - }; - let mut roots = Vec::new(); - if let Ok(entries) = std::fs::read_dir(&home) { - for entry in entries.flatten() { - let path = entry.path(); - // Follow only real directories (skip dot-directories and - // non-directory entries such as lock/socket files). - if path.is_dir() && !path.is_symlink() { - roots.push(path); - } - } - } - roots.sort(); - roots -} - /// Host-owned site dir where `dill` is provisioned for the kernel interpreter. fn kernel_site_dir() -> Option { devo_util_paths::find_devo_home() diff --git a/crates/core/src/tools/handlers/ipython/kernel_fence.rs b/crates/core/src/tools/handlers/ipython/kernel_fence.rs new file mode 100644 index 000000000..4eae0d494 --- /dev/null +++ b/crates/core/src/tools/handlers/ipython/kernel_fence.rs @@ -0,0 +1,95 @@ +use super::global_harness_state_dir; + +/// Ensure a kernel exists for RLM sessions (created on the turn task, not the actor). +/// +/// `session_dir` is the session **artifact** directory +/// (`session-artifacts//` for roots), not the parent of the rollout JSONL. +/// When provided, the kernel receives harness env so `rlm.harness` and +/// `await refine.run()` share the host's local store. +/// +/// Build the kernel OS-fence spec on the `rlm-kernel` base profile (design doc +/// §5.1, P4): **no default read** — the kernel reads only the system/ +/// interpreter base trees plus the roots granted here (workspace read-only in +/// Plan mode, read-write in Build; temp and session artifacts rw, global +/// harness rw, devo home rw). Reads and writes anywhere else are OS-refused, +/// so model code escalates through the rlm front door (`rlm.read`/`rlm.write`) +/// and the approval pipeline instead of the fence silently allowing it. +pub(crate) fn resolve_kernel_fence( + cwd: &std::path::Path, + session_dir: Option<&std::path::Path>, + collaboration_mode: devo_protocol::CollaborationMode, +) -> Option { + let mut writable_roots = Vec::new(); + if collaboration_mode == devo_protocol::CollaborationMode::Build { + writable_roots.push(cwd.to_path_buf()); + } + // Unix's base profile grants shared scratch roots too: retain them here + // so the Plan overlap check sees every writable root in the actual fence. + // Windows session kernels instead use owned scratch below the artifact + // root, avoiding recursive ACL propagation across large shared temp trees. + if session_dir.is_none() || !cfg!(windows) { + writable_roots.extend(devo_sandbox::temp_writable_paths()); + } + if let Some(dir) = session_dir { + writable_roots.push(dir.to_path_buf()); + } + // Global harness state is agent-owned memory/prompt/skill data — the same + // trust class as session artifacts. The kernel harness API writes it + // directly (`rlm.harness.create_memory(..., global_=True)` and friends), + // and its own guidance points models there, so refusing it broke every + // global create/update/delete with EACCES. Create the directory host-side + // first: fence roots resolve to path-beneath rules against real + // directories, and a missing root would downgrade the whole fence. + let global_harness = global_harness_state_dir(); + if std::fs::create_dir_all(&global_harness).is_ok() && global_harness.is_dir() { + writable_roots.push(global_harness); + } + // Devo home is agent-owned state and remains readable/writable in both modes. + if let Ok(home) = devo_util_paths::find_devo_home() + && std::fs::create_dir_all(&home).is_ok() + && home.is_dir() + { + writable_roots.push(home); + } + Some(devo_kernel::KernelFenceSpec { + readable_roots: if collaboration_mode == devo_protocol::CollaborationMode::Plan { + vec![cwd.to_path_buf()] + } else { + Vec::new() + }, + writable_roots, + deny_read: Vec::new(), + // RLM kernel fence: network is restricted by default (§5.2 — the fence + // spec carries the RLM default, not the shell profile's; kernel network + // goes through the proxy when granted, not through profile inheritance). + restrict_network: true, + }) +} + +#[cfg(test)] +mod tests { + use super::resolve_kernel_fence; + use pretty_assertions::assert_eq; + + #[test] + fn devo_home_is_writable_in_plan_and_build() { + let workspace = tempfile::tempdir().expect("workspace"); + let artifacts = tempfile::tempdir().expect("artifacts"); + let home = devo_util_paths::find_devo_home().expect("devo home"); + for mode in [ + devo_protocol::CollaborationMode::Plan, + devo_protocol::CollaborationMode::Build, + ] { + let fence = resolve_kernel_fence(workspace.path(), Some(artifacts.path()), mode) + .expect("kernel fence"); + assert_eq!( + fence + .writable_roots + .iter() + .filter(|root| *root == &home) + .collect::>(), + vec![&home] + ); + } + } +} diff --git a/crates/core/src/tools/router.rs b/crates/core/src/tools/router.rs index 22d7c9df5..aeda040b0 100644 --- a/crates/core/src/tools/router.rs +++ b/crates/core/src/tools/router.rs @@ -1013,7 +1013,8 @@ fn target_for_tool_input(tool_name: &str, input: &serde_json::Value) -> Option Option> { @@ -1266,7 +1267,7 @@ fn command_prefix(command: &str) -> Option> { /// token-based matching unsafe: compound commands, expansions, redirects, /// and leading env assignments. Returns `None` unless the command is a /// single plain invocation. -fn safe_shell_argv(command: &str) -> Option> { +pub fn safe_shell_argv(command: &str) -> Option> { if command_contains_line_separators(command) { return None; } diff --git a/crates/kernel/rlm-runtime/src/rlm/__init__.py b/crates/kernel/rlm-runtime/src/rlm/__init__.py index c578241cb..ae949112a 100644 --- a/crates/kernel/rlm-runtime/src/rlm/__init__.py +++ b/crates/kernel/rlm-runtime/src/rlm/__init__.py @@ -10,6 +10,7 @@ from typing import Any from .bash import BashHandle, BashResult, bash +from .shell import shell from .harness import HarnessEntry, HarnessScope, HarnessState, RefinementEvent, get_harness_state _NOT_CALLABLE_MESSAGE = "'rlm' is not callable; spawn a child with: handle = await rlm.spawn('sub-task', name='worker')" @@ -479,6 +480,7 @@ def __repr__(self) -> str: class _RLMNamespace: + shell = staticmethod(shell) harness = _harness_state get_harness_state = staticmethod(get_harness_state) @@ -564,6 +566,7 @@ def __call__(self, *args: Any, **kwargs: Any) -> Any: "create_session", "RefinementEvent", "bash", + "shell", "delete_subagent", "emit", "find_models", diff --git a/crates/kernel/rlm-runtime/src/rlm/shell.py b/crates/kernel/rlm-runtime/src/rlm/shell.py new file mode 100644 index 000000000..d463b2fbe --- /dev/null +++ b/crates/kernel/rlm-runtime/src/rlm/shell.py @@ -0,0 +1,38 @@ +"""Shell execution through the host approval pipeline.""" + +from __future__ import annotations +from typing import Any + + +async def shell( + command: str, + *, + sandbox_permissions: str = "use_default", + justification: str | None = None, + additional_permissions: dict[str, Any] | None = None, + prefix_rule: list[str] | None = None, + timeout_ms: int | None = None, +) -> dict[str, Any]: + """Run a host shell command after authorization, returning its structured result. + + Use with_additional_permissions for narrow grants, or require_escalated for + an approved unsandboxed command. A denial raises RuntimeError; the persistent + Python kernel remains fenced. Ordinary bash() retains its local behavior. + """ + if not isinstance(command, str) or not command.strip(): + raise ValueError("command must be a non-empty string") + from . import host_request + + payload: dict[str, Any] = { + "command": command, + "sandbox_permissions": sandbox_permissions, + } + for key, value in { + "justification": justification, + "additional_permissions": additional_permissions, + "prefix_rule": prefix_rule, + "timeout_ms": timeout_ms, + }.items(): + if value is not None: + payload[key] = value + return await host_request("bash", payload) diff --git a/crates/kernel/rlm-runtime/tests/test_rlm_shell.py b/crates/kernel/rlm-runtime/tests/test_rlm_shell.py new file mode 100644 index 000000000..fea5a1d80 --- /dev/null +++ b/crates/kernel/rlm-runtime/tests/test_rlm_shell.py @@ -0,0 +1,50 @@ +import unittest +from unittest.mock import patch +import rlm + + +class ShellApprovalTests(unittest.IsolatedAsyncioTestCase): + async def test_namespace_and_module_forward_permission_request(self): + calls = [] + async def host(kind, payload): + calls.append((kind, payload)) + return {"status": "ok", "stdout": "probe"} + with patch.object(rlm, "host_request", new=host): + for helper in [rlm.shell, rlm.rlm.shell]: + self.assertEqual(await helper( + "echo probe", sandbox_permissions="require_escalated", + justification="May I run the probe?", timeout_ms=1000, + ), {"status": "ok", "stdout": "probe"}) + self.assertEqual(calls, [("bash", { + "command": "echo probe", "sandbox_permissions": "require_escalated", + "justification": "May I run the probe?", "timeout_ms": 1000, + })] * 2) + + async def test_narrow_grant_and_prefix_are_preserved(self): + calls = [] + async def host(kind, payload): + calls.append((kind, payload)) + return {"status": "ok"} + with patch.object(rlm, "host_request", new=host): + await rlm.shell("curl example.test", sandbox_permissions="with_additional_permissions", + additional_permissions={"network": {"enabled": True}}, prefix_rule=["curl"]) + self.assertEqual(calls, [("bash", {"command": "curl example.test", + "sandbox_permissions": "with_additional_permissions", + "additional_permissions": {"network": {"enabled": True}}, "prefix_rule": ["curl"]})]) + + async def test_denial_propagates_without_retrying_locally(self): + calls = [] + async def host(kind, payload): + calls.append(kind) + raise RuntimeError("declined by user") + with patch.object(rlm, "host_request", new=host): + with self.assertRaisesRegex(RuntimeError, "declined by user"): + await rlm.shell("echo probe", sandbox_permissions="require_escalated") + self.assertEqual(calls, ["bash"]) + + async def test_empty_command_never_calls_host(self): + async def host(kind, payload): + self.fail("invalid command reached the host") + with patch.object(rlm, "host_request", new=host): + with self.assertRaises(ValueError): + await rlm.shell(" ") diff --git a/crates/server/src/runtime/kernel_host_bridge.rs b/crates/server/src/runtime/kernel_host_bridge.rs index fbdd2ab33..6188c98bc 100644 --- a/crates/server/src/runtime/kernel_host_bridge.rs +++ b/crates/server/src/runtime/kernel_host_bridge.rs @@ -20,6 +20,9 @@ use uuid::Uuid; use super::ServerRuntime; use devo_core::McpManager; use devo_core::tools::contracts::ToolBudgets; +mod shell; +pub(crate) use shell::handle_bash; + use devo_core::tools::handlers::{ EditHandler, PlanHandler, QuestionHandler, ShellCommandHandler, WebFetchHandler, WebSearchHandler, WriteHandler, @@ -317,80 +320,6 @@ fn map_tool_outcome(result: Result) -> Value { } } -pub(crate) async fn handle_bash(bridge: &HostBridge, params: &Value) -> Value { - if plan_mode_denies_mutation(bridge) { - return deny_plan_mutation("bash"); - } - let command = params - .get("command") - .or_else(|| params.get("cmd")) - .cloned() - .unwrap_or(Value::Null); - let command_str = command.as_str().unwrap_or("").to_string(); - if command_str.is_empty() { - return error_reply("missing bash command"); - } - let mut input = params.clone(); - if input.get("command").is_none() { - input["command"] = json!(command_str.clone()); - } - let tool_call_id = synthetic_tool_call_id("bash"); - let req = permission_request( - bridge, - tool_call_id.clone(), - "shell_command", - input.clone(), - ResourceKind::ShellExec, - format!("Run shell command: {command_str}"), - None, - Some(command_str), - ); - let grant = match check_or_grant(bridge, req).await { - Ok(grant) => grant, - Err(reply) => return reply, - }; - let ctx = tool_context_from_bridge(bridge, tool_call_id.clone()); - let outcome = ShellCommandHandler::new() - .handle(ctx, input.clone(), None) - .await; - // UnlessTrusted retry (mirrors `ToolRuntime::execute_single`): a - // user-approved shell call the OS sandbox refused retries once unsandboxed - // — without this, kernel `bash` approvals visibly succeed while the - // command still fails with EACCES. Policy-allows (no approval) keep the - // denial so the model re-requests with `require_escalated`. - let outcome = match outcome { - Ok(ref output) - if grant.already_approved - && !grant.bypass_sandbox - && tool_result_is_sandbox_denied_bridge(output) - && devo_sandbox::unsandboxed_execution_allowed( - bridge.sandbox_profile.as_deref(), - &bridge.cwd, - ) => - { - let mut ctx = tool_context_from_bridge(bridge, tool_call_id); - ctx.sandbox_profile = Some("off".to_string()); - ShellCommandHandler::new().handle(ctx, input, None).await - } - other => other, - }; - map_tool_outcome(outcome) -} - -/// Bridge twin of the router's `tool_result_is_sandbox_denied` (private there): -/// only the structured `SANDBOX_DENIED:` prefix triggers the retry. -fn tool_result_is_sandbox_denied_bridge(output: &ToolResult) -> bool { - let text = match &output.content { - devo_core::tools::contracts::ToolResultContent::Text(text) => text.as_str(), - devo_core::tools::contracts::ToolResultContent::Mixed { - text: Some(text), .. - } => text.as_str(), - devo_core::tools::contracts::ToolResultContent::Json(_) - | devo_core::tools::contracts::ToolResultContent::Mixed { text: None, .. } => "", - }; - text.starts_with("SANDBOX_DENIED:") || output.result_summary.starts_with("SANDBOX_DENIED:") -} - async fn check_or_grant( bridge: &HostBridge, req: ToolPermissionRequest, diff --git a/crates/server/src/runtime/kernel_host_bridge/shell.rs b/crates/server/src/runtime/kernel_host_bridge/shell.rs new file mode 100644 index 000000000..9a2d9090f --- /dev/null +++ b/crates/server/src/runtime/kernel_host_bridge/shell.rs @@ -0,0 +1,244 @@ +//! Approval-mediated shell execution for the RLM kernel. +use super::*; +use devo_core::tools::{ + command_prefix_for_tool_input, generalize_command_pattern, safe_shell_argv, + sandbox_permission_request_from_input, +}; + +pub(crate) async fn handle_bash(bridge: &HostBridge, params: &Value) -> Value { + if plan_mode_denies_mutation(bridge) { + return deny_plan_mutation("bash"); + } + let command = params + .get("command") + .or_else(|| params.get("cmd")) + .cloned() + .unwrap_or(Value::Null); + let command_str = command.as_str().unwrap_or("").to_string(); + if command_str.is_empty() { + return error_reply("missing bash command"); + } + let mut input = params.clone(); + if input.get("command").is_none() { + input["command"] = json!(command_str.clone()); + } + let tool_call_id = synthetic_tool_call_id("bash"); + let mut req = permission_request( + bridge, + tool_call_id.clone(), + "shell_command", + input.clone(), + ResourceKind::ShellExec, + format!("Run shell command: {command_str}"), + /*path*/ None, + Some(command_str), + ); + req.sandbox_permissions = match sandbox_permission_request_from_input(&input) { + Ok(permissions) => permissions, + Err(error) => return error_reply(format!("invalid sandbox permission request: {error}")), + }; + req.justification = input + .get("justification") + .and_then(Value::as_str) + .map(str::to_string); + req.command_prefix = command_prefix_for_tool_input("shell_command", &input); + req.command_argv = safe_shell_argv(command.as_str().unwrap_or_default()); + req.command_pattern = + generalize_command_pattern(command.as_str().unwrap_or_default(), &bridge.cwd); + let grant = match check_or_grant(bridge, req).await { + Ok(grant) => grant, + Err(reply) => return reply, + }; + let mut ctx = tool_context_from_bridge(bridge, tool_call_id.clone()); + if grant.bypass_sandbox { + ctx.sandbox_profile = Some("off".to_string()); + } + ctx.sandbox_permission_overlay = grant.sandbox_permission_overlay.clone(); + let outcome = ShellCommandHandler::new() + .handle(ctx, input.clone(), /*_progress*/ None) + .await; + // UnlessTrusted retry (mirrors `ToolRuntime::execute_single`): a + // user-approved shell call the OS sandbox refused retries once unsandboxed + // — without this, kernel `bash` approvals visibly succeed while the + // command still fails with EACCES. Policy-allows (no approval) keep the + // denial so the model can explicitly request escalation through rlm.shell(). + let outcome = match outcome { + Ok(ref output) + if grant.already_approved + && !grant.bypass_sandbox + && grant.sandbox_permission_overlay.is_none() + && tool_result_is_sandbox_denied_bridge(output) + && devo_sandbox::unsandboxed_execution_allowed( + bridge.sandbox_profile.as_deref(), + &bridge.cwd, + ) => + { + let mut ctx = tool_context_from_bridge(bridge, tool_call_id); + ctx.sandbox_profile = Some("off".to_string()); + ShellCommandHandler::new() + .handle(ctx, input, /*_progress*/ None) + .await + } + other => other, + }; + map_tool_outcome(outcome) +} + +/// Bridge twin of the router's `tool_result_is_sandbox_denied` (private there): +/// only the structured `SANDBOX_DENIED:` prefix triggers the retry. +fn tool_result_is_sandbox_denied_bridge(output: &ToolResult) -> bool { + let text = match &output.content { + devo_core::tools::contracts::ToolResultContent::Text(text) => text.as_str(), + devo_core::tools::contracts::ToolResultContent::Mixed { + text: Some(text), .. + } => text.as_str(), + devo_core::tools::contracts::ToolResultContent::Json(_) + | devo_core::tools::contracts::ToolResultContent::Mixed { text: None, .. } => "", + }; + text.starts_with("SANDBOX_DENIED:") || output.result_summary.starts_with("SANDBOX_DENIED:") +} + +#[cfg(test)] +mod tests { + use super::*; + use devo_core::tools::PermissionGrant; + use pretty_assertions::assert_eq; + use std::sync::Mutex; + + #[tokio::test] + async fn denied_escalation_never_executes_command() { + let workspace = tempfile::tempdir().expect("workspace"); + let mut bridge = HostBridge::for_tests("ses_shell_denied"); + bridge.cwd = workspace.path().to_path_buf(); + let seen = Arc::new(Mutex::new(Vec::new())); + let captured = Arc::clone(&seen); + bridge.permission = PermissionChecker::new(move |request| { + let captured = Arc::clone(&captured); + Box::pin(async move { + captured.lock().unwrap().push(( + request.tool_name, + request.input, + request.justification, + request.sandbox_permissions, + request.target, + request.command_prefix, + request.command_argv, + request.command_pattern, + )); + Err("declined by user".to_string()) + }) + }); + let input = json!({ + "command": "echo denied > should-not-exist.txt", + "sandbox_permissions": "require_escalated", + "justification": "May I run the shell probe?" + }); + assert_eq!( + handle_bash(&bridge, &input).await, + json!({"status":"error", "error":"declined by user"}) + ); + assert_eq!( + *seen.lock().unwrap(), + vec![( + "shell_command".to_string(), + input, + Some("May I run the shell probe?".to_string()), + SandboxPermissionRequest::FullEscalation, + Some("echo denied > should-not-exist.txt".to_string()), + None, + None, + None, + )] + ); + assert!(!workspace.path().join("should-not-exist.txt").exists()); + } + + #[tokio::test] + async fn approved_escalation_runs_without_the_original_sandbox() { + let workspace = tempfile::tempdir().expect("workspace"); + let mut bridge = HostBridge::for_tests("ses_shell_approved"); + bridge.cwd = workspace.path().to_path_buf(); + // This deliberately invalid profile would fail if the approved bypass were lost. + bridge.sandbox_profile = Some("missing-test-profile".to_string()); + bridge.permission = PermissionChecker::new(|request| { + Box::pin( + async move { Ok(PermissionGrant::from_approval(&request.sandbox_permissions)) }, + ) + }); + let reply = handle_bash( + &bridge, + &json!({ + "command":"echo RLM_ESCALATION_OK", + "sandbox_permissions":"require_escalated", + "justification":"May I run a harmless echo outside the sandbox?" + }), + ) + .await; + assert_eq!(reply["status"], json!("ok"), "{reply}"); + assert!( + reply["stdout"] + .as_str() + .unwrap_or_default() + .contains("RLM_ESCALATION_OK"), + "{reply}" + ); + } + + #[tokio::test] + async fn invalid_permissions_and_plan_mode_never_authorize() { + let mut bridge = HostBridge::for_tests("ses_shell_invalid"); + bridge.permission = PermissionChecker::new(|_| { + Box::pin(async { panic!("invalid or Plan request reached authorization") }) + }); + assert_eq!( + handle_bash( + &bridge, + &json!({ + "command":"echo invalid", "sandbox_permissions":"invalid" + }) + ) + .await, + json!({"status":"error", "error":"invalid sandbox permission request: unsupported sandbox_permissions value: invalid"}) + ); + bridge.collaboration_mode = CollaborationMode::Plan; + assert_eq!( + handle_bash( + &bridge, + &json!({ + "command":"echo invalid", "sandbox_permissions":"require_escalated" + }) + ) + .await, + json!({"status":"error", "error":"denied: bash blocked in Plan mode"}) + ); + } + + #[tokio::test] + async fn narrow_permissions_reach_authorization_without_full_escalation() { + let mut bridge = HostBridge::for_tests("ses_shell_narrow"); + let seen = Arc::new(Mutex::new(Vec::new())); + let captured = Arc::clone(&seen); + bridge.permission = PermissionChecker::new(move |request| { + let captured = Arc::clone(&captured); + Box::pin(async move { + captured.lock().unwrap().push(request.sandbox_permissions); + Err("test stops before execution".to_string()) + }) + }); + let input = json!({"command":"echo narrow", "sandbox_permissions":"with_additional_permissions", "additional_permissions":{"network":{"enabled":true}}}); + assert_eq!( + handle_bash(&bridge, &input).await, + json!({"status":"error", "error":"test stops before execution"}) + ); + assert_eq!( + *seen.lock().unwrap(), + vec![SandboxPermissionRequest::AdditionalPermissions( + devo_core::tools::AdditionalSandboxPermissions { + network: devo_core::tools::NetworkPermission::Enabled, + read_paths: Vec::new(), + write_paths: Vec::new(), + } + )] + ); + } +} diff --git a/crates/windows-sandbox/src/setup.rs b/crates/windows-sandbox/src/setup.rs index 116f19934..38c67456d 100644 --- a/crates/windows-sandbox/src/setup.rs +++ b/crates/windows-sandbox/src/setup.rs @@ -1263,11 +1263,11 @@ fn user_profile_child_name(path: &Path, user_profile: &Path) -> Option { } fn filter_sensitive_write_roots(mut roots: Vec, devo_home: &Path) -> Vec { - // Never grant capability write access to DEVO_HOME or anything under - // DEVO_HOME/.sandbox, DEVO_HOME/.sandbox-bin, or DEVO_HOME/.sandbox-secrets. + // DEVO_HOME is writable agent state. Only the sandbox control directories + // DEVO_HOME/.sandbox, DEVO_HOME/.sandbox-bin, and DEVO_HOME/.sandbox-secrets + // are excluded from capability write grants. // These locations contain sandbox control/state and helper binaries and must // remain tamper-resistant. - let devo_home_key = canonical_path_key(devo_home); let sbx_dir_key = canonical_path_key(&sandbox_dir(devo_home)); let sbx_dir_prefix = format!("{}/", sbx_dir_key.trim_end_matches('/')); let sbx_bin_dir_key = canonical_path_key(&sandbox_bin_dir(devo_home)); @@ -1277,8 +1277,7 @@ fn filter_sensitive_write_roots(mut roots: Vec, devo_home: &Path) -> Ve roots.retain(|root| { let key = canonical_path_key(root); - key != devo_home_key - && key != sbx_dir_key + key != sbx_dir_key && !key.starts_with(&sbx_dir_prefix) && key != sbx_bin_dir_key && !key.starts_with(&sbx_bin_dir_prefix) @@ -1769,7 +1768,7 @@ mod tests { } #[test] - fn expanded_write_roots_still_drop_protected_devo_home() { + fn expanded_write_roots_preserve_writable_devo_home() { let tmp = TempDir::new().expect("tempdir"); let user_profile = tmp.path().join("user-profile"); let devo_home = user_profile.join("DevoHome"); @@ -1784,7 +1783,10 @@ mod tests { roots.retain(|root| !super::is_user_profile_root_exclusion(root, &user_profile)); let roots = super::filter_sensitive_write_roots(roots, &devo_home); - assert_eq!(vec![documents], roots); + assert_eq!( + [documents, devo_home].into_iter().collect::>(), + roots.into_iter().collect::>() + ); } #[test] @@ -1905,12 +1907,20 @@ mod tests { &workspace_write_profile(&[], true, true), workspace_roots_for(command_cwd.as_path()).as_slice(), ); - let override_roots = vec![ + let mut override_roots = vec![ command_cwd.clone(), extra_root.clone(), devo_home.clone(), sandbox_root.clone(), ]; + for root in [ + sandbox_root.join("nested"), + super::sandbox_bin_dir(&devo_home), + super::sandbox_secrets_dir(&devo_home), + ] { + fs::create_dir_all(&root).expect("create sandbox control root"); + override_roots.push(root); + } let request = super::SandboxSetupRequest { permissions: &permissions, command_cwd: &command_cwd, @@ -1942,9 +1952,12 @@ mod tests { effective_write_roots .contains(&dunce::canonicalize(&extra_root).expect("canonical extra root")) ); - assert!( - !effective_write_roots - .contains(&dunce::canonicalize(&devo_home).expect("canonical devo home")) + assert_eq!( + effective_write_roots.into_iter().collect::>(), + [&command_cwd, &extra_root, &devo_home] + .into_iter() + .map(|path| dunce::canonicalize(path).expect("canonical write root")) + .collect::>() ); let workspace_root = tmp.path().join("workspace-root"); diff --git a/docs/devo-home-permissions.md b/docs/devo-home-permissions.md new file mode 100644 index 000000000..595ebd657 --- /dev/null +++ b/docs/devo-home-permissions.md @@ -0,0 +1,29 @@ +# Devo home permissions + +`DEVO_HOME` (default: `~/.devo`) is agent-owned state. Shell tools and the RLM +Python kernel can read and write it, including root-level files. The kernel +receives this grant in both Plan and Build modes; Plan still keeps the project +workspace read-only. + +On Windows, the sandbox backend retains the Devo home write root instead of +filtering it out. Its own `.sandbox`, `.sandbox-bin`, and `.sandbox-secrets` +control directories retain their existing locks and cannot be granted as +independent writable roots. + +Session permission changes continue to use `session/metadata/update`. +The TUI `/permissions` selector offers `default`, `autoReview`, and `fullAccess`. + +## Verification (Windows, 2026-10-15) + +Built with `cargo build -p devo-cli -p devo-windows-sandbox --bins`. +The 20 Windows setup tests, two kernel-fence tests, and the Plan/Build workspace +permission test passed. Rust formatting checks passed for the changed modules. + +Manually launched `target/debug/devo.exe` under psmux with an explicit repository +cwd. Verified all three `/permissions` profiles and used the selector to switch +from `fullAccess` back to `default`. In the same session, two real `ipython` turns +created unique root-level probe files in `C:\Users\lenovo\.devo`, wrote and read +back a marker, and deleted the probes. The second turn ran after switching back +to `default`; its tool result reported write/read and deletion success. Server +logs confirmed the kernel launched with `state=Fenced` and completed its handshake. +The temporary psmux session was removed after verification. diff --git a/docs/rlm-shell-escalation.md b/docs/rlm-shell-escalation.md new file mode 100644 index 000000000..395577fae --- /dev/null +++ b/docs/rlm-shell-escalation.md @@ -0,0 +1,51 @@ +# RLM shell approval + +The default RLM tool surface exposes only `ipython`. Models, including +`gpt-5.6-luna`, can request shell permissions from a Python cell using: + +```python +result = await rlm.shell( + "echo permission-probe", + sandbox_permissions="require_escalated", + justification="May I run this harmless probe outside the sandbox?", +) +print(result) +``` + +Use `sandbox_permissions="with_additional_permissions"` with +`additional_permissions` for narrow filesystem or network grants, for example +`{"network": {"enabled": True}}` or +`{"file_system": {"write": ["/absolute/path"]}}` (use native absolute paths). The request +uses the same parsing, approval UI, permission cache and shell executor as the +shell tool. An approved full escalation disables sandboxing for that command; +a narrow approval applies its overlay. The persistent Python kernel stays fenced. +Plan mode continues to deny shell mutation. A rejected approval raises an error +without executing or retrying the command locally. + +Ordinary `bash()` remains a local, fenced, asynchronous process helper. Use +`rlm.shell()` when a command needs approval; do not add permission fields to the +outer `ipython` tool call. Both `import rlm; await rlm.shell(...)` and the +preloaded `rlm` namespace support the helper. Commands use the host platform shell (PowerShell on Windows); use native +syntax instead of nesting another shell. It returns the structured host +shell result, including output and status. `timeout_ms` and `prefix_rule` are +optional. File access still uses `rlm.read()` and `rlm.write()`. + +## Manual verification (Windows, 2026-10-15) + +Ran the product binary under psmux with `gpt-5.6-luna` and `default` permissions. +The model used `rlm.shell()` from a real Python tool cell: + +- Full escalation displayed an approval prompt; choosing Yes ran the echo probe + with exit 0 and captured its output. +- Choosing No for a second escalation returned `RuntimeError: rejected by user`; + the model stopped and did not retry through another route. +- A narrow `file_system.write` grant displayed an approval prompt and allowed a + native PowerShell command to create, read/assert, and delete a probe in an + explicitly approved directory outside the workspace, with exit 0. +- Server logs recorded `state=Fenced` for the persistent Python kernel and user + approval decisions. The test session and probe directory were cleaned up. + +The initial narrow-grant command nested PowerShell with incorrect quoting. It +failed at execution; using native PowerShell syntax passed. The host's existing +PowerShell encoding prelude emits constrained-language warnings for sandboxed +commands, but the corrected probe still completed all file operations with exit 0.