diff --git a/chat.el b/chat.el index 6f34ad7..40fd7bb 100644 --- a/chat.el +++ b/chat.el @@ -732,7 +732,8 @@ fn path_within_root(path: String, root: String) -> Bool { return false } if str_starts_with(path, "/") { - return str_starts_with(path, root) + let root_normalized: String = root + "/" + return str_starts_with(path, root_normalized) } return true } @@ -823,12 +824,17 @@ fn dispatch_tool(tool_name: String, tool_input: String) -> String { let path: String = json_get(tool_input, "path") let old_text: String = json_get(tool_input, "old_text") let new_text: String = json_get(tool_input, "new_text") - let content: String = fs_read(path) + let root: String = agent_workspace_root() + if !path_within_root(path, root) { + return json_safe("denied: path is outside the agent workspace root") + } + let resolved: String = resolve_in_root(path, root) + let content: String = fs_read(resolved) if str_eq(content, "") { return json_safe("{\"error\":\"file not found\"}") } let updated: String = str_replace(content, old_text, new_text) - fs_write(path, updated) + fs_write(resolved, updated) return json_safe("{\"ok\":true}") } if str_eq(tool_name, "remember") { @@ -945,6 +951,17 @@ fn handle_chat_agentic(body: String) -> String { return "{\"error\":\"message required\",\"reply\":\"\"}" } + // Workspace scope (#23): the desktop UI sends the user-chosen Agent Workspace root + // on every agentic request. Persist it to state so agent_workspace_root() — and the + // path/command tool guards that read it — confine this turn's file/command tools to + // that subtree. Only set when non-empty: an empty/absent field means the client sent + // no root (or cleared the field), and we must not overwrite a server-configured root + // from NEURON_AGENT_ROOT with an empty string, which would silently un-scope the agent. + let ws_root: String = json_get(body, "agent_workspace_root") + if !str_eq(ws_root, "") { + state_set("agent_workspace_root", ws_root) + } + // L1 safety screen — agentic path must pass the same gate as layered_cycle. // Hard bell: return the crisis response immediately, do not enter the agentic loop. let history: String = state_get("conversation_history") @@ -953,7 +970,7 @@ fn handle_chat_agentic(body: String) -> String { if str_eq(screen_action, "hard_bell") { safety_log_bell("hard", json_get(screen_result, "reason"), str_slice(message, 0, 80)) return "{\"reply\":\"" + json_safe(safety_validate("", "hard_bell")) + "\",\"model\":\"\",\"agentic\":true,\"tools_used\":[]}" - } + let req_model: String = json_get(body, "model") let model: String = if str_eq(req_model, "") { chat_default_model() } else { req_model }