Compare commits

..

2 Commits

Author SHA1 Message Date
will.anderson dcf050ee3c fix(agentic): workspace root security — edit_file scoping, trailing-slash normalization, conditional state_set
Neuron Soul CI / build (pull_request) Has been cancelled
2026-06-22 11:46:44 -05:00
Tim Lingo 1b83b18c39 propose(agentic): read agent_workspace_root from request body and persist to state
Neuron Soul CI / build (pull_request) Successful in 7m45s
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) <noreply@anthropic.com>
2026-06-19 19:56:20 -05:00
5 changed files with 31 additions and 66 deletions
+1 -4
View File
@@ -23,14 +23,11 @@ fn ise_post(content: String) -> Void {
let ise_url: String = env("SOUL_ISE_URL")
let engram_url: String = if str_eq(ise_url, "") { state_get("soul_engram_url") } else { ise_url }
if str_eq(engram_url, "") {
let local_id: String = engram_node_full(
let discard: String = engram_node_full(
content, "InternalStateEvent", "state-event",
el_from_float(0.3), el_from_float(0.3), el_from_float(0.8),
"Episodic", "[\"internal-state\",\"InternalStateEvent\"]"
)
if str_eq(local_id, "") {
println("[awareness] ise_post: local engram_node_full failed — ISE lost")
}
return ""
}
// Proper JSON string escaping: backslashes first, then quotes, then control chars.
+24 -34
View File
@@ -130,14 +130,11 @@ fn conv_history_persist(hist: String) -> Void {
if str_eq(hist, "[]") { return "" }
let ts: Int = time_now()
let tags: String = "[\"conv-history\",\"persistent\"]"
let node_id: String = engram_node_full(
let discard: String = engram_node_full(
hist, "Conversation", "conv:history",
el_from_float(0.7), el_from_float(0.8), el_from_float(0.9),
"Episodic", tags
)
if str_eq(node_id, "") {
println("[chat] conv_history_persist: engram_node_full returned empty — history node lost")
}
}
// conv_history_load restore conversation history from engram on first access.
@@ -421,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
}
@@ -512,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,27 +637,23 @@ 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)
}
let req_model: String = json_get(body, "model")
let model: String = if str_eq(req_model, "") { chat_default_model() } else { req_model }
// Thread-aware activation: same logic as handle_chat.
// Use the session's or global history to anchor short messages to the thread.
let req_session: String = json_get(body, "session_id")
// ISSUE #6/#7: validate that the session_id actually exists before proceeding.
// Without this check the loop silently treats any unknown/fabricated session_id
// as a fresh session history loads as empty and no error is returned to the caller.
// Only validate when a session_id is explicitly provided; anonymous calls
// (no session_id) continue to work for backward compatibility.
let session_valid: Bool = if str_eq(req_session, "") {
true
} else {
!str_contains(session_get(req_session), "\"error\"")
}
if !session_valid {
return "{\"error\":\"session not found\",\"session_id\":\"" + req_session + "\",\"reply\":\"\"}"
}
let hist_key: String = if str_eq(req_session, "") { "conv_history" } else { "session_hist_" + req_session }
let agentic_hist: String = state_get(hist_key)
let agentic_hist_len: Int = if str_eq(agentic_hist, "") { 0 } else { json_array_len(agentic_hist) }
@@ -1072,19 +1071,13 @@ fn handle_dharma_room_turn(body: String) -> String {
// engram_node(content, "episodic", ...) which wrongly put a TIER into the node_type
// slot that's why nodes showed node_type="episodic". Use the full, correct contract.)
let utterance_tags: String = "[\"soul-utterance\",\"episodic\"]"
let utterance_id: String = engram_node_full(
let discard_id: String = engram_node_full(
clean_response, "Conversation", "soul:utterance",
el_from_float(0.6), el_from_float(0.6), el_from_float(0.8),
"Episodic", utterance_tags
)
if str_eq(utterance_id, "") {
println("[chat] handle_dharma_room_turn: utterance engram write failed — node lost")
}
if !str_eq(snap_path, "") {
let save_result: String = engram_save(snap_path)
if str_eq(save_result, "") {
println("[chat] handle_dharma_room_turn: engram_save failed for " + snap_path)
}
let discard_save: String = engram_save(snap_path)
}
let safe_response: String = json_safe(clean_response)
@@ -1166,7 +1159,7 @@ fn auto_persist(req: String, resp: String) -> Void {
+ ",\"label\":\"chat:" + ts_str + "\"}"
let tags: String = "[\"Conversation\",\"chat\",\"timestamped\"]"
let persist_id: String = engram_node_full(
engram_node_full(
content,
"Conversation",
"chat:" + ts_str,
@@ -1176,9 +1169,6 @@ fn auto_persist(req: String, resp: String) -> Void {
"Episodic",
tags
)
if str_eq(persist_id, "") {
println("[chat] auto_persist: engram_node_full returned empty — conversation node lost (ts=" + ts_str + ")")
}
}
// strengthen_chat_nodes strengthen the engram nodes that were activated during a chat.
+2 -8
View File
@@ -46,10 +46,7 @@ fn mem_consolidate() -> String {
}
fn mem_save(path: String) -> Void {
let save_result: String = engram_save(path)
if str_eq(save_result, "") {
println("[memory] mem_save: engram_save failed for " + path + " — snapshot may be incomplete")
}
engram_save(path)
}
fn mem_load(path: String) -> Void {
@@ -79,14 +76,11 @@ fn mem_boot_count_inc() -> Int {
let next: Int = current + 1
let content: String = "soul:boot_count:" + int_to_str(next)
let tags: String = "[\"soul-meta\",\"boot-counter\"]"
let boot_node_id: String = engram_node_full(
let discard: String = engram_node_full(
content, "Memory", "soul:boot_count",
el_from_float(0.9), el_from_float(0.9), el_from_float(1.0),
"Canonical", tags
)
if str_eq(boot_node_id, "") {
println("[memory] mem_boot_count_inc: engram write failed — boot counter node lost (count=" + int_to_str(next) + ")")
}
return next
}
+2 -10
View File
@@ -400,7 +400,6 @@ fn handle_api_log_state_event(body: String) -> String {
let id: String = engram_node_full(parts, "InternalStateEvent", "state-event:manual",
el_from_float(0.85), el_from_float(0.85), el_from_float(0.9),
"Episodic", tags)
if !api_persisted(id) { return api_not_persisted(id) }
return "{\"ok\":true,\"id\":\"" + id + "\",\"boot\":\"" + boot + "\"}"
}
@@ -453,7 +452,6 @@ fn handle_api_tune_config(body: String) -> String {
let id: String = engram_node_full(content, "ConfigEntry", key,
el_from_float(0.85), el_from_float(0.85), el_from_float(0.9),
"Canonical", tags)
if !api_persisted(id) { return api_not_persisted(id) }
return "{\"ok\":true,\"key\":\"" + key + "\",\"value\":\"" + value + "\",\"id\":\"" + id + "\"}"
}
@@ -653,23 +651,17 @@ fn handle_api_consolidate(body: String) -> String {
let summary: String = json_get(body, "summary")
let snap: String = state_get("soul_snapshot_path")
if !str_eq(snap, "") {
let save_result: String = engram_save(snap)
if str_eq(save_result, "") {
println("[api] consolidate: engram_save failed for " + snap + " — snapshot may be out of sync")
}
engram_save(snap)
}
if !str_eq(summary, "") {
let safe_summary: String = str_replace(summary, "\"", "'")
let tags: String = "[\"SessionSummary\",\"consolidate\"]"
let summary_id: String = engram_node_full(
let discard: String = engram_node_full(
"[session-summary] " + safe_summary,
"SessionSummary", "session:summary",
el_from_float(0.7), el_from_float(0.7), el_from_float(0.9),
"Episodic", tags
)
if str_eq(summary_id, "") {
println("[api] consolidate: session summary engram write failed — summary node lost")
}
}
return "{\"ok\":true,\"snapshot\":\"" + snap + "\"}"
}
+2 -10
View File
@@ -212,13 +212,8 @@ fn seed_persona_from_env() -> Void {
let h: Map = {}
map_set(h, "Content-Type", "application/json")
let resp: String = http_post_with_headers(engram_url + "/api/nodes", body, h)
// Check for empty response (timeout/network error), explicit error, or missing id.
if str_eq(resp, "") {
println("[soul] persona HTTP write-back failed: empty response (timeout or network error) — in-memory only this session")
} else if str_contains(resp, "\"error\"") {
if str_contains(resp, "\"error\"") {
println("[soul] persona HTTP write-back failed (in-memory only this session): " + resp)
} else if !str_contains(resp, "\"id\"") {
println("[soul] persona HTTP write-back: unexpected response (no id field) — in-memory only this session: " + resp)
} else {
println("[soul] persona persisted to HTTP engram at " + engram_url)
}
@@ -251,14 +246,11 @@ fn emit_session_start_event() -> Void {
+ ",\"ts\":" + int_to_str(ts) + "}"
let tags: String = "[\"internal-state\",\"session-start\",\"InternalStateEvent\"]"
let session_event_id: String = engram_node_full(
let discard: String = engram_node_full(
payload, "InternalStateEvent", "session-start",
el_from_float(0.9), el_from_float(0.9), el_from_float(1.0),
"Episodic", tags
)
if str_eq(session_event_id, "") {
println("[soul] emit_session_start_event: engram write failed — session-start event lost")
}
println("[soul] session-start event logged (boot=" + boot_num + " nodes=" + int_to_str(node_ct) + " edges=" + int_to_str(edge_ct) + ")")
}