From 1b83b18c39cb19396736e1e1dc96b2ce74209bf4 Mon Sep 17 00:00:00 2001 From: Tim Lingo <1timlingo@gmail.com> Date: Fri, 19 Jun 2026 19:56:20 -0500 Subject: [PATCH 1/2] propose(agentic): read agent_workspace_root from request body and persist to state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the UI<->soul contract for #23 (scope file/command tools to an agent workspace root). #23 made the tools read state_get("agent_workspace_root"), but nothing set that key from the desktop UI, so the agent panel's Workspace Folder was cosmetic and tools ran unscoped (default-allow). This reads the root the UI now sends on each agentic request and state_sets it before tool dispatch, so agent_workspace_root() picks it up for the turn. Minimal + pattern-matching (same json_get/state_set shape used throughout chat.el). Empty body field => unscoped (backward-compatible) and preserves the env fallback. FOR WILL'S REVIEW — do not merge without sign-off: - Ownership model: set state from body each turn (so clearing the folder un-scopes) vs. only-when-nonempty. Flagged inline. - Pairs with neuron-ui PR #32 (ChatRequest.agentWorkspaceRoot). Co-Authored-By: Claude Opus 4.8 (1M context) --- chat.el | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/chat.el b/chat.el index 913259d..6813b64 100644 --- a/chat.el +++ b/chat.el @@ -631,6 +631,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. The UI is the source of truth per request: empty means unscoped (the + // backward-compatible default), and it also lets agent_workspace_root() fall through + // to the NEURON_AGENT_ROOT env when no root is sent. FLAGGED FOR REVIEW: setting + // state from the body each turn (vs. only-when-nonempty) so clearing the folder in + // the UI un-scopes — confirm this is the intended ownership model. + let ws_root: String = json_get(body, "agent_workspace_root") + state_set("agent_workspace_root", ws_root) + let req_model: String = json_get(body, "model") let model: String = if str_eq(req_model, "") { chat_default_model() } else { req_model } From dcf050ee3c018bb7410b53171df28d55ff56859e Mon Sep 17 00:00:00 2001 From: Will Anderson Date: Mon, 22 Jun 2026 11:46:44 -0500 Subject: [PATCH 2/2] =?UTF-8?q?fix(agentic):=20workspace=20root=20security?= =?UTF-8?q?=20=E2=80=94=20edit=5Ffile=20scoping,=20trailing-slash=20normal?= =?UTF-8?q?ization,=20conditional=20state=5Fset?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- chat.el | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/chat.el b/chat.el index 6813b64..977e8b6 100644 --- a/chat.el +++ b/chat.el @@ -418,7 +418,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 } @@ -509,12 +510,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") { @@ -634,13 +640,13 @@ fn handle_chat_agentic(body: String) -> String { // 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. The UI is the source of truth per request: empty means unscoped (the - // backward-compatible default), and it also lets agent_workspace_root() fall through - // to the NEURON_AGENT_ROOT env when no root is sent. FLAGGED FOR REVIEW: setting - // state from the body each turn (vs. only-when-nonempty) so clearing the folder in - // the UI un-scopes — confirm this is the intended ownership model. + // 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") - state_set("agent_workspace_root", ws_root) + if !str_eq(ws_root, "") { + state_set("agent_workspace_root", ws_root) + } let req_model: String = json_get(body, "model") let model: String = if str_eq(req_model, "") { chat_default_model() } else { req_model }