feat(agentic): workspace root from request body — edit_file scope, trailing-slash fix, conditional state_set
Merge propose/agent-workspace-root-read (Tim's PR #28): - path_within_root now appends '/' to root before prefix check (closes proj_evil bypass) - edit_file in dispatch_tool now checks agent_workspace_root() and resolves path - handle_chat_agentic reads agent_workspace_root from request body, only persists if non-empty - Safety screen preserved after workspace root read (conflict resolved)
This commit is contained in:
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user