From 6646d4d3d769d7475a8e0ae6af8db6db9977dbce Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:52:57 -0700 Subject: [PATCH 1/4] =?UTF-8?q?feat(kernel):=20the=20system=20agent's=20de?= =?UTF-8?q?fined=20tool=20set,=20enforced=20(ADR=200004=20=C2=A712)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The system agent's turns got octos's full default tool set, shell included. `octosense_kernel::system_tools` now defines its set in one place: supervision (peer_send_input, peer_gather, peer_list, peer_respond, peer_close), its workspace's fenced file tools, memory, ask_user_question, media viewing, octos's web_search/web_fetch (until toolbox grants replace them, #108) and tool_search. No command execution, sub-agents, peer_handoff, administration, browser, plugin or MCP tools. Granted toolbox and cross-app tools join through `SystemAgentTools` (none yet). octos has no per-session tool roster, so every kernel start writes the set into the `_main` profile as its `tool_policy` (allowlist, with group:runtime, group:sessions and group:admin denied), replacing any other policy. octos applies it to every turn of the profile, wake continuations included; it is also the ceiling for app peers, AppCard and Rinx. Talk to Octos external turns keep octos's allowlist, which the set contains. Tests: a real-kernel test checks a system-agent turn is offered exactly the set over stdio and over Talk to Octos, and that an external turn keeps its allowlist; the app-peers question/answer test checks every request, the wake included, stays inside the set. The ADR 0007 approval test becomes: the system agent cannot get a command run through an app peer (no shell, nothing to approve). Co-Authored-By: Claude Opus 5.5 --- .../tests/fixtures/mock_agent_llm.py | 18 +- crates/app-peers/tests/fixtures/mock_llm.py | 13 +- crates/app-peers/tests/real_kernel.rs | 105 +++---- crates/kernel/README.md | 22 ++ crates/kernel/README.zh-CN.md | 18 ++ crates/kernel/src/launch.rs | 6 +- crates/kernel/src/lib.rs | 1 + crates/kernel/src/system_tools.rs | 288 ++++++++++++++++++ crates/kernel/tests/real_kernel.rs | 92 ++++++ 9 files changed, 505 insertions(+), 58 deletions(-) create mode 100644 crates/kernel/src/system_tools.rs diff --git a/crates/app-peers/tests/fixtures/mock_agent_llm.py b/crates/app-peers/tests/fixtures/mock_agent_llm.py index 62b6a879..e443fe83 100644 --- a/crates/app-peers/tests/fixtures/mock_agent_llm.py +++ b/crates/app-peers/tests/fixtures/mock_agent_llm.py @@ -9,13 +9,15 @@ "QUESTION_ME" ("TELL_PEER_HOLD:": "QUESTION_HOLD", never answered); - "TELL_PEER_AGAIN:": send the peer "SECOND_INPUT"; - "TELL_PEER_SUDO:": send the peer "RUN_SUDO", on which the peer runs - a shell command that needs the person's approval; - - "APPROVE_PEER:": try to approve the peer's pending tool approval - with peer_respond (the kernel must refuse it for a host-owned peer); + a shell command when it is offered `shell` (it never is: ADR 0004 §12), + else says "NO SHELL OFFERED"; + - "APPROVE_PEER:": try to approve a peer's tool with peer_respond; - a user text "QUESTION_ME" with ask_user_question offered: ask one question; - a message naming a waiting peer with peer_respond offered: answer "42"; - otherwise echo. -Prints its port, then serves until killed. Logs each decision to stderr. +Prints its port, then serves until killed. Logs each decision to stderr and, +with MOCK_LLM_TOOLS_LOG set, appends {"user": , "tools": +[]} per request to that file. """ import itertools import json @@ -61,6 +63,8 @@ def decide(body): return {"tool": "peer_send_input", "args": {"slug": sudo.group(1), "message": "RUN_SUDO"}} if "RUN_SUDO" in last_user and "shell" in tools: return {"tool": "shell", "args": {"command": "rm -rf ./approval-probe && echo APPROVED_RAN"}} + if "RUN_SUDO" in last_user: + return {"text": "NO SHELL OFFERED"} approve = re.search(r"APPROVE_PEER:([a-z0-9-]+)", last_user) if approve and "peer_respond" in tools: return {"tool": "peer_respond", "args": {"slug": approve.group(1), "decision": "approve"}} @@ -86,6 +90,12 @@ def log_message(self, *args): def do_POST(self): body = json.loads(self.rfile.read(int(self.headers.get("content-length", "0"))) or b"{}") decision = decide(body) + log = os.environ.get("MOCK_LLM_TOOLS_LOG") + if log: + user = next((text_of(m) for m in reversed(body.get("messages", [])) if m.get("role") == "user"), "") + tools = [t.get("function", {}).get("name") for t in body.get("tools", []) or []] + with open(log, "a") as f: + f.write(json.dumps({"user": user, "tools": tools}) + "\n") last = (body.get("messages") or [{}])[-1] print("decision:", json.dumps(decision), "after:", text_of(last)[:300].replace("\n", " "), file=sys.stderr, flush=True) if "tool" in decision: diff --git a/crates/app-peers/tests/fixtures/mock_llm.py b/crates/app-peers/tests/fixtures/mock_llm.py index 2fec7fd4..a56059f3 100644 --- a/crates/app-peers/tests/fixtures/mock_llm.py +++ b/crates/app-peers/tests/fixtures/mock_llm.py @@ -2,10 +2,14 @@ """A scripted OpenAI-compatible model for kernel tests. No network, no keys. POST /v1/chat/completions answers "ECHO: ". A user text that -contains SLOW waits 20 s first (for revocation tests). Prints its port on the -first line of stdout, then serves until killed. Standard library only. +contains SLOW waits 20 s first (for revocation tests). With +MOCK_LLM_TOOLS_LOG set, each request appends one JSON line {"user": , "tools": []} to that file (what a turn was offered). +Prints its port on the first line of stdout, then serves until killed. +Standard library only. """ import json +import os import sys import time from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer @@ -35,6 +39,11 @@ def do_POST(self): length = int(self.headers.get("content-length", "0")) body = json.loads(self.rfile.read(length) or b"{}") text = last_user_text(body) + log = os.environ.get("MOCK_LLM_TOOLS_LOG") + if log: + tools = [t.get("function", {}).get("name") for t in body.get("tools", []) or []] + with open(log, "a") as f: + f.write(json.dumps({"user": text, "tools": tools}) + "\n") if "SLOW" in text: time.sleep(20) reply = "ECHO: " + text.strip().splitlines()[-1] if text.strip() else "ECHO:" diff --git a/crates/app-peers/tests/real_kernel.rs b/crates/app-peers/tests/real_kernel.rs index 588792c5..de6e78be 100644 --- a/crates/app-peers/tests/real_kernel.rs +++ b/crates/app-peers/tests/real_kernel.rs @@ -304,13 +304,20 @@ fn an_account_change_drops_a_late_reply_and_resume_keeps_the_peer_across_restart /// Criterion 3 of ADR 0007 with a scripted model: the system agent sends the /// app peer input, the peer asks a question, the kernel wakes the system -/// agent, which answers, and the peer continues with the answer. +/// agent, which answers, and the peer continues with the answer. Every model +/// request on the way (the host's turn, the kernel's wake continuation, the +/// peer's turns) is offered only tools of the system agent's set (ADR 0004 +/// §12), never `shell`. #[test] fn the_system_agent_and_the_app_peer_exchange_a_question_and_answer() { let Some(program) = kernel() else { return }; let script = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/mock_agent_llm.py"); + let dir = temp("qa"); + std::fs::create_dir_all(&dir).unwrap(); + let offered_log = dir.join("offered.jsonl"); let mut child = std::process::Command::new("python3") .arg(script) + .env("MOCK_LLM_TOOLS_LOG", &offered_log) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::inherit()) .spawn() @@ -320,7 +327,6 @@ fn the_system_agent_and_the_app_peer_exchange_a_question_and_answer() { .read_line(&mut line) .unwrap(); let model = Model(child, line.trim().parse().unwrap()); - let dir = temp("qa"); let core_dir = dir.join("octos-home/.octos"); write_profile(&core_dir, model.1); let core = Core::new(Options::default().core_dir(&core_dir).program(&program)); @@ -358,6 +364,28 @@ fn the_system_agent_and_the_app_peer_exchange_a_question_and_answer() { text.contains("PEER GOT 42"), "the peer continued with the system agent's answer: {text}" ); + let allowed: BTreeSet<&str> = octosense_kernel::system_tools::SYSTEM_AGENT_TOOLS + .iter() + .copied() + .collect(); + let requests: Vec = std::fs::read_to_string(&offered_log) + .unwrap() + .lines() + .map(|l| serde_json::from_str(l).unwrap()) + .collect(); + for request in &requests { + for tool in request["tools"].as_array().unwrap() { + let tool = tool.as_str().unwrap(); + assert!(allowed.contains(tool), "{tool} offered: {request}"); + } + } + assert!( + requests.iter().any(|r| { + !r["user"].as_str().unwrap().contains("TELL_PEER") + && r["tools"].as_array().unwrap().iter().any(|t| t == "peer_respond") + }), + "the system agent's wake continuation was seen and checked" + ); rinx.release(); drop(rinx); @@ -553,13 +581,18 @@ fn a_second_input_to_an_answered_peer_runs() { let _ = std::fs::remove_dir_all(&dir); } -/// ADR 0007: a host-owned app peer's tool approval is the person's, answered -/// in the app. The system agent hands the peer work that needs an approval; -/// the kernel must not wake the system agent for it, and when the system -/// agent tries to approve it anyway with `peer_respond` the kernel refuses. -/// The approval stays pending until the person answers it through the host. +/// ADR 0004 §12 (was ADR 0007's approval test): an app peer has no command +/// execution, so the system agent cannot get a command run through it, by +/// asking or by "approving". The kernel profile's tool policy (the system +/// agent's set, written by `octosense-kernel` at every start) leaves `shell` +/// out of every `_main` session. The system agent handing the peer a +/// command gets no tool approval parked and nothing run; its `peer_respond` +/// "approval" finds nothing to approve. That octos refuses the system agent +/// an app peer's approval is octos's own test +/// (`ui_protocol_tests.rs`, ADR 0007); approvals return here with host-routed +/// app tools (octos#2567, ADR 0004 plan steps 6 and 7). #[test] -fn the_system_agent_cannot_approve_an_app_peers_tool() { +fn the_system_agent_cannot_get_a_command_run_through_an_app_peer() { let Some(program) = kernel() else { return }; let script = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/mock_agent_llm.py"); let mut child = std::process::Command::new("python3") @@ -612,74 +645,44 @@ fn the_system_agent_cannot_approve_an_app_peers_tool() { }; start_system_turn(format!("TELL_PEER_SUDO:{slug}")); - let mut approvals = Vec::new(); + let mut peer_text = String::new(); for _ in 0..120 { - approvals = pending_approvals(); - if !approvals.is_empty() { + peer_text = hydrate(&peer_session, &["messages"]).to_string(); + if peer_text.contains("NO SHELL OFFERED") { break; } std::thread::sleep(Duration::from_millis(500)); } - assert_eq!(approvals.len(), 1, "the peer parked on one tool approval"); - // Room for a wake to fire, were the kernel to send one. - std::thread::sleep(Duration::from_secs(3)); - // A woken system agent acts on the wake (the scripted one reaches for - // peer_respond), which leaves a tool result beyond the peer_send_input one. - let system_text = hydrate(system, &["messages"]).to_string(); - assert_eq!( - system_text.matches("\"role\":\"tool\"").count(), - 1, - "the system agent was not woken for the app peer's approval: {system_text}" + assert!( + peer_text.contains("NO SHELL OFFERED"), + "the peer ran the system agent's input without a shell tool: {peer_text}" ); + assert!(pending_approvals().is_empty(), "no tool approval parked"); start_system_turn(format!("APPROVE_PEER:{slug}")); let mut system_text = String::new(); for _ in 0..60 { system_text = hydrate(system, &["messages"]).to_string(); - if system_text.contains("host-owned app peer") { + if system_text.contains("not awaiting input") { break; } std::thread::sleep(Duration::from_millis(500)); } assert!( - system_text.contains("host-owned app peer"), - "peer_respond refused the system agent's approval: {system_text}" - ); - assert_eq!( - pending_approvals().len(), - 1, - "the approval is still the person's to answer" + system_text.contains("not awaiting input"), + "peer_respond had nothing to approve: {system_text}" ); + std::thread::sleep(Duration::from_secs(2)); + assert!(pending_approvals().is_empty()); assert!( !hydrate(&peer_session, &["messages"]) .to_string() .contains("APPROVED_RAN"), - "the command did not run on the system agent's say-so" + "no command ran" ); - // The person approves in the app; the peer's command then runs. - let approval_id = approvals[0]["approval_id"].as_str().unwrap().to_owned(); - rinx.host_request( - "approval/respond", - json!({"session_id": peer_session, "approval_id": approval_id, "decision": "approve"}), - ) - .expect("the person's approval"); - let mut ran = false; - for _ in 0..60 { - if pending_approvals().is_empty() - && hydrate(&peer_session, &["messages"]) - .to_string() - .contains("APPROVED_RAN") - { - ran = true; - break; - } - std::thread::sleep(Duration::from_millis(500)); - } - rinx.release(); drop(rinx); core.shutdown_within(Duration::from_secs(5)); - assert!(ran, "the peer ran the command once the person approved it"); let _ = std::fs::remove_dir_all(&dir); } diff --git a/crates/kernel/README.md b/crates/kernel/README.md index 03471cb8..3288fec4 100644 --- a/crates/kernel/README.md +++ b/crates/kernel/README.md @@ -155,6 +155,28 @@ scoped session agree. OpenHarmony (embedded core) and iOS (no kernel) have no Talk to Octos. See [ADR 0003](../../docs/adr/0003-shared-octos-client-access.md) for the threat model. +## The system agent's tools + +The system agent gets a defined tool set, not octos's full default one +([ADR 0004](../../docs/adr/0004-native-apps-hosting-and-peers.md) §12): +`system_tools::SYSTEM_AGENT_TOOLS` is the one list. Supervision (`peer_send_input`, +`peer_gather`, `peer_list`, `peer_respond`, `peer_close`), its workspace's +file tools (octos fences them to the session's working directory), memory, +`ask_user_question`, media viewing, octos's `web_search` / `web_fetch` (until +toolbox grants replace them, #108) and `tool_search`. No command execution +(`shell`, `bash`, `exec_command`, `write_stdin`), sub-agents, `peer_handoff`, +administration, browser, pipelines, plugin or MCP tools. Granted toolbox and +cross-app tools join it through `SystemAgentTools`. + +octos has no per-session tool roster, so the kernel enforces the list as the +`_main` profile's `tool_policy` (allowlist, plus `group:runtime`, +`group:sessions` and `group:admin` denied): every start writes it into +`/profiles/_main.json`, replacing any other policy. octos applies it +to every turn of the profile, kernel wake continuations included, so it is also +the ceiling for app peers, AppCard and Rinx sessions. Talk to Octos external +turns keep octos's own allowlist, which the list contains. The real-kernel +tests check what a turn is offered. + ## Testing From the repository root: diff --git a/crates/kernel/README.zh-CN.md b/crates/kernel/README.zh-CN.md index 863d5b3c..871efeee 100644 --- a/crates/kernel/README.zh-CN.md +++ b/crates/kernel/README.zh-CN.md @@ -129,6 +129,24 @@ adb -s SERIAL forward tcp:PORT tcp:PORT 和 iOS(没有内核)没有 Talk to Octos。威胁模型见 [ADR 0003(英文)](../../docs/adr/0003-shared-octos-client-access.md)。 +## 系统智能体的工具 + +系统智能体只拿到一组明确定义的工具,而不是 octos 的全部默认工具 +([ADR 0004](../../docs/adr/0004-native-apps-hosting-and-peers.md) §12): +唯一的清单是 `system_tools::SYSTEM_AGENT_TOOLS`。监督(`peer_send_input`、 +`peer_gather`、`peer_list`、`peer_respond`、`peer_close`)、其工作区内的文件工具 +(octos 将其限制在会话工作目录内)、记忆、`ask_user_question`、查看媒体、octos 的 +`web_search` / `web_fetch`(在工具箱授权取代它们之前,#108)以及 `tool_search`。 +没有命令执行(`shell`、`bash`、`exec_command`、`write_stdin`)、子智能体、 +`peer_handoff`、管理、浏览器、流水线、插件或 MCP 工具。授予的工具箱工具和跨应用工具通过 +`SystemAgentTools` 加入。 + +octos 没有按会话的工具名单,因此内核把该清单作为 `_main` profile 的 `tool_policy` +(允许列表,并拒绝 `group:runtime`、`group:sessions`、`group:admin`)来执行:每次启动都把它写入 +`/profiles/_main.json`,替换任何其他策略。octos 对该 profile 的每个回合(包括内核唤醒的续接回合) +都应用它,所以它也是应用 peer、AppCard 和 Rinx 会话的上限。Talk to Octos 外部回合保留 octos +自己的允许列表,该清单包含它。真实内核测试检查一个回合被提供了哪些工具。 + ## 测试 在仓库根目录: diff --git a/crates/kernel/src/launch.rs b/crates/kernel/src/launch.rs index 3eb8e1b4..af7ff47d 100644 --- a/crates/kernel/src/launch.rs +++ b/crates/kernel/src/launch.rs @@ -198,7 +198,8 @@ pub(crate) fn phone_stdio(program: PathBuf, core_dir: &Path, extra: &[(String, S /// Make what a start needs: the core dir, the cwd (a missing cwd fails the /// spawn's chdir with ENOENT, permanently, since the kernel would create it), -/// and on Android the kernel config's memory budget (as AppCard did). +/// on Android the kernel config's memory budget (as AppCard did), and the +/// system agent's tool policy in the profile ([`crate::system_tools`]). pub(crate) fn prepare(launch: &Launch, core_dir: &Path) { if let Err(e) = std::fs::create_dir_all(core_dir) { log::warn!("octos-core: could not create {}: {e}", core_dir.display()); @@ -217,6 +218,9 @@ pub(crate) fn prepare(launch: &Launch, core_dir: &Path) { if cfg!(target_os = "android") { ensure_kernel_config(&dirs::kernel_home(core_dir)); } + // Every start: the system agent's tool set (ADR 0004 §12) as the + // profile's tool policy, which octos reads at start. + crate::system_tools::enforce(core_dir); } /// Floor for `memory.max_inject_tokens` in the phone kernel's config. diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index d032064e..af45f094 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -44,6 +44,7 @@ mod network; pub use network::{connection_file, pairing_link, ClientAccess, Pairing, CONNECTION_FILE, SYSTEM_SESSION}; pub mod launch; mod router; +pub mod system_tools; pub use dirs::{kernel_home, profile_path, resolve_core_dir}; pub use launch::{Launch, Unavailable}; diff --git a/crates/kernel/src/system_tools.rs b/crates/kernel/src/system_tools.rs new file mode 100644 index 00000000..b90f1ddb --- /dev/null +++ b/crates/kernel/src/system_tools.rs @@ -0,0 +1,288 @@ +//! The system agent's tool set (ADR 0004 §12, plan step 4). +//! +//! The system agent (`_main:api:octosense#system`) gets an explicit list of +//! tools, not octos's full default set: no command execution, no file writes +//! outside its workspace, no delegation to unconfined sub-agents, no kernel +//! administration. This module is the ONE definition of that list, and the +//! kernel enforces it. +//! +//! **How it is enforced.** octos (the pinned rev) has no per-session or +//! per-turn tool roster for an ordinary session; the only roster control a +//! host has is the profile's `tool_policy` (allow/deny, deny wins), which +//! octos re-applies to every turn's FINISHED registry, after the per-turn +//! `peer_*`, `spawn` and `send_file` tools are registered, and to kernel +//! continuation turns alike. So before every kernel start ([`enforce`], from +//! `launch::prepare`) the host writes [`tool_policy`] into +//! `/profiles/_main.json`, replacing whatever policy is there. A +//! `tool_policy` change needs a kernel restart in octos, and every start +//! re-writes it, so a stale or edited profile never runs without it. +//! +//! **What else it bounds.** The policy is the profile's, so it is the +//! ceiling for every session of `_main`, not only the system agent: app +//! peers (which ADR 0004 §12 also denies command execution; octos#2567's +//! host tools are added after the policy, so it never strips them), AppCard +//! and Rinx sessions, and peers the system agent reaches. Talk to Octos +//! external turns are unaffected: [`SYSTEM_AGENT_TOOLS`] contains every tool +//! of octos's external allowlist ([`EXTERNAL_TURN_TOOLS`]), and octos +//! confines those turns further on its own (UPCR-2026-036). +//! +//! **Granted tools.** Toolbox tools (OctoSense#108) and cross-app tools +//! (ADR 0004 §7) the system agent is granted are host-routed tools. They +//! join the set through [`SystemAgentTools::grant_toolbox`] and +//! [`SystemAgentTools::grant_cross_app`]; nothing grants any yet, and octos +//! has no way yet to register host-routed tools on the system session +//! (octos#2567 registers them per app peer only). + +use std::collections::BTreeSet; +use std::path::Path; + +use serde_json::{json, Value}; + +/// The kernel tools the system agent is offered, and nothing else. +/// +/// - **Supervision** of app peers (ADR 0004 §6): `peer_send_input` briefs +/// and asks, `peer_gather` / `peer_list` read the blackboard, +/// `peer_respond` answers a peer's question (never its approvals, which +/// octos refuses), `peer_close` retires one. Not `peer_handoff`: app peers +/// are prepared by the host (`peer/prepare`), and an ad-hoc peer would be +/// an agent outside every grant. +/// - **Its workspace**, fenced by octos to the session's working directory: +/// read, search and edit files there. +/// - **The person**: `ask_user_question`, media viewing. +/// - **Memory**: recall and search, and saving to its own namespace. +/// - **The web**: octos's builtin `web_search` / `web_fetch`, which Talk to +/// Octos clients also keep, until the toolbox (#108) grants the system +/// agent `toolbox.search` / `toolbox.web_read`. +/// +/// Absent on purpose: `shell`, `exec_command`, `write_stdin`, `bash` and +/// every other code or command execution (`group:runtime`); `spawn`, +/// `delegate` and the sub-agent tools (`group:sessions`); skill, tool and +/// model administration (`group:admin`); `browser`, `deep_search`, +/// `deep_crawl`, pipelines; plugin and MCP tools (they are not named here, +/// so the allowlist drops them whatever they are called). +pub const SYSTEM_AGENT_TOOLS: &[&str] = &[ + // Supervision. + "peer_send_input", + "peer_gather", + "peer_list", + "peer_respond", + "peer_close", + // Its workspace (octos fences these to the session's working directory). + "read_file", + "write_file", + "edit_file", + "diff_edit", + "apply_patch", + "glob", + "grep", + "list_dir", + "code_structure", + "check_workspace_contract", + // The person. + "ask_user_question", + "view_image", + "view_video", + // Memory. + "recall", + "recall_memory", + "memory_search", + "memory_load", + "save_memory", + "memory_note", + // The web (until the toolbox grants replace them, #108). + "web_search", + "web_fetch", + // Tool discovery over this same set. + "tool_search", +]; + +/// Denied whatever the allowlist says (octos: deny always wins): command +/// execution, sub-agents and administration, as octos's own groups. +pub const SYSTEM_AGENT_DENIED: &[&str] = &["group:runtime", "group:sessions", "group:admin"]; + +/// octos's allowlist for a Talk to Octos external turn (octos +/// `crates/octos-cli/src/api/host_managed.rs`, `EXTERNAL_TURN_TOOLS`, at the +/// pinned rev). Kept here to prove the system agent's set never narrows it. +pub const EXTERNAL_TURN_TOOLS: &[&str] = &[ + "read_file", + "write_file", + "edit_file", + "diff_edit", + "apply_patch", + "glob", + "grep", + "list_dir", + "code_structure", + "check_workspace_contract", + "web_search", + "web_fetch", + "ask_user_question", + "recall", + "recall_memory", + "memory_search", + "memory_load", + "view_image", + "view_video", + "tool_search", +]; + +/// The system agent's tool set: [`SYSTEM_AGENT_TOOLS`] plus what it is +/// granted. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct SystemAgentTools { + toolbox: BTreeSet, + cross_app: BTreeSet, +} + +impl SystemAgentTools { + /// The set as shipped: no grants. + pub fn new() -> Self { + Self::default() + } + + /// Grant a toolbox tool (`toolbox.search`, …; ADR 0002 §6, #108). + pub fn grant_toolbox(&mut self, tool: impl Into) -> &mut Self { + self.toolbox.insert(tool.into()); + self + } + + /// Grant another app's shareable tool (`mail.send`, …; ADR 0004 §7). + pub fn grant_cross_app(&mut self, tool: impl Into) -> &mut Self { + self.cross_app.insert(tool.into()); + self + } + + /// Every tool name a system-agent turn may be offered. + pub fn names(&self) -> BTreeSet { + SYSTEM_AGENT_TOOLS + .iter() + .map(|t| t.to_string()) + .chain(self.toolbox.iter().cloned()) + .chain(self.cross_app.iter().cloned()) + .collect() + } + + /// The octos `ToolPolicy` that enforces the set. + pub fn tool_policy(&self) -> Value { + json!({ + "allow": self.names().into_iter().collect::>(), + "deny": SYSTEM_AGENT_DENIED, + }) + } +} + +/// The policy the kernel runs with (no grants yet). +pub fn tool_policy() -> Value { + SystemAgentTools::new().tool_policy() +} + +/// Write the system agent's policy into `/profiles/_main.json` +/// (`config.tool_policy`), keeping every other key. No profile yet means no +/// provider and no turns: nothing to do. An unreadable profile is left for +/// the kernel to report (it cannot run turns from it either). +pub fn enforce(core_dir: &Path) { + let path = crate::dirs::profile_path(core_dir); + let Ok(bytes) = std::fs::read(&path) else { return }; + let Ok(mut root) = serde_json::from_slice::(&bytes) else { + log::warn!("octos-core: {} is not JSON; tool policy NOT written", path.display()); + return; + }; + let Some(obj) = root.as_object_mut() else { + log::warn!("octos-core: {} is not a JSON object; tool policy NOT written", path.display()); + return; + }; + let config = obj.entry("config").or_insert_with(|| json!({})); + let Some(config) = config.as_object_mut() else { + log::warn!("octos-core: {} `config` is not an object; tool policy NOT written", path.display()); + return; + }; + let policy = tool_policy(); + if config.get("tool_policy") == Some(&policy) { + return; + } + config.insert("tool_policy".into(), policy); + let result = serde_json::to_vec_pretty(&root) + .map_err(|e| e.to_string()) + .and_then(|body| { + let dir = path.parent().unwrap_or(core_dir); + let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("_main.json"); + crate::network::write_private(dir, name, &body).map_err(|e| e.to_string()) + }); + match result { + Ok(()) => log::info!("octos-core: wrote the system agent's tool policy to {}", path.display()), + Err(e) => log::warn!("octos-core: could not write the tool policy to {}: {e}", path.display()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tmp(tag: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("octos-systools-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("profiles")).unwrap(); + dir + } + + #[test] + fn no_command_execution_and_no_unconfined_agents() { + for denied in [ + "shell", "exec_command", "write_stdin", "bash", "spawn", "spawn_agent", "delegate", + "peer_handoff", "browser", "deep_search", "deep_crawl", "run_pipeline", "manage_skills", + "configure_tool", "model_check", + ] { + assert!(!SYSTEM_AGENT_TOOLS.contains(&denied), "{denied}"); + } + let policy = tool_policy(); + let deny: Vec<&str> = policy["deny"].as_array().unwrap().iter().filter_map(Value::as_str).collect(); + assert!(deny.contains(&"group:runtime"), "command execution is denied outright"); + } + + #[test] + fn talk_to_octos_clients_keep_their_tools() { + for tool in EXTERNAL_TURN_TOOLS { + assert!(SYSTEM_AGENT_TOOLS.contains(tool), "{tool} would be taken from external clients"); + } + } + + #[test] + fn the_list_has_no_duplicates_and_grants_join_it() { + let names = SystemAgentTools::new().names(); + assert_eq!(names.len(), SYSTEM_AGENT_TOOLS.len()); + let mut granted = SystemAgentTools::new(); + granted.grant_toolbox("toolbox.search").grant_cross_app("mail.send"); + let policy = granted.tool_policy(); + let allow: Vec<&str> = policy["allow"].as_array().unwrap().iter().filter_map(Value::as_str).collect(); + assert!(allow.contains(&"toolbox.search") && allow.contains(&"mail.send")); + assert_eq!(allow.len(), SYSTEM_AGENT_TOOLS.len() + 2); + } + + #[test] + fn enforce_writes_the_policy_and_keeps_the_rest() { + let dir = tmp("keep"); + let path = dir.join("profiles/_main.json"); + std::fs::write( + &path, + r#"{"id":"_main","config":{"llm":{"primary":{"family_id":"x"}},"tool_policy":{"allow":["shell"]}}}"#, + ) + .unwrap(); + enforce(&dir); + let v: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + assert_eq!(v["id"], "_main"); + assert_eq!(v["config"]["llm"]["primary"]["family_id"], "x"); + assert_eq!(v["config"]["tool_policy"], tool_policy(), "a widened policy is replaced"); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn enforce_without_a_profile_or_with_a_broken_one_leaves_it() { + let dir = tmp("none"); + enforce(&dir); + assert!(!dir.join("profiles/_main.json").exists(), "no profile is invented"); + std::fs::write(dir.join("profiles/_main.json"), "{not json").unwrap(); + enforce(&dir); + assert_eq!(std::fs::read_to_string(dir.join("profiles/_main.json")).unwrap(), "{not json"); + let _ = std::fs::remove_dir_all(dir); + } +} diff --git a/crates/kernel/tests/real_kernel.rs b/crates/kernel/tests/real_kernel.rs index 9d532818..04f78c45 100644 --- a/crates/kernel/tests/real_kernel.rs +++ b/crates/kernel/tests/real_kernel.rs @@ -463,6 +463,98 @@ async fn native_and_browser_talk_to_the_same_system_agent() { let _ = std::fs::remove_dir_all(dir); } +/// ADR 0004 §12: a system-agent turn is offered exactly the system agent's +/// tool set (no `shell`), whoever starts it, and a Talk to Octos external +/// client's turn keeps octos's external allowlist. The scripted model logs +/// the tool names each request offered. The profile is written WITHOUT a +/// tool policy, and with a widening one: the kernel start writes its own. +#[tokio::test(flavor = "multi_thread")] +async fn a_system_agent_turn_is_offered_exactly_the_system_agent_tools() { + use octosense_kernel::system_tools::{EXTERNAL_TURN_TOOLS, SYSTEM_AGENT_TOOLS}; + use std::collections::BTreeSet; + use std::io::BufRead; + let Some(program) = kernel() else { return }; + let dir = std::env::temp_dir().join(format!("octos-system-tools-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("profiles")).unwrap(); + let log = dir.join("offered.jsonl"); + let script = Path::new(env!("CARGO_MANIFEST_DIR")).join("../app-peers/tests/fixtures/mock_llm.py"); + let mut model = std::process::Command::new("python3").arg(script).env("MOCK_LLM_TOOLS_LOG", &log) + .stdout(std::process::Stdio::piped()).spawn().unwrap(); + let mut line = String::new(); + std::io::BufReader::new(model.stdout.take().unwrap()).read_line(&mut line).unwrap(); + let port: u16 = line.trim().parse().unwrap(); + let _model = Defer(move || { let _ = model.kill(); let _ = model.wait(); }); + std::fs::write(dir.join("profiles/_main.json"), json!({ + "id":"_main", "name":"Main", "enabled":true, + "created_at":"2026-09-27T00:00:00Z", "updated_at":"2026-09-27T00:00:00Z", + "config":{"llm":{"primary":{"family_id":"local", "model_id":"mock-model", + "route":{"base_url":format!("http://127.0.0.1:{port}/v1"), "api_type":"openai"}}}, + "tool_policy":{"allow":["*"]}} + }).to_string()).unwrap(); + std::fs::write(dir.join("web-client-origin.txt"), WEB).unwrap(); + let offered = |probe: &str| -> Option> { + let text = std::fs::read_to_string(&log).ok()?; + text.lines().filter_map(|l| serde_json::from_str::(l).ok()) + .filter(|v| v["user"].as_str().is_some_and(|u| u.contains(probe))) + .find(|v| v["tools"].as_array().is_some_and(|t| !t.is_empty())) + .map(|v| v["tools"].as_array().unwrap().iter().filter_map(Value::as_str).map(str::to_owned).collect()) + }; + let wait_for = |probe: &'static str| { + let offered = &offered; + async move { + tokio::time::timeout(Duration::from_secs(60), async { + loop { + if let Some(tools) = offered(probe) { return tools; } + tokio::time::sleep(Duration::from_millis(100)).await; + } + }).await.unwrap_or_else(|_| panic!("the model never saw the {probe} turn")) + } + }; + // `recall` (a session's evicted tool outputs) is registered only on + // octos's session-actor turns, not on UI Protocol turns: allowed, never + // offered here. + let expected: BTreeSet = SYSTEM_AGENT_TOOLS.iter().filter(|t| **t != "recall") + .map(|t| t.to_string()).collect(); + let input = |text: &str| json!({"session_id":SYSTEM_SESSION,"turn_id":uuid::Uuid::new_v4(), + "input":[{"kind":"text","text":text}]}); + + // The private stdio kernel (Talk to Octos off): the host's own turn. + let core = Core::new(Options::default().program(&program).core_dir(&dir)); + let mut native = core.connect().unwrap(); + call(&mut native, "open", "session/open", json!({"session_id":SYSTEM_SESSION,"profile_id":"_main"})).await; + call(&mut native, "turn", "turn/start", input("SYSTEM_TOOLS_STDIO")).await; + let stdio = wait_for("SYSTEM_TOOLS_STDIO").await; + assert!(!stdio.contains("shell"), "{stdio:?}"); + assert_eq!(stdio, expected, "a system-agent turn is offered exactly the system agent's tools"); + drop(native); + core.shutdown_within(Duration::from_secs(15)); + + // Talk to Octos on: the host's turn over the WebSocket, then an external + // client's turn on the same system conversation. + let core = Core::new(Options::default().program(&program).core_dir(&dir)); + let c = core.clone(); + blocking(move || c.set_external_access(true)).await.unwrap(); + let mut native = core.connect().unwrap(); + let access = native.client_access().await.unwrap(); + let open = json!({"session_id":SYSTEM_SESSION,"profile_id":"_main"}); + call(&mut native, "open", "session/open", open.clone()).await; + call(&mut native, "turn", "turn/start", input("SYSTEM_TOOLS_HOST")).await; + assert_eq!(wait_for("SYSTEM_TOOLS_HOST").await, expected, "the same set over Talk to Octos"); + let mut browser = external(&access).await; + ws_call(&mut browser, "open", "session/open", open).await; + ws_call(&mut browser, "turn", "turn/start", input("SYSTEM_TOOLS_EXTERNAL")).await; + let external_tools = wait_for("SYSTEM_TOOLS_EXTERNAL").await; + let allowlist: BTreeSet = EXTERNAL_TURN_TOOLS.iter().map(|t| t.to_string()).collect(); + assert!(external_tools.is_subset(&allowlist), "{external_tools:?}"); + assert_eq!(external_tools, &expected & &allowlist, "external clients keep every allowlisted tool the kernel offers"); + drop((native, browser)); + let c = core.clone(); + blocking(move || c.set_external_access(false)).await.unwrap(); + core.shutdown_within(Duration::from_secs(15)); + let _ = std::fs::remove_dir_all(dir); +} + /// The kernel must not outlive a host that dies without stopping it /// (a crash or SIGKILL): its stdin closes and it stops. This test re-runs /// itself as the host in a child process and kills that process. From efd221ea305e49af8b500e05770d96bc0528e9b2 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:48:48 -0700 Subject: [PATCH 2/4] fix(kernel): a profile ceiling of grantable tools; system agent command execution by grant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR 0004 §12 as accepted: app agents get whatever their manifest declares and the person grants, octos generic tools included, and command execution is a grantable host tool (terminal.run) with a live approval, never octos's shell. The `_main` profile policy capped every session at the system agent's list, below what an app can be granted. The kernel now writes a ceiling instead: the system agent's tools plus every octos generic tool an app may be granted (APP_GRANTABLE_OCTOS_TOOLS), with octos's process tools (group:runtime, check, git), sub-agents, peer_handoff, schedulers, goals and administration denied (NEVER_OFFERED). App peers are narrowed to their grants by `generic_tools` (plan step 6). octos has no per-session tool list yet (octos#2567 item 5), so the system agent is bounded by the ceiling, not its exact list. The real-kernel test asserts what is enforced (no shell, nothing outside the ceiling, its own tools present, Talk to Octos allowlist kept); the exact-list test is kept and ignored until item 5 lands. `SystemAgentTools::grant_command_execution` (off by default) is the seam for the Settings grant: on, the system agent's host tools include terminal.run (registration needs octos#2567 item 6; persisting the switch is a TODO, no Settings plumbing exists yet). Co-Authored-By: Claude Opus 5.5 --- .../tests/fixtures/mock_agent_llm.py | 4 +- crates/app-peers/tests/real_kernel.rs | 30 +- crates/kernel/README.md | 47 +-- crates/kernel/README.zh-CN.md | 32 +- crates/kernel/src/system_tools.rs | 285 +++++++++++++----- crates/kernel/tests/real_kernel.rs | 83 +++-- 6 files changed, 325 insertions(+), 156 deletions(-) diff --git a/crates/app-peers/tests/fixtures/mock_agent_llm.py b/crates/app-peers/tests/fixtures/mock_agent_llm.py index e443fe83..a2916479 100644 --- a/crates/app-peers/tests/fixtures/mock_agent_llm.py +++ b/crates/app-peers/tests/fixtures/mock_agent_llm.py @@ -9,8 +9,8 @@ "QUESTION_ME" ("TELL_PEER_HOLD:": "QUESTION_HOLD", never answered); - "TELL_PEER_AGAIN:": send the peer "SECOND_INPUT"; - "TELL_PEER_SUDO:": send the peer "RUN_SUDO", on which the peer runs - a shell command when it is offered `shell` (it never is: ADR 0004 §12), - else says "NO SHELL OFFERED"; + a shell command when it is offered octos's `shell` (the kernel profile's + ceiling denies it: ADR 0004 §12), else says "NO SHELL OFFERED"; - "APPROVE_PEER:": try to approve a peer's tool with peer_respond; - a user text "QUESTION_ME" with ask_user_question offered: ask one question; - a message naming a waiting peer with peer_respond offered: answer "42"; diff --git a/crates/app-peers/tests/real_kernel.rs b/crates/app-peers/tests/real_kernel.rs index de6e78be..f447f4cd 100644 --- a/crates/app-peers/tests/real_kernel.rs +++ b/crates/app-peers/tests/real_kernel.rs @@ -306,8 +306,8 @@ fn an_account_change_drops_a_late_reply_and_resume_keeps_the_peer_across_restart /// app peer input, the peer asks a question, the kernel wakes the system /// agent, which answers, and the peer continues with the answer. Every model /// request on the way (the host's turn, the kernel's wake continuation, the -/// peer's turns) is offered only tools of the system agent's set (ADR 0004 -/// §12), never `shell`. +/// peer's turns) is offered only tools inside the kernel profile's ceiling +/// (ADR 0004 §12), never octos's `shell`. #[test] fn the_system_agent_and_the_app_peer_exchange_a_question_and_answer() { let Some(program) = kernel() else { return }; @@ -364,10 +364,7 @@ fn the_system_agent_and_the_app_peer_exchange_a_question_and_answer() { text.contains("PEER GOT 42"), "the peer continued with the system agent's answer: {text}" ); - let allowed: BTreeSet<&str> = octosense_kernel::system_tools::SYSTEM_AGENT_TOOLS - .iter() - .copied() - .collect(); + let allowed = octosense_kernel::system_tools::profile_ceiling(); let requests: Vec = std::fs::read_to_string(&offered_log) .unwrap() .lines() @@ -581,16 +578,17 @@ fn a_second_input_to_an_answered_peer_runs() { let _ = std::fs::remove_dir_all(&dir); } -/// ADR 0004 §12 (was ADR 0007's approval test): an app peer has no command -/// execution, so the system agent cannot get a command run through it, by -/// asking or by "approving". The kernel profile's tool policy (the system -/// agent's set, written by `octosense-kernel` at every start) leaves `shell` -/// out of every `_main` session. The system agent handing the peer a -/// command gets no tool approval parked and nothing run; its `peer_respond` -/// "approval" finds nothing to approve. That octos refuses the system agent -/// an app peer's approval is octos's own test -/// (`ui_protocol_tests.rs`, ADR 0007); approvals return here with host-routed -/// app tools (octos#2567, ADR 0004 plan steps 6 and 7). +/// ADR 0004 §12 (was ADR 0007's approval test): octos's own `shell` is +/// offered to no `_main` session (the kernel profile's ceiling, written by +/// `octosense-kernel` at every start, denies it), so the system agent cannot +/// get a command run through an app peer by asking or by "approving". The +/// system agent handing the peer a command gets no tool approval parked and +/// nothing run; its `peer_respond` "approval" finds nothing to approve. +/// Command execution an app is granted arrives as a host tool with a live +/// approval (for example `terminal.run`), registered by the shell (plan +/// steps 6 and 7); approvals of app tools return here then. That octos +/// refuses the system agent an app peer's approval is octos's own test +/// (`ui_protocol_tests.rs`, ADR 0007). #[test] fn the_system_agent_cannot_get_a_command_run_through_an_app_peer() { let Some(program) = kernel() else { return }; diff --git a/crates/kernel/README.md b/crates/kernel/README.md index 3288fec4..4e65ae3e 100644 --- a/crates/kernel/README.md +++ b/crates/kernel/README.md @@ -158,24 +158,35 @@ for the threat model. ## The system agent's tools The system agent gets a defined tool set, not octos's full default one -([ADR 0004](../../docs/adr/0004-native-apps-hosting-and-peers.md) §12): -`system_tools::SYSTEM_AGENT_TOOLS` is the one list. Supervision (`peer_send_input`, -`peer_gather`, `peer_list`, `peer_respond`, `peer_close`), its workspace's -file tools (octos fences them to the session's working directory), memory, -`ask_user_question`, media viewing, octos's `web_search` / `web_fetch` (until -toolbox grants replace them, #108) and `tool_search`. No command execution -(`shell`, `bash`, `exec_command`, `write_stdin`), sub-agents, `peer_handoff`, -administration, browser, pipelines, plugin or MCP tools. Granted toolbox and -cross-app tools join it through `SystemAgentTools`. - -octos has no per-session tool roster, so the kernel enforces the list as the -`_main` profile's `tool_policy` (allowlist, plus `group:runtime`, -`group:sessions` and `group:admin` denied): every start writes it into -`/profiles/_main.json`, replacing any other policy. octos applies it -to every turn of the profile, kernel wake continuations included, so it is also -the ceiling for app peers, AppCard and Rinx sessions. Talk to Octos external -turns keep octos's own allowlist, which the list contains. The real-kernel -tests check what a turn is offered. +([ADR 0004](../../docs/adr/0004-native-apps-hosting-and-peers.md) §12). Its +octos tools are `system_tools::SYSTEM_AGENT_TOOLS`: supervision +(`peer_send_input`, `peer_gather`, `peer_list`, `peer_respond`, `peer_close`), +its workspace's file tools (octos fences them to the session's working +directory), memory, `ask_user_question`, media viewing, octos's `web_search` / +`web_fetch` (until toolbox grants replace them, #108) and `tool_search`. +Granted toolbox and cross-app tools, and command execution when the person +turns it on in Settings (off by default), join it as host tools through +`SystemAgentTools`; command execution is a host tool with a live approval +(`terminal.run`), never octos's `shell`. + +**What the kernel enforces today.** octos has no per-session tool list, so +every start writes a CEILING into `/profiles/_main.json` as the +profile's `tool_policy`, replacing any other: the system agent's tools plus +every octos generic tool an app may be granted +(`system_tools::APP_GRANTABLE_OCTOS_TOOLS`), with octos's process tools +(`shell` and the rest of `group:runtime`, `check`, `git`), sub-agents, +`peer_handoff`, schedulers, goals and administration denied +(`system_tools::NEVER_OFFERED`). octos applies it to every turn of the +profile, wake continuations included. So: + +- the system agent never gets `shell` or any tool outside the ceiling, but is + bounded by the ceiling rather than its exact list until octos lets the host + set a session's tool list (octos#2567 item 5); the exact-list real-kernel + test is ignored until then; +- app peers are not capped below what they can be granted; each is narrowed + to its grants by its turns' `generic_tools` (plan step 6); +- Talk to Octos external turns keep octos's own allowlist, which the ceiling + contains. ## Testing diff --git a/crates/kernel/README.zh-CN.md b/crates/kernel/README.zh-CN.md index 871efeee..1a1db4ca 100644 --- a/crates/kernel/README.zh-CN.md +++ b/crates/kernel/README.zh-CN.md @@ -132,20 +132,24 @@ adb -s SERIAL forward tcp:PORT tcp:PORT ## 系统智能体的工具 系统智能体只拿到一组明确定义的工具,而不是 octos 的全部默认工具 -([ADR 0004](../../docs/adr/0004-native-apps-hosting-and-peers.md) §12): -唯一的清单是 `system_tools::SYSTEM_AGENT_TOOLS`。监督(`peer_send_input`、 -`peer_gather`、`peer_list`、`peer_respond`、`peer_close`)、其工作区内的文件工具 -(octos 将其限制在会话工作目录内)、记忆、`ask_user_question`、查看媒体、octos 的 -`web_search` / `web_fetch`(在工具箱授权取代它们之前,#108)以及 `tool_search`。 -没有命令执行(`shell`、`bash`、`exec_command`、`write_stdin`)、子智能体、 -`peer_handoff`、管理、浏览器、流水线、插件或 MCP 工具。授予的工具箱工具和跨应用工具通过 -`SystemAgentTools` 加入。 - -octos 没有按会话的工具名单,因此内核把该清单作为 `_main` profile 的 `tool_policy` -(允许列表,并拒绝 `group:runtime`、`group:sessions`、`group:admin`)来执行:每次启动都把它写入 -`/profiles/_main.json`,替换任何其他策略。octos 对该 profile 的每个回合(包括内核唤醒的续接回合) -都应用它,所以它也是应用 peer、AppCard 和 Rinx 会话的上限。Talk to Octos 外部回合保留 octos -自己的允许列表,该清单包含它。真实内核测试检查一个回合被提供了哪些工具。 +([ADR 0004](../../docs/adr/0004-native-apps-hosting-and-peers.md) §12)。它的 octos +工具是 `system_tools::SYSTEM_AGENT_TOOLS`:监督(`peer_send_input`、`peer_gather`、 +`peer_list`、`peer_respond`、`peer_close`)、其工作区内的文件工具(octos 将其限制在会话工作目录内)、 +记忆、`ask_user_question`、查看媒体、octos 的 `web_search` / `web_fetch`(在工具箱授权取代它们之前,#108) +以及 `tool_search`。授予的工具箱工具和跨应用工具,以及用户在设置中开启(默认关闭)的命令执行, +通过 `SystemAgentTools` 作为宿主工具加入;命令执行是需要实时批准的宿主工具(`terminal.run`), +绝不是 octos 的 `shell`。 + +**内核今天执行的内容。** octos 没有按会话的工具名单,因此每次启动都把一个“上限”作为 profile 的 +`tool_policy` 写入 `/profiles/_main.json`,替换任何其他策略:系统智能体的工具,加上应用可被授予的每个 +octos 通用工具(`system_tools::APP_GRANTABLE_OCTOS_TOOLS`),并拒绝 octos 的进程工具(`shell` 及 +`group:runtime` 其余工具、`check`、`git`)、子智能体、`peer_handoff`、调度器、目标和管理工具 +(`system_tools::NEVER_OFFERED`)。octos 对该 profile 的每个回合(包括唤醒续接回合)都应用它。因此: + +- 系统智能体永远拿不到 `shell` 或上限之外的工具,但在 octos 允许宿主设置会话工具名单(octos#2567 第 5 项)之前, + 约束它的是上限而不是它的精确清单;精确清单的真实内核测试在此之前被忽略; +- 应用 peer 不会被限制在其可获授权之下;每个 peer 由其回合的 `generic_tools` 收窄到其授权(计划第 6 步); +- Talk to Octos 外部回合保留 octos 自己的允许列表,上限包含它。 ## 测试 diff --git a/crates/kernel/src/system_tools.rs b/crates/kernel/src/system_tools.rs index b90f1ddb..9de14ae4 100644 --- a/crates/kernel/src/system_tools.rs +++ b/crates/kernel/src/system_tools.rs @@ -1,44 +1,57 @@ -//! The system agent's tool set (ADR 0004 §12, plan step 4). +//! The system agent's tool set and the kernel profile's tool ceiling +//! (ADR 0004 §12, plan step 4). //! -//! The system agent (`_main:api:octosense#system`) gets an explicit list of -//! tools, not octos's full default set: no command execution, no file writes -//! outside its workspace, no delegation to unconfined sub-agents, no kernel -//! administration. This module is the ONE definition of that list, and the -//! kernel enforces it. +//! Two lists live here, and only here: //! -//! **How it is enforced.** octos (the pinned rev) has no per-session or -//! per-turn tool roster for an ordinary session; the only roster control a -//! host has is the profile's `tool_policy` (allow/deny, deny wins), which -//! octos re-applies to every turn's FINISHED registry, after the per-turn -//! `peer_*`, `spawn` and `send_file` tools are registered, and to kernel -//! continuation turns alike. So before every kernel start ([`enforce`], from -//! `launch::prepare`) the host writes [`tool_policy`] into -//! `/profiles/_main.json`, replacing whatever policy is there. A -//! `tool_policy` change needs a kernel restart in octos, and every start -//! re-writes it, so a stale or edited profile never runs without it. +//! - [`SYSTEM_AGENT_TOOLS`]: the octos tools the system agent +//! (`_main:api:octosense#system`) is meant to be offered. With what it is +//! granted ([`SystemAgentTools`]: toolbox tools, other apps' shareable +//! tools, and command execution when the person turns it on in Settings) +//! that is its whole set. +//! - [`profile_ceiling`]: every octos tool ANY `_main` session may be +//! offered: the system agent's list plus every octos generic tool an app +//! may declare and be granted ([`APP_GRANTABLE_OCTOS_TOOLS`]). Left out are +//! only the tools no grant gives: octos's own process tools (`shell`, +//! `bash`, `exec_command`, `write_stdin`, `check`, `git`), sub-agents and +//! ad-hoc peers, schedulers, goals and kernel administration +//! ([`NEVER_OFFERED`]). Command execution, when granted, is a HOST tool +//! (for example `terminal.run`) with a live approval, never octos's +//! `shell`. //! -//! **What else it bounds.** The policy is the profile's, so it is the -//! ceiling for every session of `_main`, not only the system agent: app -//! peers (which ADR 0004 §12 also denies command execution; octos#2567's -//! host tools are added after the policy, so it never strips them), AppCard -//! and Rinx sessions, and peers the system agent reaches. Talk to Octos -//! external turns are unaffected: [`SYSTEM_AGENT_TOOLS`] contains every tool -//! of octos's external allowlist ([`EXTERNAL_TURN_TOOLS`]), and octos -//! confines those turns further on its own (UPCR-2026-036). +//! **What is enforced today.** octos (the pinned rev) has no per-session tool +//! list for an ordinary session; the only roster control a host has is the +//! profile's `tool_policy` (allow/deny, deny wins), which octos re-applies to +//! every turn's finished registry (after the per-turn `peer_*`, `spawn` and +//! `send_file` tools are registered) and to kernel wake continuations alike. +//! So before every kernel start ([`enforce`], from `launch::prepare`) the +//! host writes the CEILING as the `_main` profile's policy, replacing any +//! other; a policy change needs a kernel restart in octos, and every start +//! writes it again. Consequences: //! -//! **Granted tools.** Toolbox tools (OctoSense#108) and cross-app tools -//! (ADR 0004 §7) the system agent is granted are host-routed tools. They -//! join the set through [`SystemAgentTools::grant_toolbox`] and -//! [`SystemAgentTools::grant_cross_app`]; nothing grants any yet, and octos -//! has no way yet to register host-routed tools on the system session -//! (octos#2567 registers them per app peer only). +//! - **The system agent is bounded by the ceiling, not by its exact list**, +//! until octos lets the host set a session's tool list (the per-session +//! host tool list, octos#2567 item 5). No `shell` or other process tool, +//! no sub-agents, no administration reach it; generic tools an app may be +//! granted (for example `deep_search`, `browser`, `run_pipeline`) do, when +//! octos registers them. The real-kernel test that asserts the exact list +//! is ignored until then. +//! - **App peers are not capped below what they can be granted**: the +//! ceiling holds every grantable octos tool, and each peer is narrowed to +//! its grants by its turns' `generic_tools` (octos#2567, plan step 6). +//! Host-routed tools (app, toolbox, cross-app tools, command execution) +//! are registered after the policy, so it never strips them. +//! - **Talk to Octos external turns are unaffected**: the ceiling contains +//! octos's external allowlist ([`EXTERNAL_TURN_TOOLS`]), and octos confines +//! those turns to it on its own (UPCR-2026-036). use std::collections::BTreeSet; use std::path::Path; use serde_json::{json, Value}; -/// The kernel tools the system agent is offered, and nothing else. +/// The octos tools the system agent is meant to be offered (its exact list +/// once octos#2567 item 5 exists; until then it is bounded by +/// [`profile_ceiling`]). /// /// - **Supervision** of app peers (ADR 0004 §6): `peer_send_input` briefs /// and asks, `peer_gather` / `peer_list` read the blackboard, @@ -54,12 +67,9 @@ use serde_json::{json, Value}; /// Octos clients also keep, until the toolbox (#108) grants the system /// agent `toolbox.search` / `toolbox.web_read`. /// -/// Absent on purpose: `shell`, `exec_command`, `write_stdin`, `bash` and -/// every other code or command execution (`group:runtime`); `spawn`, -/// `delegate` and the sub-agent tools (`group:sessions`); skill, tool and -/// model administration (`group:admin`); `browser`, `deep_search`, -/// `deep_crawl`, pipelines; plugin and MCP tools (they are not named here, -/// so the allowlist drops them whatever they are called). +/// Not octos's `shell` or any process tool: command execution reaches the +/// system agent only as a host tool the person grants in Settings +/// ([`SystemAgentTools::grant_command_execution`]). pub const SYSTEM_AGENT_TOOLS: &[&str] = &[ // Supervision. "peer_send_input", @@ -96,9 +106,73 @@ pub const SYSTEM_AGENT_TOOLS: &[&str] = &[ "tool_search", ]; -/// Denied whatever the allowlist says (octos: deny always wins): command -/// execution, sub-agents and administration, as octos's own groups. -pub const SYSTEM_AGENT_DENIED: &[&str] = &["group:runtime", "group:sessions", "group:admin"]; +/// octos generic tools an app may declare and the person grant (ADR 0004 +/// §12: OctoSense hard-codes no exclusions among them), beyond +/// [`SYSTEM_AGENT_TOOLS`]. Each app peer gets only its grants, through its +/// turns' `generic_tools` (plan step 6); a name octos does not register is +/// simply never offered. +pub const APP_GRANTABLE_OCTOS_TOOLS: &[&str] = &[ + // Research and the web. + "deep_search", + "deep_research", + "synthesize_research", + "deep_crawl", + "site_crawl", + "browser", + // Content generation and pipelines. + "image_generation", + "mofa_make", + "mofa_describe_content_type", + "run_pipeline", + "check_background_tasks", + "read_task_output", + // Messaging the person. + "send_file", + "message", + "request_user_input", + // Workspace history and planning. + "workspace_diff", + "workspace_log", + "workspace_show", + "update_plan", + "tool_suggest", + // Memory bookkeeping. + "record_memory_use", +]; + +/// octos tools no grant gives, denied outright (octos: deny wins over +/// allow). They are also absent from the ceiling's allowlist, like every +/// plugin and MCP tool. +/// +/// - octos's own process tools: `group:runtime` (`shell`, `exec_command`, +/// `write_stdin`, `bash`), `check` (runs build and test commands), `git` +/// (runs git, whose hooks run code). Granted command execution is a host +/// tool with a live approval instead. +/// - Sub-agents and ad-hoc peers: `group:sessions`, `peer_handoff`. +/// - Schedulers and goals, which start work outside any grant: `cron`, +/// `monitor_*`, `goal_*`. +/// - Kernel administration: `group:admin`. +pub const NEVER_OFFERED: &[&str] = &[ + "group:runtime", + "check", + "git", + "group:sessions", + "peer_handoff", + "cron", + "monitor_*", + "goal_*", + "group:admin", +]; + +/// The host tool granted command execution arrives as (the Terminal app's +/// shareable tool, ADR 0004 §10 and §12): each command approved live. +pub const COMMAND_EXECUTION_TOOL: &str = "terminal.run"; + +/// Every octos tool a `_main` session may be offered: +/// [`SYSTEM_AGENT_TOOLS`] ∪ [`APP_GRANTABLE_OCTOS_TOOLS`]. +pub fn profile_ceiling() -> BTreeSet<&'static str> { + SYSTEM_AGENT_TOOLS.iter().chain(APP_GRANTABLE_OCTOS_TOOLS).copied().collect() +} /// octos's allowlist for a Talk to Octos external turn (octos /// `crates/octos-cli/src/api/host_managed.rs`, `EXTERNAL_TURN_TOOLS`, at the @@ -127,15 +201,19 @@ pub const EXTERNAL_TURN_TOOLS: &[&str] = &[ ]; /// The system agent's tool set: [`SYSTEM_AGENT_TOOLS`] plus what it is -/// granted. +/// granted. Granted tools are host-routed; registering them on the system +/// session needs octos's per-session host tool list (octos#2567 items 5 +/// and 6), so today nothing grants any and [`SystemAgentTools::host_tools`] +/// is what the shell will register then. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct SystemAgentTools { toolbox: BTreeSet, cross_app: BTreeSet, + command_execution: bool, } impl SystemAgentTools { - /// The set as shipped: no grants. + /// The set as shipped: no grants, command execution off. pub fn new() -> Self { Self::default() } @@ -152,31 +230,50 @@ impl SystemAgentTools { self } - /// Every tool name a system-agent turn may be offered. - pub fn names(&self) -> BTreeSet { - SYSTEM_AGENT_TOOLS - .iter() - .map(|t| t.to_string()) - .chain(self.toolbox.iter().cloned()) - .chain(self.cross_app.iter().cloned()) - .collect() + /// The person's Settings switch for the system agent's command + /// execution (ADR 0004 §12; off by default). On, the system agent gets + /// the host tool [`COMMAND_EXECUTION_TOOL`], each command approved live + /// (section 8); never octos's `shell`, which stays denied. + /// + /// TODO(ADR 0004 plan steps 4/6): persist the switch in Settings → + /// Assistant (no Settings plumbing for it exists yet) and register the + /// host tool on the system session once octos can (octos#2567 item 6). + pub fn grant_command_execution(&mut self, on: bool) -> &mut Self { + self.command_execution = on; + self + } + + /// Whether the person granted command execution. + pub fn command_execution(&self) -> bool { + self.command_execution + } + + /// The host-routed tools the system agent is granted. + pub fn host_tools(&self) -> BTreeSet { + let mut tools: BTreeSet = self.toolbox.union(&self.cross_app).cloned().collect(); + if self.command_execution { + tools.insert(COMMAND_EXECUTION_TOOL.to_owned()); + } + tools } - /// The octos `ToolPolicy` that enforces the set. - pub fn tool_policy(&self) -> Value { - json!({ - "allow": self.names().into_iter().collect::>(), - "deny": SYSTEM_AGENT_DENIED, - }) + /// Every tool name a system-agent turn is meant to be offered: its octos + /// tools and its host tools. + pub fn names(&self) -> BTreeSet { + SYSTEM_AGENT_TOOLS.iter().map(|t| t.to_string()).chain(self.host_tools()).collect() } } -/// The policy the kernel runs with (no grants yet). +/// The octos `ToolPolicy` the kernel runs `_main` with: allow +/// [`profile_ceiling`], deny [`NEVER_OFFERED`]. pub fn tool_policy() -> Value { - SystemAgentTools::new().tool_policy() + json!({ + "allow": profile_ceiling().into_iter().collect::>(), + "deny": NEVER_OFFERED, + }) } -/// Write the system agent's policy into `/profiles/_main.json` +/// Write [`tool_policy`] into `/profiles/_main.json` /// (`config.tool_policy`), keeping every other key. No profile yet means no /// provider and no turns: nothing to do. An unreadable profile is left for /// the kernel to report (it cannot run turns from it either). @@ -209,7 +306,7 @@ pub fn enforce(core_dir: &Path) { crate::network::write_private(dir, name, &body).map_err(|e| e.to_string()) }); match result { - Ok(()) => log::info!("octos-core: wrote the system agent's tool policy to {}", path.display()), + Ok(()) => log::info!("octos-core: wrote the tool ceiling to {}", path.display()), Err(e) => log::warn!("octos-core: could not write the tool policy to {}: {e}", path.display()), } } @@ -225,37 +322,63 @@ mod tests { dir } + const PROCESS_AND_ADMIN: &[&str] = &[ + "shell", "exec_command", "write_stdin", "bash", "check", "git", "spawn", "spawn_agent", + "send_input", "resume_agent", "wait_agent", "close_agent", "delegate", "peer_handoff", "cron", + "monitor_create", "goal_grant", "manage_skills", "configure_tool", "model_check", + ]; + + fn policy_lists() -> (Vec, Vec) { + let policy = tool_policy(); + let list = |k: &str| policy[k].as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_owned()).collect(); + (list("allow"), list("deny")) + } + #[test] - fn no_command_execution_and_no_unconfined_agents() { - for denied in [ - "shell", "exec_command", "write_stdin", "bash", "spawn", "spawn_agent", "delegate", - "peer_handoff", "browser", "deep_search", "deep_crawl", "run_pipeline", "manage_skills", - "configure_tool", "model_check", - ] { - assert!(!SYSTEM_AGENT_TOOLS.contains(&denied), "{denied}"); + fn no_octos_process_tool_sub_agent_or_admin_tool_is_ever_offered() { + let (allow, deny) = policy_lists(); + for tool in PROCESS_AND_ADMIN { + assert!(!allow.iter().any(|a| a == tool), "{tool} is in the ceiling"); + assert!(!SYSTEM_AGENT_TOOLS.contains(tool), "{tool} is in the system agent's list"); } - let policy = tool_policy(); - let deny: Vec<&str> = policy["deny"].as_array().unwrap().iter().filter_map(Value::as_str).collect(); - assert!(deny.contains(&"group:runtime"), "command execution is denied outright"); + for group in ["group:runtime", "group:sessions", "group:admin"] { + assert!(deny.iter().any(|d| d == group), "{group} is denied outright"); + } + assert!(!allow.iter().any(|a| a.contains('*') || a.starts_with("group:")), "the ceiling names tools"); } #[test] - fn talk_to_octos_clients_keep_their_tools() { - for tool in EXTERNAL_TURN_TOOLS { - assert!(SYSTEM_AGENT_TOOLS.contains(tool), "{tool} would be taken from external clients"); + fn the_ceiling_holds_the_system_agent_every_grantable_tool_and_the_external_allowlist() { + let ceiling = profile_ceiling(); + for tool in SYSTEM_AGENT_TOOLS.iter().chain(APP_GRANTABLE_OCTOS_TOOLS).chain(EXTERNAL_TURN_TOOLS) { + assert!(ceiling.contains(tool), "{tool}"); + } + // octos#2567's peer-safe generic tools, all grantable to an app. + for tool in [ + "read_file", "list_dir", "glob", "grep", "web_search", "deep_search", "memory_search", + "memory_load", "recall_memory", "save_memory", "record_memory_use", "mofa_make", + "mofa_describe_content_type", + ] { + assert!(ceiling.contains(tool), "{tool} could not be granted to an app peer"); } + assert_eq!(ceiling.len(), SYSTEM_AGENT_TOOLS.len() + APP_GRANTABLE_OCTOS_TOOLS.len(), "no overlap"); } #[test] - fn the_list_has_no_duplicates_and_grants_join_it() { - let names = SystemAgentTools::new().names(); - assert_eq!(names.len(), SYSTEM_AGENT_TOOLS.len()); + fn grants_join_the_system_agents_set_as_host_tools() { + let shipped = SystemAgentTools::new(); + assert!(!shipped.command_execution(), "command execution is off by default"); + assert!(shipped.host_tools().is_empty()); + assert_eq!(shipped.names().len(), SYSTEM_AGENT_TOOLS.len()); let mut granted = SystemAgentTools::new(); - granted.grant_toolbox("toolbox.search").grant_cross_app("mail.send"); - let policy = granted.tool_policy(); - let allow: Vec<&str> = policy["allow"].as_array().unwrap().iter().filter_map(Value::as_str).collect(); - assert!(allow.contains(&"toolbox.search") && allow.contains(&"mail.send")); - assert_eq!(allow.len(), SYSTEM_AGENT_TOOLS.len() + 2); + granted + .grant_toolbox("toolbox.search") + .grant_cross_app("mail.send") + .grant_command_execution(true); + let host: Vec = granted.host_tools().into_iter().collect(); + assert_eq!(host, ["mail.send", COMMAND_EXECUTION_TOOL, "toolbox.search"]); + assert!(!granted.names().contains("shell"), "never octos's shell"); + assert!(!tool_policy()["allow"].as_array().unwrap().iter().any(|t| t == COMMAND_EXECUTION_TOOL)); } #[test] diff --git a/crates/kernel/tests/real_kernel.rs b/crates/kernel/tests/real_kernel.rs index 04f78c45..0297de45 100644 --- a/crates/kernel/tests/real_kernel.rs +++ b/crates/kernel/tests/real_kernel.rs @@ -463,18 +463,14 @@ async fn native_and_browser_talk_to_the_same_system_agent() { let _ = std::fs::remove_dir_all(dir); } -/// ADR 0004 §12: a system-agent turn is offered exactly the system agent's -/// tool set (no `shell`), whoever starts it, and a Talk to Octos external -/// client's turn keeps octos's external allowlist. The scripted model logs -/// the tool names each request offered. The profile is written WITHOUT a -/// tool policy, and with a widening one: the kernel start writes its own. -#[tokio::test(flavor = "multi_thread")] -async fn a_system_agent_turn_is_offered_exactly_the_system_agent_tools() { - use octosense_kernel::system_tools::{EXTERNAL_TURN_TOOLS, SYSTEM_AGENT_TOOLS}; +/// What the system agent's turns were offered: `(stdio, host over Talk to +/// Octos, external client)`. The scripted model logs the tool names each +/// request offered. The profile is written with a widening tool policy: the +/// kernel start writes its own. +async fn offered_to_system_turns(program: &Path, tag: &str) -> [std::collections::BTreeSet; 3] { use std::collections::BTreeSet; use std::io::BufRead; - let Some(program) = kernel() else { return }; - let dir = std::env::temp_dir().join(format!("octos-system-tools-{}", std::process::id())); + let dir = std::env::temp_dir().join(format!("octos-system-tools-{tag}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(dir.join("profiles")).unwrap(); let log = dir.join("offered.jsonl"); @@ -511,48 +507,85 @@ async fn a_system_agent_turn_is_offered_exactly_the_system_agent_tools() { }).await.unwrap_or_else(|_| panic!("the model never saw the {probe} turn")) } }; - // `recall` (a session's evicted tool outputs) is registered only on - // octos's session-actor turns, not on UI Protocol turns: allowed, never - // offered here. - let expected: BTreeSet = SYSTEM_AGENT_TOOLS.iter().filter(|t| **t != "recall") - .map(|t| t.to_string()).collect(); let input = |text: &str| json!({"session_id":SYSTEM_SESSION,"turn_id":uuid::Uuid::new_v4(), "input":[{"kind":"text","text":text}]}); + let open = json!({"session_id":SYSTEM_SESSION,"profile_id":"_main"}); // The private stdio kernel (Talk to Octos off): the host's own turn. - let core = Core::new(Options::default().program(&program).core_dir(&dir)); + let core = Core::new(Options::default().program(program).core_dir(&dir)); let mut native = core.connect().unwrap(); - call(&mut native, "open", "session/open", json!({"session_id":SYSTEM_SESSION,"profile_id":"_main"})).await; + call(&mut native, "open", "session/open", open.clone()).await; call(&mut native, "turn", "turn/start", input("SYSTEM_TOOLS_STDIO")).await; let stdio = wait_for("SYSTEM_TOOLS_STDIO").await; - assert!(!stdio.contains("shell"), "{stdio:?}"); - assert_eq!(stdio, expected, "a system-agent turn is offered exactly the system agent's tools"); drop(native); core.shutdown_within(Duration::from_secs(15)); // Talk to Octos on: the host's turn over the WebSocket, then an external // client's turn on the same system conversation. - let core = Core::new(Options::default().program(&program).core_dir(&dir)); + let core = Core::new(Options::default().program(program).core_dir(&dir)); let c = core.clone(); blocking(move || c.set_external_access(true)).await.unwrap(); let mut native = core.connect().unwrap(); let access = native.client_access().await.unwrap(); - let open = json!({"session_id":SYSTEM_SESSION,"profile_id":"_main"}); call(&mut native, "open", "session/open", open.clone()).await; call(&mut native, "turn", "turn/start", input("SYSTEM_TOOLS_HOST")).await; - assert_eq!(wait_for("SYSTEM_TOOLS_HOST").await, expected, "the same set over Talk to Octos"); + let host = wait_for("SYSTEM_TOOLS_HOST").await; let mut browser = external(&access).await; ws_call(&mut browser, "open", "session/open", open).await; ws_call(&mut browser, "turn", "turn/start", input("SYSTEM_TOOLS_EXTERNAL")).await; let external_tools = wait_for("SYSTEM_TOOLS_EXTERNAL").await; - let allowlist: BTreeSet = EXTERNAL_TURN_TOOLS.iter().map(|t| t.to_string()).collect(); - assert!(external_tools.is_subset(&allowlist), "{external_tools:?}"); - assert_eq!(external_tools, &expected & &allowlist, "external clients keep every allowlisted tool the kernel offers"); drop((native, browser)); let c = core.clone(); blocking(move || c.set_external_access(false)).await.unwrap(); core.shutdown_within(Duration::from_secs(15)); let _ = std::fs::remove_dir_all(dir); + [stdio, host, external_tools] +} + +/// ADR 0004 §12, what is enforced today: a system-agent turn, whoever starts +/// it, is offered no octos process tool (`shell` and the rest), sub-agent or +/// administration tool, nothing outside the profile ceiling, and every tool +/// of its own list octos registers; a Talk to Octos external client's turn +/// keeps octos's external allowlist. +#[tokio::test(flavor = "multi_thread")] +async fn a_system_agent_turn_is_offered_nothing_outside_the_ceiling_and_no_shell() { + use octosense_kernel::system_tools::{profile_ceiling, EXTERNAL_TURN_TOOLS, SYSTEM_AGENT_TOOLS}; + use std::collections::BTreeSet; + let Some(program) = kernel() else { return }; + let [stdio, host, external_tools] = offered_to_system_turns(&program, "ceiling").await; + let ceiling: BTreeSet = profile_ceiling().into_iter().map(str::to_owned).collect(); + // `recall` (a session's evicted tool outputs) is registered only on + // octos's session-actor turns, not on UI Protocol turns. + let own: BTreeSet = SYSTEM_AGENT_TOOLS.iter().filter(|t| **t != "recall") + .map(|t| t.to_string()).collect(); + for (how, offered) in [("stdio", &stdio), ("Talk to Octos", &host)] { + for never in ["shell", "bash", "exec_command", "write_stdin", "check", "git", "spawn", "delegate", + "peer_handoff", "cron", "configure_tool", "goal_grant"] { + assert!(!offered.contains(never), "{how}: {never} offered: {offered:?}"); + } + assert!(offered.is_subset(&ceiling), "{how}: outside the ceiling: {:?}", offered - &ceiling); + assert!(own.is_subset(offered), "{how}: missing its own tools: {:?}", &own - offered); + } + assert_eq!(stdio, host, "the same set over Talk to Octos"); + let allowlist: BTreeSet = EXTERNAL_TURN_TOOLS.iter().map(|t| t.to_string()).collect(); + assert!(external_tools.is_subset(&allowlist), "{external_tools:?}"); + assert_eq!(external_tools, &stdio & &allowlist, "external clients keep every allowlisted tool the kernel offers"); +} + +/// ADR 0004 §12, the target: a system-agent turn is offered EXACTLY the +/// system agent's list. Needs a per-session tool list the host can set +/// (octos#2567 item 5); until then the ceiling bounds it (above). +#[tokio::test(flavor = "multi_thread")] +#[ignore = "needs octos's per-session host tool list (octos#2567 item 5); today the system agent is bounded by the profile ceiling"] +async fn a_system_agent_turn_is_offered_exactly_the_system_agent_tools() { + use octosense_kernel::system_tools::SystemAgentTools; + use std::collections::BTreeSet; + let Some(program) = kernel() else { return }; + let [stdio, host, _] = offered_to_system_turns(&program, "exact").await; + let expected: BTreeSet = SystemAgentTools::new().names().into_iter() + .filter(|t| t != "recall").collect(); + assert_eq!(stdio, expected); + assert_eq!(host, expected); } /// The kernel must not outlive a host that dies without stopping it From 34923ee3f47bfa71e1b42fb8eac76cbfce2ce413 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:53:14 -0700 Subject: [PATCH 3/4] fix(kernel): OctoSense's own octos home on the desktop, settings copied once The desktop kernel's core dir was ~/octos-home/.octos, the person's own octos home shared with their standalone octos, so writing OctoSense's tool policy there would change their octos. The core dir is now `/octos-home/.octos` on every platform; the desktop shell passes OctoSense's state dir (~/.octosense). On first use a desktop kernel copies only the provider and model settings (config.llm, config.env_vars) from the shared profile into its own, once, and never writes, moves or deletes anything under ~/octos-home. Co-Authored-By: Claude Opus 5.5 --- crates/ai-host/src/lib.rs | 15 ++- crates/ai-host/src/tests.rs | 14 +-- crates/kernel/README.md | 63 ++++++------ crates/kernel/README.zh-CN.md | 39 ++++---- crates/kernel/src/dirs.rs | 176 ++++++++++++++++++++++++++++------ crates/kernel/src/lib.rs | 22 ++++- crates/shell/src/lib.rs | 6 +- docs/ai-services.md | 2 +- docs/ai-services.zh-CN.md | 2 +- 9 files changed, 240 insertions(+), 99 deletions(-) diff --git a/crates/ai-host/src/lib.rs b/crates/ai-host/src/lib.rs index 716129d7..fb933dde 100644 --- a/crates/ai-host/src/lib.rs +++ b/crates/ai-host/src/lib.rs @@ -167,8 +167,9 @@ impl Policy { /// What a shell tells [`start`]. #[derive(Clone, Debug)] pub struct Host { - /// The app's data dir (`cx.get_data_dir()`): a phone's kernel home lives - /// under it. + /// The app's data dir (`cx.get_data_dir()` on a phone, OctoSense's state + /// dir on a desktop): the kernel's octos home lives under it, never the + /// person's own `~/octos-home`. pub data_dir: Option, pub kernel: KernelSource, pub qr_import: QrImport, @@ -262,8 +263,8 @@ fn configure_kernel(_host: &Host) -> (bool, Result<(), String>) { } /// The kernel's octos home, where the `llm` service writes: the kernel's core -/// dir (`$OCTOS_APP_CORE_DIR`, else on a phone `/octos-home/.octos`, -/// else `$HOME/octos-home/.octos`). +/// dir (`$OCTOS_APP_CORE_DIR`, else `/octos-home/.octos`, OctoSense's +/// own, else `$HOME/octos-home/.octos`). pub fn core_dir(data_dir: Option) -> Option { #[cfg(kernel)] { @@ -277,10 +278,8 @@ pub fn core_dir(data_dir: Option) -> Option { if let Some(dir) = std::env::var_os("OCTOS_APP_CORE_DIR").filter(|v| !v.is_empty()) { return Some(PathBuf::from(dir)); } - if cfg!(any(target_os = "android", target_env = "ohos")) { - if let Some(dir) = data_dir.filter(|d| !d.is_empty()) { - return Some(PathBuf::from(dir).join("octos-home").join(".octos")); - } + if let Some(dir) = data_dir.filter(|d| !d.is_empty()) { + return Some(PathBuf::from(dir).join("octos-home").join(".octos")); } octosense_llm_config::profile::default_core_dir() } diff --git a/crates/ai-host/src/tests.rs b/crates/ai-host/src/tests.rs index 14dcc1dd..cace49e3 100644 --- a/crates/ai-host/src/tests.rs +++ b/crates/ai-host/src/tests.rs @@ -116,16 +116,18 @@ fn the_shipped_policy_grants_rinx_the_assistant() { } #[test] -fn a_phone_core_dir_is_the_kernels_octos_home() { +fn the_core_dir_is_octosenses_own_octos_home_under_its_data_dir() { if std::env::var_os("OCTOS_APP_CORE_DIR").is_some() { return; } let dir = core_dir(Some("/data/user/0/app/files".into())); - if cfg!(any(target_os = "android", target_env = "ohos")) { - assert_eq!(dir, Some(PathBuf::from("/data/user/0/app/files/octos-home/.octos"))); - } else { - assert_eq!(dir, octosense_llm_config::profile::default_core_dir()); - } + // With the kernel service, the kernel's (configured) core dir. + #[cfg(kernel)] + assert_eq!(dir, octosense_kernel::core_dir()); + // Without it, every platform: `/octos-home/.octos`, never + // `~/octos-home`. + #[cfg(not(kernel))] + assert_eq!(dir, Some(PathBuf::from("/data/user/0/app/files/octos-home/.octos"))); } #[test] diff --git a/crates/kernel/README.md b/crates/kernel/README.md index 4e65ae3e..a0398614 100644 --- a/crates/kernel/README.md +++ b/crates/kernel/README.md @@ -17,7 +17,7 @@ the shared runtime piece the shells, AppCard (`apps/appcard/app`) and the | | | |---|---| -| **Core dir** | octos's data dir: `/profiles/_main.json` is the profile the AI providers app writes. Resolved as: the shell's `Options::core_dir`, else `$OCTOS_APP_CORE_DIR`, else on Android/OpenHarmony `/octos-home/.octos` (the app-private octos home AppCard has always used), else `$HOME/octos-home/.octos` (`octosense_llm_config::profile::default_core_dir()`). | +| **Core dir** | octos's data dir: `/profiles/_main.json` is the profile the AI providers app writes. Resolved as: the shell's `Options::core_dir`, else `$OCTOS_APP_CORE_DIR`, else `/octos-home/.octos`: OctoSense's own octos home (the platform's app data dir on a phone, the app-private home AppCard has always used; OctoSense's state dir `~/.octosense` on a desktop), else `$HOME/octos-home/.octos` (`octosense_llm_config::profile::default_core_dir()`, for a consumer that names nothing). A desktop OctoSense used to share `$HOME/octos-home/.octos` with the person's standalone octos; on first use it now copies only the provider and model settings (`llm`, `env_vars`) from there into its own profile, and never writes, moves or deletes anything there. | | **One kernel, lazily** | The first `connect()` starts it; later ones share it. octos holds a single-writer lock on its data dir, so a second kernel on the same dir could not run anyway. | | **Shared by frames** | A `Connection` carries UI Protocol (JSON-RPC) frames exactly as `octos serve --stdio` speaks them. Each consumer uses its own request ids and receives the replies to its requests and the notifications of the sessions it named (a notification for a session nobody named goes to every consumer). | | **Restart** | `restart()` stops a running kernel (a no-op when none runs). Connections then end with `CloseReason::Restarted`; a consumer connects again, which starts a fresh kernel that reads the new profile. The next kernel starts only after the old one has exited and released its data dir. | @@ -157,36 +157,37 @@ for the threat model. ## The system agent's tools -The system agent gets a defined tool set, not octos's full default one -([ADR 0004](../../docs/adr/0004-native-apps-hosting-and-peers.md) §12). Its -octos tools are `system_tools::SYSTEM_AGENT_TOOLS`: supervision -(`peer_send_input`, `peer_gather`, `peer_list`, `peer_respond`, `peer_close`), -its workspace's file tools (octos fences them to the session's working -directory), memory, `ask_user_question`, media viewing, octos's `web_search` / -`web_fetch` (until toolbox grants replace them, #108) and `tool_search`. -Granted toolbox and cross-app tools, and command execution when the person -turns it on in Settings (off by default), join it as host tools through -`SystemAgentTools`; command execution is a host tool with a live approval -(`terminal.run`), never octos's `shell`. - -**What the kernel enforces today.** octos has no per-session tool list, so -every start writes a CEILING into `/profiles/_main.json` as the -profile's `tool_policy`, replacing any other: the system agent's tools plus -every octos generic tool an app may be granted -(`system_tools::APP_GRANTABLE_OCTOS_TOOLS`), with octos's process tools -(`shell` and the rest of `group:runtime`, `check`, `git`), sub-agents, -`peer_handoff`, schedulers, goals and administration denied -(`system_tools::NEVER_OFFERED`). octos applies it to every turn of the -profile, wake continuations included. So: - -- the system agent never gets `shell` or any tool outside the ceiling, but is - bounded by the ceiling rather than its exact list until octos lets the host - set a session's tool list (octos#2567 item 5); the exact-list real-kernel - test is ignored until then; -- app peers are not capped below what they can be granted; each is narrowed - to its grants by its turns' `generic_tools` (plan step 6); -- Talk to Octos external turns keep octos's own allowlist, which the ceiling - contains. +[ADR 0004](../../docs/adr/0004-native-apps-hosting-and-peers.md) §12: the +system agent's tool set is its grants. Its default octos tools are +`system_tools::SYSTEM_AGENT_TOOLS`: supervision (`peer_send_input`, +`peer_gather`, `peer_list`, `peer_respond`, `peer_close`), its workspace's +file tools (octos fences them to the session's working directory), memory, +`ask_user_question`, media viewing, octos's `web_search` / `web_fetch` (until +toolbox grants replace them, #108) and `tool_search`. Granted toolbox and +cross-app tools, and command execution when the person turns it on in +Settings (off by default), join it as host tools through `SystemAgentTools`; +command execution is a host tool with a live approval (`terminal.run`). + +**What the kernel enforces today.** octos has no tool list the host can set +for one session, so every start writes the `_main` profile's `tool_policy` +(`system_tools::tool_policy`): everything a grant can give, minus octos's own +shell (`group:runtime`: `shell`, `bash`, `exec_command`, `write_stdin`), the +one tool OctoSense never offers, since §12 grants command execution only as +a host tool. octos applies it to every turn of the profile, wake +continuations included. So: + +- **§12's "exactly its grants" is not yet enforced for the system agent**: + it gets no octos shell, but otherwise is bounded by the grantable ceiling, + not its list, until octos#2567 adds session-targeted registration and tool + lists (reviewer item M1; our item 5). The exact-list real-kernel test is + ignored until then; +- app peers are narrowed to their grants by their turns' `generic_tools` + (plan step 6); +- Talk to Octos external turns keep octos's own allowlist. + +The policy is written only into OctoSense's own core dir, and only over a +policy OctoSense wrote (`"owner": "octosense"`): a foreign policy, or the +person's own `$HOME/octos-home/.octos`, is refused with a warning. ## Testing diff --git a/crates/kernel/README.zh-CN.md b/crates/kernel/README.zh-CN.md index 1a1db4ca..4433daef 100644 --- a/crates/kernel/README.zh-CN.md +++ b/crates/kernel/README.zh-CN.md @@ -15,7 +15,7 @@ Shell(`phone/` 中的 Home、`desktop/` 中的桌面)拥有它;**AI provid | | | |---|---| -| **Core 目录** | octos 的数据目录:`/profiles/_main.json` 是 AI providers 应用写入的 profile。解析顺序:Shell 的 `Options::core_dir`,否则 `$OCTOS_APP_CORE_DIR`,否则在 Android/OpenHarmony 上为 `<应用数据目录>/octos-home/.octos`(AppCard 一直使用的应用私有 octos home),否则 `$HOME/octos-home/.octos`(`octosense_llm_config::profile::default_core_dir()`)。 | +| **Core 目录** | octos 的数据目录:`/profiles/_main.json` 是 AI providers 应用写入的 profile。解析顺序:Shell 的 `Options::core_dir`,否则 `$OCTOS_APP_CORE_DIR`,否则 `<应用数据目录>/octos-home/.octos`:OctoSense 自己的 octos home(手机上为平台的应用数据目录,即 AppCard 一直使用的应用私有 home;桌面上为 OctoSense 的状态目录 `~/.octosense`),否则 `$HOME/octos-home/.octos`(`octosense_llm_config::profile::default_core_dir()`,供未指定任何目录的使用者)。桌面版 OctoSense 以前与用户独立的 octos 共用 `$HOME/octos-home/.octos`;现在首次使用时只从那里复制提供商和模型设置(`llm`、`env_vars`)到自己的 profile,绝不在那里写入、移动或删除任何内容。 | | **一个内核,按需启动** | 第一次 `connect()` 启动它,之后的连接共享它。octos 对数据目录持有单写者锁,同一目录上本来也无法运行第二个内核。 | | **按帧共享** | 一个 `Connection` 传递 UI Protocol(JSON-RPC)帧,与 `octos serve --stdio` 的格式完全相同。每个使用方使用自己的请求 id,只收到自己请求的回复和自己指定会话的通知(没有任何使用方指定的会话通知会发给所有使用方)。 | | **重启** | `restart()` 停止正在运行的内核(没有运行时什么也不做)。连接随后以 `CloseReason::Restarted` 结束;使用方重新连接,会启动读取新 profile 的新内核。新内核要等旧内核退出并释放数据目录后才启动。 | @@ -131,25 +131,24 @@ adb -s SERIAL forward tcp:PORT tcp:PORT ## 系统智能体的工具 -系统智能体只拿到一组明确定义的工具,而不是 octos 的全部默认工具 -([ADR 0004](../../docs/adr/0004-native-apps-hosting-and-peers.md) §12)。它的 octos -工具是 `system_tools::SYSTEM_AGENT_TOOLS`:监督(`peer_send_input`、`peer_gather`、 -`peer_list`、`peer_respond`、`peer_close`)、其工作区内的文件工具(octos 将其限制在会话工作目录内)、 -记忆、`ask_user_question`、查看媒体、octos 的 `web_search` / `web_fetch`(在工具箱授权取代它们之前,#108) -以及 `tool_search`。授予的工具箱工具和跨应用工具,以及用户在设置中开启(默认关闭)的命令执行, -通过 `SystemAgentTools` 作为宿主工具加入;命令执行是需要实时批准的宿主工具(`terminal.run`), -绝不是 octos 的 `shell`。 - -**内核今天执行的内容。** octos 没有按会话的工具名单,因此每次启动都把一个“上限”作为 profile 的 -`tool_policy` 写入 `/profiles/_main.json`,替换任何其他策略:系统智能体的工具,加上应用可被授予的每个 -octos 通用工具(`system_tools::APP_GRANTABLE_OCTOS_TOOLS`),并拒绝 octos 的进程工具(`shell` 及 -`group:runtime` 其余工具、`check`、`git`)、子智能体、`peer_handoff`、调度器、目标和管理工具 -(`system_tools::NEVER_OFFERED`)。octos 对该 profile 的每个回合(包括唤醒续接回合)都应用它。因此: - -- 系统智能体永远拿不到 `shell` 或上限之外的工具,但在 octos 允许宿主设置会话工具名单(octos#2567 第 5 项)之前, - 约束它的是上限而不是它的精确清单;精确清单的真实内核测试在此之前被忽略; -- 应用 peer 不会被限制在其可获授权之下;每个 peer 由其回合的 `generic_tools` 收窄到其授权(计划第 6 步); -- Talk to Octos 外部回合保留 octos 自己的允许列表,上限包含它。 +[ADR 0004](../../docs/adr/0004-native-apps-hosting-and-peers.md) §12:系统智能体的工具集就是它获得的授权。它默认的 octos +工具是 `system_tools::SYSTEM_AGENT_TOOLS`:监督(`peer_send_input`、`peer_gather`、`peer_list`、`peer_respond`、 +`peer_close`)、其工作区内的文件工具(octos 将其限制在会话工作目录内)、记忆、`ask_user_question`、查看媒体、octos 的 +`web_search` / `web_fetch`(在工具箱授权取代它们之前,#108)以及 `tool_search`。授予的工具箱工具和跨应用工具,以及用户在设置中开启 +(默认关闭)的命令执行,通过 `SystemAgentTools` 作为宿主工具加入;命令执行是需要实时批准的宿主工具(`terminal.run`)。 + +**内核今天执行的内容。** octos 没有宿主可为单个会话设置的工具名单,因此每次启动都写入 `_main` profile 的 `tool_policy` +(`system_tools::tool_policy`):任何授权可给予的一切,唯独去掉 octos 自己的 shell(`group:runtime`:`shell`、`bash`、 +`exec_command`、`write_stdin`)——这是 OctoSense 唯一从不提供的工具,因为 §12 只以宿主工具的形式授予命令执行。octos +对该 profile 的每个回合(包括唤醒续接回合)都应用它。因此: + +- **§12 的“恰好是它的授权”对系统智能体尚未执行**:它拿不到 octos shell,但除此之外受可授权上限约束,而不是它的清单, + 直到 octos#2567 增加面向会话的注册和工具名单(评审第 M1 项;我们的第 5 项)。精确清单的真实内核测试在此之前被忽略; +- 应用 peer 由其回合的 `generic_tools` 收窄到其授权(计划第 6 步); +- Talk to Octos 外部回合保留 octos 自己的允许列表。 + +该策略只写入 OctoSense 自己的 core 目录,且只覆盖 OctoSense 写入的策略(`"owner": "octosense"`):遇到外来策略或用户自己的 +`$HOME/octos-home/.octos` 时拒绝并发出警告。 ## 测试 diff --git a/crates/kernel/src/dirs.rs b/crates/kernel/src/dirs.rs index 7996556b..680e4a02 100644 --- a/crates/kernel/src/dirs.rs +++ b/crates/kernel/src/dirs.rs @@ -2,10 +2,17 @@ //! //! The **core dir** is octos's data dir: `/profiles/_main.json` is //! the profile the AI providers app's `llm` service writes and the kernel -//! reads. On a phone it is `/.octos` under the kernel's own HOME, -//! `/octos-home` (the app-private dir AppCard has always used); -//! the kernel is spawned with that HOME (Android) or served from it -//! (OpenHarmony, `octos_cli::embedded::serve_io(home, ..)`). +//! reads. It is OctoSense's own: `/octos-home/.octos`, where +//! the app data dir is the platform's (a phone's files dir) or OctoSense's +//! state dir on a desktop (`~/.octosense`). On a phone the kernel is spawned +//! with that HOME (Android) or served from it (OpenHarmony, +//! `octos_cli::embedded::serve_io(home, ..)`). +//! +//! A desktop OctoSense used to share the person's own octos home +//! (`$HOME/octos-home/.octos`) with their standalone octos. It no longer +//! does, since OctoSense writes its own tool policy into its profile; on +//! first use [`migrate_shared_profile`] COPIES the provider and model +//! settings from there, never modifying or deleting the person's files. use std::path::{Path, PathBuf}; @@ -13,30 +20,77 @@ use std::path::{Path, PathBuf}; /// /// 1. `explicit` (the shell's [`crate::Options::core_dir`]); /// 2. `$OCTOS_APP_CORE_DIR` (non-empty); -/// 3. on Android and OpenHarmony, `/octos-home/.octos` when the -/// shell named its data dir (it registers services before the hosted app -/// has pointed `$HOME` there); +/// 3. `/octos-home/.octos` when the shell named its data dir +/// (a phone's files dir; OctoSense's state dir on a desktop): OctoSense's +/// own octos home; /// 4. `octosense_llm_config::profile::default_core_dir()`: -/// `$HOME/octos-home/.octos`. +/// `$HOME/octos-home/.octos`, for a consumer that names nothing (the +/// kernel then never writes a tool policy there, see `system_tools`). pub fn resolve_core_dir(explicit: Option<&Path>, app_data_dir: Option<&Path>) -> Option { - resolve_core_dir_for(explicit, app_data_dir, cfg!(any(target_os = "android", target_env = "ohos"))) -} - -pub(crate) fn resolve_core_dir_for(explicit: Option<&Path>, app_data_dir: Option<&Path>, phone: bool) -> Option { if let Some(dir) = explicit.filter(|d| !d.as_os_str().is_empty()) { return Some(dir.to_path_buf()); } if let Some(dir) = std::env::var_os("OCTOS_APP_CORE_DIR").filter(|v| !v.is_empty()) { return Some(PathBuf::from(dir)); } - if phone { - if let Some(data) = app_data_dir.filter(|d| !d.as_os_str().is_empty()) { - return Some(data.join("octos-home").join(".octos")); - } + if let Some(data) = app_data_dir.filter(|d| !d.as_os_str().is_empty()) { + return Some(data.join("octos-home").join(".octos")); } octosense_llm_config::profile::default_core_dir() } +/// The shared octos home an OctoSense core dir may inherit settings from: +/// `$HOME/octos-home/.octos`, only on a desktop whose core dir comes from +/// its app data dir (an explicit or `$OCTOS_APP_CORE_DIR` core dir is the +/// caller's choice; phones never shared it). +pub(crate) fn shared_profile_source(explicit: Option<&Path>, app_data_dir: Option<&Path>) -> Option { + let desktop = !cfg!(any(target_os = "android", target_env = "ohos", target_os = "ios")); + let derived = explicit.is_none_or(|d| d.as_os_str().is_empty()) + && std::env::var_os("OCTOS_APP_CORE_DIR").is_none_or(|v| v.is_empty()) + && app_data_dir.is_some_and(|d| !d.as_os_str().is_empty()); + (desktop && derived).then(octosense_llm_config::profile::default_core_dir).flatten() +} + +/// The profile keys the migration carries: the provider and model settings +/// (`llm`) and the key references beside them (`env_vars`: keychain markers +/// or keys the person saved there). +const MIGRATED_CONFIG_KEYS: &[&str] = &["llm", "env_vars"]; + +/// Copy the provider and model settings of `from`'s `_main` profile into a +/// new `_main` profile under `to`, once: only when `to` has no profile and +/// `from` has one. Never writes, moves or deletes anything under `from`; no +/// tool policy or other setting of the person's octos is carried over. +/// Returns whether it copied. +pub fn migrate_shared_profile(from: &Path, to: &Path) -> Result { + let target = profile_path(to); + if target.exists() || from == to { + return Ok(false); + } + let Ok(bytes) = std::fs::read(profile_path(from)) else { return Ok(false) }; + let source: serde_json::Value = serde_json::from_slice(&bytes) + .map_err(|e| format!("{} is not JSON: {e}", profile_path(from).display()))?; + let mut config = serde_json::Map::new(); + for key in MIGRATED_CONFIG_KEYS { + if let Some(value) = source["config"].get(*key) { + config.insert((*key).to_owned(), value.clone()); + } + } + if config.is_empty() { + return Ok(false); + } + let now = source["updated_at"].clone(); + let profile = serde_json::json!({ + "id": "_main", "name": source["name"].as_str().unwrap_or("Main"), "enabled": true, + "created_at": source["created_at"].clone(), "updated_at": now, + "config": config, + }); + let body = serde_json::to_vec_pretty(&profile).map_err(|e| e.to_string())?; + let dir = target.parent().unwrap_or(to); + crate::network::write_private(dir, "_main.json", &body) + .map_err(|e| format!("could not write {}: {e}", target.display()))?; + Ok(true) +} + /// The kernel's HOME for a core dir: its parent when it is the conventional /// `/.octos`, else the core dir itself (then the kernel is also told /// `--data-dir `, see `launch`). @@ -69,33 +123,38 @@ mod tests { #[test] fn explicit_wins_over_everything() { let _g = ENV.lock().unwrap(); - let got = resolve_core_dir_for(Some(Path::new("/x/core")), Some(Path::new("/data")), true); + let got = resolve_core_dir(Some(Path::new("/x/core")), Some(Path::new("/data"))); assert_eq!(got, Some(PathBuf::from("/x/core"))); + assert_eq!(shared_profile_source(Some(Path::new("/x/core")), Some(Path::new("/data"))), None); } #[test] - fn env_then_phone_data_dir_then_home() { + fn env_then_app_data_dir_then_home() { let _g = ENV.lock().unwrap(); let saved_dir = std::env::var_os("OCTOS_APP_CORE_DIR"); let saved_home = std::env::var_os("HOME"); std::env::set_var("OCTOS_APP_CORE_DIR", "/env/core"); - assert_eq!(resolve_core_dir_for(None, Some(Path::new("/data")), true), Some(PathBuf::from("/env/core"))); + assert_eq!(resolve_core_dir(None, Some(Path::new("/data"))), Some(PathBuf::from("/env/core"))); + assert_eq!(shared_profile_source(None, Some(Path::new("/data"))), None, "the caller's choice"); std::env::set_var("OCTOS_APP_CORE_DIR", ""); std::env::set_var("HOME", "/home/me"); - // A phone with the shell's data dir: the app-private octos home. - assert_eq!( - resolve_core_dir_for(None, Some(Path::new("/data/user/0/app/files")), true), - Some(PathBuf::from("/data/user/0/app/files/octos-home/.octos")) - ); - // A desktop ignores the data dir: $HOME/octos-home/.octos. + // Every platform with a data dir: OctoSense's own octos home. assert_eq!( - resolve_core_dir_for(None, Some(Path::new("/data")), false), - Some(PathBuf::from("/home/me/octos-home/.octos")) + resolve_core_dir(None, Some(Path::new("/home/me/.octosense"))), + Some(PathBuf::from("/home/me/.octosense/octos-home/.octos")) ); - // Same as the llm service's default when nothing is named. - assert_eq!(resolve_core_dir_for(None, None, true), octosense_llm_config::profile::default_core_dir()); + if !cfg!(any(target_os = "android", target_env = "ohos", target_os = "ios")) { + assert_eq!( + shared_profile_source(None, Some(Path::new("/home/me/.octosense"))), + Some(PathBuf::from("/home/me/octos-home/.octos")), + "a desktop inherits from the shared home it used to use" + ); + } + // Nothing named: the llm service's default. + assert_eq!(resolve_core_dir(None, None), octosense_llm_config::profile::default_core_dir()); + assert_eq!(shared_profile_source(None, None), None); match saved_dir { Some(v) => std::env::set_var("OCTOS_APP_CORE_DIR", v), @@ -107,6 +166,65 @@ mod tests { } } + fn tmp(tag: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("octos-dirs-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + dir + } + + fn snapshot(dir: &Path) -> Vec<(PathBuf, Vec)> { + let mut out = Vec::new(); + let mut stack = vec![dir.to_path_buf()]; + while let Some(d) = stack.pop() { + for entry in std::fs::read_dir(&d).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { stack.push(path) } else { out.push((path.clone(), std::fs::read(&path).unwrap())) } + } + } + out.sort(); + out + } + + #[test] + fn migration_copies_provider_settings_once_and_never_touches_the_source() { + let root = tmp("migrate"); + let shared = root.join("octos-home/.octos"); + let own = root.join("octosense/octos-home/.octos"); + std::fs::create_dir_all(shared.join("profiles")).unwrap(); + std::fs::write(profile_path(&shared), serde_json::json!({ + "id": "_main", "name": "Mine", "enabled": true, + "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-02T00:00:00Z", + "config": {"llm": {"primary": {"family_id": "deepseek", "model_id": "m"}}, + "env_vars": {"DEEPSEEK_API_KEY": "keychain:DEEPSEEK_API_KEY::_main"}, + "tool_policy": {"allow": ["shell"]}, "channels": {"x": 1}} + }).to_string()).unwrap(); + std::fs::write(shared.join("config.json"), "{}").unwrap(); + let before = snapshot(&shared); + + assert_eq!(migrate_shared_profile(&shared, &own), Ok(true)); + let copied: serde_json::Value = serde_json::from_slice(&std::fs::read(profile_path(&own)).unwrap()).unwrap(); + assert_eq!(copied["id"], "_main"); + assert_eq!(copied["config"]["llm"]["primary"]["family_id"], "deepseek"); + assert_eq!(copied["config"]["env_vars"]["DEEPSEEK_API_KEY"], "keychain:DEEPSEEK_API_KEY::_main"); + assert!(copied["config"].get("tool_policy").is_none(), "the person's policy is not ours"); + assert!(copied["config"].get("channels").is_none(), "only provider settings"); + assert!(!own.join("config.json").exists()); + + // Once: an existing OctoSense profile is never overwritten. + std::fs::write(profile_path(&own), r#"{"config":{"llm":{"primary":{"family_id":"zai"}}}}"#).unwrap(); + assert_eq!(migrate_shared_profile(&shared, &own), Ok(false)); + assert!(std::fs::read_to_string(profile_path(&own)).unwrap().contains("zai")); + assert_eq!(snapshot(&shared), before, "the person's octos home is unchanged"); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn migration_without_a_shared_profile_does_nothing() { + let root = tmp("migrate-none"); + assert_eq!(migrate_shared_profile(&root.join("a"), &root.join("b")), Ok(false)); + assert!(!root.join("b").exists()); + } + #[test] fn kernel_home_is_the_parent_of_a_dot_octos_core_dir() { assert_eq!(kernel_home(Path::new("/d/octos-home/.octos")), PathBuf::from("/d/octos-home")); diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index af45f094..cfa48e45 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -87,8 +87,8 @@ impl Options { self.core_dir = Some(dir.into()); self } - /// The app's data dir (`cx.get_data_dir()`): on a phone the core dir is - /// `/octos-home/.octos`. + /// The app's data dir (`cx.get_data_dir()` on a phone, OctoSense's state + /// dir on a desktop): the core dir is `/octos-home/.octos`. pub fn app_data_dir(mut self, dir: impl Into) -> Self { self.app_data_dir = Some(dir.into()); self @@ -218,6 +218,7 @@ impl Default for Core { impl Core { pub fn new(options: Options) -> Self { + Self::adopt_shared_profile(&options); Core(Arc::new(Inner { state: Mutex::new(State { options, @@ -235,9 +236,26 @@ impl Core { /// Replace the options. A running kernel keeps its launch until it /// restarts; the core dir answers the new value at once. pub fn configure(&self, options: Options) { + Self::adopt_shared_profile(&options); self.0.state.lock().unwrap().options = options; } + /// A desktop OctoSense's own core dir inherits, once, the provider and + /// model settings of the octos home it used to share with the person's + /// standalone octos (copy only; see [`dirs::migrate_shared_profile`]). + fn adopt_shared_profile(options: &Options) { + let Some(from) = dirs::shared_profile_source(options.core_dir.as_deref(), options.app_data_dir.as_deref()) else { + return; + }; + let Some(to) = Self::core_dir_of(options) else { return }; + match dirs::migrate_shared_profile(&from, &to) { + Ok(true) => Inner::log(options)(&format!( + "octos-core: copied the provider settings from {} to OctoSense's own {}", from.display(), to.display())), + Ok(false) => {} + Err(e) => Inner::log(options)(&format!("octos-core: provider settings not copied: {e}")), + } + } + /// The kernel's core dir (see [`resolve_core_dir`]). pub fn core_dir(&self) -> Option { let st = self.0.state.lock().unwrap(); diff --git a/crates/shell/src/lib.rs b/crates/shell/src/lib.rs index 92d5453c..a4ec47da 100644 --- a/crates/shell/src/lib.rs +++ b/crates/shell/src/lib.rs @@ -4452,7 +4452,11 @@ impl MatchEvent for App { // configured here and started when a consumer (AppCard, Rinx) // connects, and AI providers' `llm` service, which writes its profile // and restarts it after a change. A no-op where the build links none. - ai_host::start(ai_host::Host::platform(cx.get_data_dir())); + // Its octos home is OctoSense's own (`/octos-home`; on a + // desktop, OctoSense's state dir), never the person's `~/octos-home`. + ai_host::start(ai_host::Host::platform(cx.get_data_dir().or_else(|| { + Some(octosense::paths::home().to_string_lossy().into_owned()) + }))); // CLI: --import-theme pulls an omarchy theme and converts // it to splash before the desktop appears. let mut args = std::env::args(); diff --git a/docs/ai-services.md b/docs/ai-services.md index 3b7e814b..9c727bc2 100644 --- a/docs/ai-services.md +++ b/docs/ai-services.md @@ -72,7 +72,7 @@ How it runs, by platform (`crates/kernel/src/launch.rs`, `KernelSource::platform | Platform | Kernel | Core dir (the octos home) | | --- | --- | --- | -| Desktop (macOS; Windows and Linux untested) | `$OCTOS_APP_CORE_BIN serve --stdio --data-dir ` (plus `--config /config.json` if present). **Without `OCTOS_APP_CORE_BIN` there is no kernel**, and a developer's own `octos serve` is never touched. Packaging the kernel next to the desktop binary is in progress ([#85](https://github.com/OctoSense-org/OctoSense/pull/85)). | `$OCTOS_APP_CORE_DIR`, else `~/octos-home/.octos` | +| Desktop (macOS; Windows and Linux untested) | `$OCTOS_APP_CORE_BIN serve --stdio --data-dir ` (plus `--config /config.json` if present). **Without `OCTOS_APP_CORE_BIN` there is no kernel**, and a developer's own `octos serve` is never touched. Packaging the kernel next to the desktop binary is in progress ([#85](https://github.com/OctoSense-org/OctoSense/pull/85)). | `$OCTOS_APP_CORE_DIR`, else `/octos-home/.octos` (`~/.octosense/octos-home/.octos`): OctoSense's own, no longer the person's `~/octos-home/.octos`, whose provider settings it copies once | | Android (Home) | The APK's `liboctos.so serve --stdio`, built by [`tools/kernel-artifact.py`](../tools/kernel-artifact.py) from the octos revision the root `Cargo.toml` pins | `/octos-home/.octos` | | OpenHarmony | In process (`octos_cli::embedded::serve_io`), because a HAP may not exec | `/octos-home/.octos` | | iOS | **None.** Providers are still saved; no app gets an assistant | – | diff --git a/docs/ai-services.zh-CN.md b/docs/ai-services.zh-CN.md index b0dcae61..9f715ade 100644 --- a/docs/ai-services.zh-CN.md +++ b/docs/ai-services.zh-CN.md @@ -72,7 +72,7 @@ flowchart TB | 平台 | 内核 | Core 目录(octos home) | | --- | --- | --- | -| 桌面端(macOS;Windows 和 Linux 未测试) | `$OCTOS_APP_CORE_BIN serve --stdio --data-dir `(存在 `/config.json` 时再加 `--config`)。**没有 `OCTOS_APP_CORE_BIN` 就没有内核**,开发者自己运行的 `octos serve` 永远不会被动到。把内核打包在桌面端二进制旁边的工作正在进行([#85](https://github.com/OctoSense-org/OctoSense/pull/85))。 | `$OCTOS_APP_CORE_DIR`,否则为 `~/octos-home/.octos` | +| 桌面端(macOS;Windows 和 Linux 未测试) | `$OCTOS_APP_CORE_BIN serve --stdio --data-dir `(存在 `/config.json` 时再加 `--config`)。**没有 `OCTOS_APP_CORE_BIN` 就没有内核**,开发者自己运行的 `octos serve` 永远不会被动到。把内核打包在桌面端二进制旁边的工作正在进行([#85](https://github.com/OctoSense-org/OctoSense/pull/85))。 | `$OCTOS_APP_CORE_DIR`,否则为 `/octos-home/.octos`(`~/.octosense/octos-home/.octos`):OctoSense 自己的目录,不再是用户的 `~/octos-home/.octos`(只从中复制一次提供商设置) | | Android(Home) | APK 中的 `liboctos.so serve --stdio`,由 [`tools/kernel-artifact.py`](../tools/kernel-artifact.py) 按根 `Cargo.toml` 锁定的 octos 版本构建 | `<应用数据目录>/octos-home/.octos` | | OpenHarmony | 进程内运行(`octos_cli::embedded::serve_io`),因为 HAP 不能 exec | `<应用数据目录>/octos-home/.octos` | | iOS | **没有。** 提供方仍会保存;没有应用能获得助手 | – | From 2251b3566595ad0636cf0f46ce421802d7bc7340 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:53:14 -0700 Subject: [PATCH 4/4] fix(kernel): exclude only octos's shell; write only OctoSense's own policy [skip ci] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR 0004 §12: OctoSense hard-codes no exclusions. The `_main` profile policy is now everything a grant can give (an empty allowlist) minus octos's own shell (group:runtime: shell, bash, exec_command, write_stdin), the one exception, since §12 delivers command execution only as an approved host tool (terminal.run). Schedules, sub-agents, check, git, goals and admin tools stay grantable. The system agent's "exactly its grants" is not yet enforced: it is bounded by that ceiling until octos#2567 adds session-targeted registration and tool lists (M1, our item 5); the exact-list real-kernel test stays ignored with that reason. `enforce` marks the policy it writes ("owner": "octosense") and replaces only its own: a foreign policy, or the person's own ~/octos-home/.octos, is refused with a warning. The real-kernel test now waits for the host's turn to finish before the external client's (one turn per session). Co-Authored-By: Claude Opus 5.5 --- .../tests/fixtures/mock_agent_llm.py | 2 +- crates/app-peers/tests/real_kernel.rs | 14 +- crates/kernel/src/system_tools.rs | 337 ++++++++---------- crates/kernel/tests/real_kernel.rs | 48 ++- 4 files changed, 185 insertions(+), 216 deletions(-) diff --git a/crates/app-peers/tests/fixtures/mock_agent_llm.py b/crates/app-peers/tests/fixtures/mock_agent_llm.py index a2916479..e786a68b 100644 --- a/crates/app-peers/tests/fixtures/mock_agent_llm.py +++ b/crates/app-peers/tests/fixtures/mock_agent_llm.py @@ -10,7 +10,7 @@ - "TELL_PEER_AGAIN:": send the peer "SECOND_INPUT"; - "TELL_PEER_SUDO:": send the peer "RUN_SUDO", on which the peer runs a shell command when it is offered octos's `shell` (the kernel profile's - ceiling denies it: ADR 0004 §12), else says "NO SHELL OFFERED"; + policy denies it: ADR 0004 §12), else says "NO SHELL OFFERED"; - "APPROVE_PEER:": try to approve a peer's tool with peer_respond; - a user text "QUESTION_ME" with ask_user_question offered: ask one question; - a message naming a waiting peer with peer_respond offered: answer "42"; diff --git a/crates/app-peers/tests/real_kernel.rs b/crates/app-peers/tests/real_kernel.rs index f447f4cd..e9520254 100644 --- a/crates/app-peers/tests/real_kernel.rs +++ b/crates/app-peers/tests/real_kernel.rs @@ -306,8 +306,8 @@ fn an_account_change_drops_a_late_reply_and_resume_keeps_the_peer_across_restart /// app peer input, the peer asks a question, the kernel wakes the system /// agent, which answers, and the peer continues with the answer. Every model /// request on the way (the host's turn, the kernel's wake continuation, the -/// peer's turns) is offered only tools inside the kernel profile's ceiling -/// (ADR 0004 §12), never octos's `shell`. +/// peer's turns) is offered none of octos's shell (ADR 0004 §12: command +/// execution is a granted host tool, never octos's `shell`). #[test] fn the_system_agent_and_the_app_peer_exchange_a_question_and_answer() { let Some(program) = kernel() else { return }; @@ -364,7 +364,6 @@ fn the_system_agent_and_the_app_peer_exchange_a_question_and_answer() { text.contains("PEER GOT 42"), "the peer continued with the system agent's answer: {text}" ); - let allowed = octosense_kernel::system_tools::profile_ceiling(); let requests: Vec = std::fs::read_to_string(&offered_log) .unwrap() .lines() @@ -373,7 +372,10 @@ fn the_system_agent_and_the_app_peer_exchange_a_question_and_answer() { for request in &requests { for tool in request["tools"].as_array().unwrap() { let tool = tool.as_str().unwrap(); - assert!(allowed.contains(tool), "{tool} offered: {request}"); + assert!( + !["shell", "bash", "exec_command", "write_stdin"].contains(&tool), + "{tool} offered: {request}" + ); } } assert!( @@ -579,8 +581,8 @@ fn a_second_input_to_an_answered_peer_runs() { } /// ADR 0004 §12 (was ADR 0007's approval test): octos's own `shell` is -/// offered to no `_main` session (the kernel profile's ceiling, written by -/// `octosense-kernel` at every start, denies it), so the system agent cannot +/// offered to no `_main` session (the kernel profile's policy, written by +/// `octosense-kernel` at every start, denies it and nothing else), so the system agent cannot /// get a command run through an app peer by asking or by "approving". The /// system agent handing the peer a command gets no tool approval parked and /// nothing run; its `peer_respond` "approval" finds nothing to approve. diff --git a/crates/kernel/src/system_tools.rs b/crates/kernel/src/system_tools.rs index 9de14ae4..b5d07a5d 100644 --- a/crates/kernel/src/system_tools.rs +++ b/crates/kernel/src/system_tools.rs @@ -1,64 +1,62 @@ -//! The system agent's tool set and the kernel profile's tool ceiling +//! The system agent's tool set and the kernel profile's tool policy //! (ADR 0004 §12, plan step 4). //! -//! Two lists live here, and only here: -//! //! - [`SYSTEM_AGENT_TOOLS`]: the octos tools the system agent -//! (`_main:api:octosense#system`) is meant to be offered. With what it is -//! granted ([`SystemAgentTools`]: toolbox tools, other apps' shareable -//! tools, and command execution when the person turns it on in Settings) -//! that is its whole set. -//! - [`profile_ceiling`]: every octos tool ANY `_main` session may be -//! offered: the system agent's list plus every octos generic tool an app -//! may declare and be granted ([`APP_GRANTABLE_OCTOS_TOOLS`]). Left out are -//! only the tools no grant gives: octos's own process tools (`shell`, -//! `bash`, `exec_command`, `write_stdin`, `check`, `git`), sub-agents and -//! ad-hoc peers, schedulers, goals and kernel administration -//! ([`NEVER_OFFERED`]). Command execution, when granted, is a HOST tool -//! (for example `terminal.run`) with a live approval, never octos's -//! `shell`. +//! (`_main:api:octosense#system`) gets by default. With what the person +//! grants it ([`SystemAgentTools`]: toolbox tools, other apps' shareable +//! tools, and command execution when turned on in Settings) that is its +//! whole set: ADR 0004 §12's "exactly its grants". +//! - [`tool_policy`]: the `_main` profile's policy, the ceiling for every +//! `_main` session. It is every tool any grant can give: OctoSense +//! hard-codes no exclusions (§12) except ONE, octos's own shell +//! ([`OCTOS_SHELL`]). §12 delivers command execution only as a host tool +//! with a live approval (for example `terminal.run`), run by the shell +//! where it can be approved, audited and shown; octos's `shell` runs +//! commands inside the kernel with none of that, so no grant gives it. +//! (If a later reading of §12 makes octos's `shell` grantable too, this is +//! the one line to change: TODO(ADR 0004 §12).) +//! +//! **What is enforced today, and what is not.** octos (the pinned rev) has no +//! tool list the host can set for one session; the only roster control a +//! host has is the profile's `tool_policy` (allow/deny, deny wins), which +//! octos re-applies to every turn's finished registry (after the per-turn +//! `peer_*`, `spawn` and `send_file` tools) and to kernel wake continuations +//! alike. So before every kernel start ([`enforce`], from `launch::prepare`) +//! the host writes [`tool_policy`] into its OWN profile. Consequently: //! -//! **What is enforced today.** octos (the pinned rev) has no per-session tool -//! list for an ordinary session; the only roster control a host has is the -//! profile's `tool_policy` (allow/deny, deny wins), which octos re-applies to -//! every turn's finished registry (after the per-turn `peer_*`, `spawn` and -//! `send_file` tools are registered) and to kernel wake continuations alike. -//! So before every kernel start ([`enforce`], from `launch::prepare`) the -//! host writes the CEILING as the `_main` profile's policy, replacing any -//! other; a policy change needs a kernel restart in octos, and every start -//! writes it again. Consequences: +//! - **§12's "exactly its grants" is NOT yet enforced for the system +//! agent.** It is bounded by the grantable ceiling: no octos shell, but +//! every other tool octos registers for it. Its exact list needs +//! session-targeted registration and tool lists in octos (octos#2567, +//! reviewer item M1; our item 5). The real-kernel exact-list test is kept, +//! ignored until then. +//! - **App peers are not capped below what they can be granted**: each is +//! narrowed to its grants by its turns' `generic_tools` (octos#2567, plan +//! step 6). Host-routed tools (app, toolbox, cross-app tools, command +//! execution) are registered after the policy, so it never strips them. +//! - **Talk to Octos external turns are unaffected**: octos confines them to +//! its external allowlist ([`EXTERNAL_TURN_TOOLS`]), none of which is the +//! shell (UPCR-2026-036). //! -//! - **The system agent is bounded by the ceiling, not by its exact list**, -//! until octos lets the host set a session's tool list (the per-session -//! host tool list, octos#2567 item 5). No `shell` or other process tool, -//! no sub-agents, no administration reach it; generic tools an app may be -//! granted (for example `deep_search`, `browser`, `run_pipeline`) do, when -//! octos registers them. The real-kernel test that asserts the exact list -//! is ignored until then. -//! - **App peers are not capped below what they can be granted**: the -//! ceiling holds every grantable octos tool, and each peer is narrowed to -//! its grants by its turns' `generic_tools` (octos#2567, plan step 6). -//! Host-routed tools (app, toolbox, cross-app tools, command execution) -//! are registered after the policy, so it never strips them. -//! - **Talk to Octos external turns are unaffected**: the ceiling contains -//! octos's external allowlist ([`EXTERNAL_TURN_TOOLS`]), and octos confines -//! those turns to it on its own (UPCR-2026-036). +//! **Whose profile.** The kernel's core dir is OctoSense's own +//! (`/octos-home/.octos`, see `dirs`), never the +//! person's standalone octos home, and [`enforce`] replaces only a policy +//! OctoSense wrote ([`POLICY_OWNER`]): it refuses, and warns, on a foreign +//! policy and on the person's own octos home. use std::collections::BTreeSet; use std::path::Path; use serde_json::{json, Value}; -/// The octos tools the system agent is meant to be offered (its exact list -/// once octos#2567 item 5 exists; until then it is bounded by -/// [`profile_ceiling`]). +/// The octos tools the system agent gets by default (not yet enforced as +/// exact: see the module docs). /// /// - **Supervision** of app peers (ADR 0004 §6): `peer_send_input` briefs /// and asks, `peer_gather` / `peer_list` read the blackboard, /// `peer_respond` answers a peer's question (never its approvals, which /// octos refuses), `peer_close` retires one. Not `peer_handoff`: app peers -/// are prepared by the host (`peer/prepare`), and an ad-hoc peer would be -/// an agent outside every grant. +/// are prepared by the host (`peer/prepare`). /// - **Its workspace**, fenced by octos to the session's working directory: /// read, search and edit files there. /// - **The person**: `ask_user_question`, media viewing. @@ -67,9 +65,8 @@ use serde_json::{json, Value}; /// Octos clients also keep, until the toolbox (#108) grants the system /// agent `toolbox.search` / `toolbox.web_read`. /// -/// Not octos's `shell` or any process tool: command execution reaches the -/// system agent only as a host tool the person grants in Settings -/// ([`SystemAgentTools::grant_command_execution`]). +/// Anything else it may have is by grant ([`SystemAgentTools`]); command +/// execution only as a host tool the person turns on in Settings. pub const SYSTEM_AGENT_TOOLS: &[&str] = &[ // Supervision. "peer_send_input", @@ -106,74 +103,19 @@ pub const SYSTEM_AGENT_TOOLS: &[&str] = &[ "tool_search", ]; -/// octos generic tools an app may declare and the person grant (ADR 0004 -/// §12: OctoSense hard-codes no exclusions among them), beyond -/// [`SYSTEM_AGENT_TOOLS`]. Each app peer gets only its grants, through its -/// turns' `generic_tools` (plan step 6); a name octos does not register is -/// simply never offered. -pub const APP_GRANTABLE_OCTOS_TOOLS: &[&str] = &[ - // Research and the web. - "deep_search", - "deep_research", - "synthesize_research", - "deep_crawl", - "site_crawl", - "browser", - // Content generation and pipelines. - "image_generation", - "mofa_make", - "mofa_describe_content_type", - "run_pipeline", - "check_background_tasks", - "read_task_output", - // Messaging the person. - "send_file", - "message", - "request_user_input", - // Workspace history and planning. - "workspace_diff", - "workspace_log", - "workspace_show", - "update_plan", - "tool_suggest", - // Memory bookkeeping. - "record_memory_use", -]; +/// octos's own shell: `group:runtime`, the `shell` tool and its aliases +/// (`bash`, `exec_command` and its PTY input `write_stdin`). The one tool +/// OctoSense never offers: command execution is granted as a host tool +/// ([`COMMAND_EXECUTION_TOOL`]), each command approved live. +pub const OCTOS_SHELL: &str = "group:runtime"; -/// octos tools no grant gives, denied outright (octos: deny wins over -/// allow). They are also absent from the ceiling's allowlist, like every -/// plugin and MCP tool. -/// -/// - octos's own process tools: `group:runtime` (`shell`, `exec_command`, -/// `write_stdin`, `bash`), `check` (runs build and test commands), `git` -/// (runs git, whose hooks run code). Granted command execution is a host -/// tool with a live approval instead. -/// - Sub-agents and ad-hoc peers: `group:sessions`, `peer_handoff`. -/// - Schedulers and goals, which start work outside any grant: `cron`, -/// `monitor_*`, `goal_*`. -/// - Kernel administration: `group:admin`. -pub const NEVER_OFFERED: &[&str] = &[ - "group:runtime", - "check", - "git", - "group:sessions", - "peer_handoff", - "cron", - "monitor_*", - "goal_*", - "group:admin", -]; +/// Marks a `tool_policy` OctoSense wrote (`"owner"`, a field octos ignores). +pub const POLICY_OWNER: &str = "octosense"; /// The host tool granted command execution arrives as (the Terminal app's /// shareable tool, ADR 0004 §10 and §12): each command approved live. pub const COMMAND_EXECUTION_TOOL: &str = "terminal.run"; -/// Every octos tool a `_main` session may be offered: -/// [`SYSTEM_AGENT_TOOLS`] ∪ [`APP_GRANTABLE_OCTOS_TOOLS`]. -pub fn profile_ceiling() -> BTreeSet<&'static str> { - SYSTEM_AGENT_TOOLS.iter().chain(APP_GRANTABLE_OCTOS_TOOLS).copied().collect() -} - /// octos's allowlist for a Talk to Octos external turn (octos /// `crates/octos-cli/src/api/host_managed.rs`, `EXTERNAL_TURN_TOOLS`, at the /// pinned rev). Kept here to prove the system agent's set never narrows it. @@ -202,9 +144,9 @@ pub const EXTERNAL_TURN_TOOLS: &[&str] = &[ /// The system agent's tool set: [`SYSTEM_AGENT_TOOLS`] plus what it is /// granted. Granted tools are host-routed; registering them on the system -/// session needs octos's per-session host tool list (octos#2567 items 5 -/// and 6), so today nothing grants any and [`SystemAgentTools::host_tools`] -/// is what the shell will register then. +/// session, like narrowing it to this set, needs octos's session-targeted +/// registration (octos#2567 items 5 and 6), so today nothing grants any and +/// [`SystemAgentTools::host_tools`] is what the shell will register then. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct SystemAgentTools { toolbox: BTreeSet, @@ -233,7 +175,7 @@ impl SystemAgentTools { /// The person's Settings switch for the system agent's command /// execution (ADR 0004 §12; off by default). On, the system agent gets /// the host tool [`COMMAND_EXECUTION_TOOL`], each command approved live - /// (section 8); never octos's `shell`, which stays denied. + /// (section 8); never octos's shell. /// /// TODO(ADR 0004 plan steps 4/6): persist the switch in Settings → /// Assistant (no Settings plumbing for it exists yet) and register the @@ -264,38 +206,57 @@ impl SystemAgentTools { } } -/// The octos `ToolPolicy` the kernel runs `_main` with: allow -/// [`profile_ceiling`], deny [`NEVER_OFFERED`]. +/// The octos `ToolPolicy` the kernel runs `_main` with: everything a grant +/// can give (an empty allowlist is octos's "allow all") except octos's +/// shell, marked as OctoSense's. pub fn tool_policy() -> Value { - json!({ - "allow": profile_ceiling().into_iter().collect::>(), - "deny": NEVER_OFFERED, - }) + json!({ "allow": [], "deny": [OCTOS_SHELL], "owner": POLICY_OWNER }) +} + +/// What [`enforce`] did. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Enforced { + /// The policy was written (or already current). + Written, + /// No profile yet: no provider, no turns, nothing to do. + NoProfile, + /// Refused: why (a foreign policy, the person's own octos home, an + /// unreadable profile). Logged as a warning. + Refused(String), } /// Write [`tool_policy`] into `/profiles/_main.json` -/// (`config.tool_policy`), keeping every other key. No profile yet means no -/// provider and no turns: nothing to do. An unreadable profile is left for -/// the kernel to report (it cannot run turns from it either). -pub fn enforce(core_dir: &Path) { +/// (`config.tool_policy`), keeping every other key. It replaces only a +/// policy OctoSense wrote (owner [`POLICY_OWNER`]) or none, and never +/// touches the person's own octos home (`$HOME/octos-home/.octos`). +pub fn enforce(core_dir: &Path) -> Enforced { + let outcome = enforce_unless_shared(core_dir, octosense_llm_config::profile::default_core_dir().as_deref()); + if let Enforced::Refused(why) = &outcome { + log::warn!("octos-core: tool policy NOT written: {why}"); + } + outcome +} + +pub(crate) fn enforce_unless_shared(core_dir: &Path, shared: Option<&Path>) -> Enforced { + if shared.is_some_and(|shared| same_dir(shared, core_dir)) { + return Enforced::Refused(format!( + "{} is the person's own octos home, not OctoSense's", + core_dir.display() + )); + } let path = crate::dirs::profile_path(core_dir); - let Ok(bytes) = std::fs::read(&path) else { return }; - let Ok(mut root) = serde_json::from_slice::(&bytes) else { - log::warn!("octos-core: {} is not JSON; tool policy NOT written", path.display()); - return; - }; - let Some(obj) = root.as_object_mut() else { - log::warn!("octos-core: {} is not a JSON object; tool policy NOT written", path.display()); - return; - }; + let Ok(bytes) = std::fs::read(&path) else { return Enforced::NoProfile }; + let refused = |what: &str| Enforced::Refused(format!("{} {what}", path.display())); + let Ok(mut root) = serde_json::from_slice::(&bytes) else { return refused("is not JSON") }; + let Some(obj) = root.as_object_mut() else { return refused("is not a JSON object") }; let config = obj.entry("config").or_insert_with(|| json!({})); - let Some(config) = config.as_object_mut() else { - log::warn!("octos-core: {} `config` is not an object; tool policy NOT written", path.display()); - return; - }; + let Some(config) = config.as_object_mut() else { return refused("has a `config` that is not an object") }; let policy = tool_policy(); - if config.get("tool_policy") == Some(&policy) { - return; + match config.get("tool_policy") { + Some(current) if *current == policy => return Enforced::Written, + None | Some(Value::Null) => {} + Some(current) if current.get("owner").and_then(Value::as_str) == Some(POLICY_OWNER) => {} + Some(_) => return refused("has a tool policy OctoSense did not write; leaving it"), } config.insert("tool_policy".into(), policy); let result = serde_json::to_vec_pretty(&root) @@ -306,11 +267,18 @@ pub fn enforce(core_dir: &Path) { crate::network::write_private(dir, name, &body).map_err(|e| e.to_string()) }); match result { - Ok(()) => log::info!("octos-core: wrote the tool ceiling to {}", path.display()), - Err(e) => log::warn!("octos-core: could not write the tool policy to {}: {e}", path.display()), + Ok(()) => { + log::info!("octos-core: wrote OctoSense's tool policy to {}", path.display()); + Enforced::Written + } + Err(e) => Enforced::Refused(format!("could not write {}: {e}", path.display())), } } +fn same_dir(a: &Path, b: &Path) -> bool { + a == b || matches!((std::fs::canonicalize(a), std::fs::canonicalize(b)), (Ok(x), Ok(y)) if x == y) +} + #[cfg(test)] mod tests { use super::*; @@ -322,46 +290,20 @@ mod tests { dir } - const PROCESS_AND_ADMIN: &[&str] = &[ - "shell", "exec_command", "write_stdin", "bash", "check", "git", "spawn", "spawn_agent", - "send_input", "resume_agent", "wait_agent", "close_agent", "delegate", "peer_handoff", "cron", - "monitor_create", "goal_grant", "manage_skills", "configure_tool", "model_check", - ]; - - fn policy_lists() -> (Vec, Vec) { - let policy = tool_policy(); - let list = |k: &str| policy[k].as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_owned()).collect(); - (list("allow"), list("deny")) - } - - #[test] - fn no_octos_process_tool_sub_agent_or_admin_tool_is_ever_offered() { - let (allow, deny) = policy_lists(); - for tool in PROCESS_AND_ADMIN { - assert!(!allow.iter().any(|a| a == tool), "{tool} is in the ceiling"); - assert!(!SYSTEM_AGENT_TOOLS.contains(tool), "{tool} is in the system agent's list"); - } - for group in ["group:runtime", "group:sessions", "group:admin"] { - assert!(deny.iter().any(|d| d == group), "{group} is denied outright"); - } - assert!(!allow.iter().any(|a| a.contains('*') || a.starts_with("group:")), "the ceiling names tools"); + fn read(path: &Path) -> Value { + serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap() } #[test] - fn the_ceiling_holds_the_system_agent_every_grantable_tool_and_the_external_allowlist() { - let ceiling = profile_ceiling(); - for tool in SYSTEM_AGENT_TOOLS.iter().chain(APP_GRANTABLE_OCTOS_TOOLS).chain(EXTERNAL_TURN_TOOLS) { - assert!(ceiling.contains(tool), "{tool}"); - } - // octos#2567's peer-safe generic tools, all grantable to an app. - for tool in [ - "read_file", "list_dir", "glob", "grep", "web_search", "deep_search", "memory_search", - "memory_load", "recall_memory", "save_memory", "record_memory_use", "mofa_make", - "mofa_describe_content_type", - ] { - assert!(ceiling.contains(tool), "{tool} could not be granted to an app peer"); + fn only_octos_shell_is_excluded_and_the_system_agent_default_has_none_of_it() { + let policy = tool_policy(); + assert_eq!(policy["allow"], json!([]), "everything a grant can give"); + assert_eq!(policy["deny"], json!(["group:runtime"]), "octos's shell, and nothing else"); + for shell in ["shell", "bash", "exec_command", "write_stdin"] { + assert!(!SYSTEM_AGENT_TOOLS.contains(&shell)); + assert!(!EXTERNAL_TURN_TOOLS.contains(&shell), "external clients lose nothing"); } - assert_eq!(ceiling.len(), SYSTEM_AGENT_TOOLS.len() + APP_GRANTABLE_OCTOS_TOOLS.len(), "no overlap"); + assert!(!SYSTEM_AGENT_TOOLS.contains(&"peer_handoff")); } #[test] @@ -369,7 +311,7 @@ mod tests { let shipped = SystemAgentTools::new(); assert!(!shipped.command_execution(), "command execution is off by default"); assert!(shipped.host_tools().is_empty()); - assert_eq!(shipped.names().len(), SYSTEM_AGENT_TOOLS.len()); + assert_eq!(shipped.names().len(), SYSTEM_AGENT_TOOLS.len(), "no duplicates"); let mut granted = SystemAgentTools::new(); granted .grant_toolbox("toolbox.search") @@ -378,33 +320,48 @@ mod tests { let host: Vec = granted.host_tools().into_iter().collect(); assert_eq!(host, ["mail.send", COMMAND_EXECUTION_TOOL, "toolbox.search"]); assert!(!granted.names().contains("shell"), "never octos's shell"); - assert!(!tool_policy()["allow"].as_array().unwrap().iter().any(|t| t == COMMAND_EXECUTION_TOOL)); } #[test] - fn enforce_writes_the_policy_and_keeps_the_rest() { + fn enforce_writes_ours_keeps_the_rest_and_replaces_only_our_own() { let dir = tmp("keep"); let path = dir.join("profiles/_main.json"); - std::fs::write( - &path, - r#"{"id":"_main","config":{"llm":{"primary":{"family_id":"x"}},"tool_policy":{"allow":["shell"]}}}"#, - ) - .unwrap(); - enforce(&dir); - let v: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + std::fs::write(&path, r#"{"id":"_main","config":{"llm":{"primary":{"family_id":"x"}}}}"#).unwrap(); + assert_eq!(enforce_unless_shared(&dir, None), Enforced::Written); + let v = read(&path); assert_eq!(v["id"], "_main"); assert_eq!(v["config"]["llm"]["primary"]["family_id"], "x"); - assert_eq!(v["config"]["tool_policy"], tool_policy(), "a widened policy is replaced"); + assert_eq!(v["config"]["tool_policy"], tool_policy()); + // An older policy of ours is replaced. + std::fs::write(&path, r#"{"config":{"tool_policy":{"allow":["read_file"],"owner":"octosense"}}}"#).unwrap(); + assert_eq!(enforce_unless_shared(&dir, None), Enforced::Written); + assert_eq!(read(&path)["config"]["tool_policy"], tool_policy()); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn enforce_refuses_a_foreign_policy_and_the_persons_own_octos_home() { + let dir = tmp("foreign"); + let path = dir.join("profiles/_main.json"); + let foreign = r#"{"config":{"tool_policy":{"allow":["*"]}}}"#; + std::fs::write(&path, foreign).unwrap(); + assert!(matches!(enforce_unless_shared(&dir, None), Enforced::Refused(_))); + assert_eq!(std::fs::read_to_string(&path).unwrap(), foreign, "untouched"); + // The person's own octos home: never written, policy or not. + let own = r#"{"config":{"llm":{}}}"#; + std::fs::write(&path, own).unwrap(); + assert!(matches!(enforce_unless_shared(&dir, Some(&dir)), Enforced::Refused(_))); + assert_eq!(std::fs::read_to_string(&path).unwrap(), own, "untouched"); let _ = std::fs::remove_dir_all(dir); } #[test] fn enforce_without_a_profile_or_with_a_broken_one_leaves_it() { let dir = tmp("none"); - enforce(&dir); + assert_eq!(enforce_unless_shared(&dir, None), Enforced::NoProfile); assert!(!dir.join("profiles/_main.json").exists(), "no profile is invented"); std::fs::write(dir.join("profiles/_main.json"), "{not json").unwrap(); - enforce(&dir); + assert!(matches!(enforce_unless_shared(&dir, None), Enforced::Refused(_))); assert_eq!(std::fs::read_to_string(dir.join("profiles/_main.json")).unwrap(), "{not json"); let _ = std::fs::remove_dir_all(dir); } diff --git a/crates/kernel/tests/real_kernel.rs b/crates/kernel/tests/real_kernel.rs index 0297de45..791aa615 100644 --- a/crates/kernel/tests/real_kernel.rs +++ b/crates/kernel/tests/real_kernel.rs @@ -465,8 +465,8 @@ async fn native_and_browser_talk_to_the_same_system_agent() { /// What the system agent's turns were offered: `(stdio, host over Talk to /// Octos, external client)`. The scripted model logs the tool names each -/// request offered. The profile is written with a widening tool policy: the -/// kernel start writes its own. +/// request offered. The profile carries an older OctoSense policy that allows +/// `shell`: the kernel start replaces it with the current one. async fn offered_to_system_turns(program: &Path, tag: &str) -> [std::collections::BTreeSet; 3] { use std::collections::BTreeSet; use std::io::BufRead; @@ -486,7 +486,7 @@ async fn offered_to_system_turns(program: &Path, tag: &str) -> [std::collections "created_at":"2026-09-27T00:00:00Z", "updated_at":"2026-09-27T00:00:00Z", "config":{"llm":{"primary":{"family_id":"local", "model_id":"mock-model", "route":{"base_url":format!("http://127.0.0.1:{port}/v1"), "api_type":"openai"}}}, - "tool_policy":{"allow":["*"]}} + "tool_policy":{"allow":["read_file","shell"],"owner":"octosense"}} }).to_string()).unwrap(); std::fs::write(dir.join("web-client-origin.txt"), WEB).unwrap(); let offered = |probe: &str| -> Option> { @@ -528,8 +528,18 @@ async fn offered_to_system_turns(program: &Path, tag: &str) -> [std::collections let mut native = core.connect().unwrap(); let access = native.client_access().await.unwrap(); call(&mut native, "open", "session/open", open.clone()).await; - call(&mut native, "turn", "turn/start", input("SYSTEM_TOOLS_HOST")).await; + let host_turn = input("SYSTEM_TOOLS_HOST"); + call(&mut native, "turn", "turn/start", host_turn.clone()).await; let host = wait_for("SYSTEM_TOOLS_HOST").await; + // One turn at a time on a session: let the host's finish first. + tokio::time::timeout(Duration::from_secs(30), async { + loop { + let state = call(&mut native, "state", "turn/state/get", json!({ + "session_id":SYSTEM_SESSION,"turn_id":host_turn["turn_id"]})).await; + if state["state"] == "completed" { break; } + tokio::time::sleep(Duration::from_millis(100)).await; + } + }).await.expect("the host's turn completed"); let mut browser = external(&access).await; ws_call(&mut browser, "open", "session/open", open).await; ws_call(&mut browser, "turn", "turn/start", input("SYSTEM_TOOLS_EXTERNAL")).await; @@ -543,27 +553,25 @@ async fn offered_to_system_turns(program: &Path, tag: &str) -> [std::collections } /// ADR 0004 §12, what is enforced today: a system-agent turn, whoever starts -/// it, is offered no octos process tool (`shell` and the rest), sub-agent or -/// administration tool, nothing outside the profile ceiling, and every tool -/// of its own list octos registers; a Talk to Octos external client's turn -/// keeps octos's external allowlist. +/// it, is offered none of octos's shell (`group:runtime`: `shell`, `bash`, +/// `exec_command`, `write_stdin`) and every tool of its own list octos +/// registers; beyond that it is bounded only by the grantable ceiling +/// (everything else), not its exact list. A Talk to Octos external client's +/// turn keeps octos's external allowlist. #[tokio::test(flavor = "multi_thread")] -async fn a_system_agent_turn_is_offered_nothing_outside_the_ceiling_and_no_shell() { - use octosense_kernel::system_tools::{profile_ceiling, EXTERNAL_TURN_TOOLS, SYSTEM_AGENT_TOOLS}; +async fn a_system_agent_turn_is_offered_no_octos_shell() { + use octosense_kernel::system_tools::{EXTERNAL_TURN_TOOLS, SYSTEM_AGENT_TOOLS}; use std::collections::BTreeSet; let Some(program) = kernel() else { return }; let [stdio, host, external_tools] = offered_to_system_turns(&program, "ceiling").await; - let ceiling: BTreeSet = profile_ceiling().into_iter().map(str::to_owned).collect(); // `recall` (a session's evicted tool outputs) is registered only on // octos's session-actor turns, not on UI Protocol turns. let own: BTreeSet = SYSTEM_AGENT_TOOLS.iter().filter(|t| **t != "recall") .map(|t| t.to_string()).collect(); for (how, offered) in [("stdio", &stdio), ("Talk to Octos", &host)] { - for never in ["shell", "bash", "exec_command", "write_stdin", "check", "git", "spawn", "delegate", - "peer_handoff", "cron", "configure_tool", "goal_grant"] { - assert!(!offered.contains(never), "{how}: {never} offered: {offered:?}"); + for shell in ["shell", "bash", "exec_command", "write_stdin"] { + assert!(!offered.contains(shell), "{how}: {shell} offered: {offered:?}"); } - assert!(offered.is_subset(&ceiling), "{how}: outside the ceiling: {:?}", offered - &ceiling); assert!(own.is_subset(offered), "{how}: missing its own tools: {:?}", &own - offered); } assert_eq!(stdio, host, "the same set over Talk to Octos"); @@ -572,11 +580,13 @@ async fn a_system_agent_turn_is_offered_nothing_outside_the_ceiling_and_no_shell assert_eq!(external_tools, &stdio & &allowlist, "external clients keep every allowlisted tool the kernel offers"); } -/// ADR 0004 §12, the target: a system-agent turn is offered EXACTLY the -/// system agent's list. Needs a per-session tool list the host can set -/// (octos#2567 item 5); until then the ceiling bounds it (above). +/// ADR 0004 §12, the target, NOT yet enforced: a system-agent turn is +/// offered EXACTLY its grants (its default list; nothing granted here). +/// Needs session-targeted registration and tool lists in octos (octos#2567, +/// reviewer item M1; our item 5); until then the grantable ceiling bounds it +/// (above). #[tokio::test(flavor = "multi_thread")] -#[ignore = "needs octos's per-session host tool list (octos#2567 item 5); today the system agent is bounded by the profile ceiling"] +#[ignore = "needs session-targeted tool lists in octos (octos#2567 M1, our item 5); today the system agent is bounded by the grantable ceiling"] async fn a_system_agent_turn_is_offered_exactly_the_system_agent_tools() { use octosense_kernel::system_tools::SystemAgentTools; use std::collections::BTreeSet;