diff --git a/chat.el b/chat.el index 6897a20..776a636 100644 --- a/chat.el +++ b/chat.el @@ -1935,8 +1935,24 @@ fn handle_chat_agentic(body: String) -> String { // 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") + // BUG-LEAK fix (2026-07-16): the root used to live ONLY in the shared key, so any + // request that omitted it INHERITED the previous session's folder (proven: a rootless + // curl session wrote into another session's run folder). Now each session keeps its + // own copy, and every request RE-ASSERTS its own root (possibly empty) into the shared + // key the tool guards read — no session can ever act under another session's root. + // Empty state still falls through to env NEURON_AGENT_ROOT inside + // agent_workspace_root(), so a server-configured root survives unchanged. + // LIMITATION (for review): assumes serialized request handling; true per-call scoping + // means threading session_id through dispatch_tool/classify — deeper change, Will's call. + let sess_for_root: String = json_get(body, "session_id") if !str_eq(ws_root, "") { + if !str_eq(sess_for_root, "") { + state_set("agent_workspace_root_" + sess_for_root, ws_root) + } state_set("agent_workspace_root", ws_root) + } else { + let own_root: String = if str_eq(sess_for_root, "") { "" } else { state_get("agent_workspace_root_" + sess_for_root) } + state_set("agent_workspace_root", own_root) } // L1 safety screen — agentic path must pass the same gate as layered_cycle. @@ -2066,6 +2082,14 @@ fn handle_chat_agentic(body: String) -> String { // Use caller-supplied session_id if provided, otherwise generate a bridge id. let session_id: String = if str_eq(req_session, "") { next_bridge_id() } else { req_session } + // PAUSE-CONTRACT fix (2026-07-16): honor the client's require_approval field — the + // Phase 1c contract ("the soul pauses on EVERY tool; the client's tier gate decides + // what actually prompts") was never implemented engine-side, which made the client's + // Ask autonomy silently inert for builtin sub-escalate tools. Persisted per session + // (set/reset on every request) so the /approve resume path keeps the same behavior + // for the rest of the run. Absent/false = behavior identical to before this fix. + let req_ask_all: String = json_get(body, "require_approval") + state_set("require_approval_" + session_id, if str_eq(req_ask_all, "true") { "true" } else { "" }) // Provider fork: OpenAI-compatible providers (Ollama/OpenAI/Grok/Gemini) take the plain-completion // path (v1, no tools); everything else stays on the Anthropic agentic loop (the default). let use_openai: Bool = !str_eq(llm_base_url(), "") && str_eq(llm_wire_format(), "openai") @@ -2126,6 +2150,12 @@ fn handle_chat_agentic(body: String) -> String { fn agentic_loop(session_id: String, model: String, safe_sys: String, tools_json: String, messages_in: String, h: Map, tools_log_in: String) -> String { let api_url: String = "https://api.anthropic.com/v1/messages" + // PAUSE-CONTRACT fix (2026-07-16): when the client asked to approve every action + // (require_approval on the request, persisted per session), EVERY tool turn bridges — + // the client's tier gate decides what actually prompts vs auto-continues. Read from + // session state so the /approve resume re-entry keeps the same behavior mid-run. + let ask_all: Bool = !str_eq(session_id, "") && str_eq(state_get("require_approval_" + session_id), "true") + let messages: String = messages_in let final_text: String = "" let tools_log: String = tools_log_in @@ -2212,7 +2242,10 @@ fn agentic_loop(session_id: String, model: String, safe_sys: String, tools_json: // confirm). Escalated calls suspend to the client's consent flow; the // /approve round-trip is the only path that executes them. let risk_tier: String = if is_tool_turn { classify_tool_risk(tool_name, tool_input) } else { "" } - let needs_bridge: Bool = is_tool_turn && (str_eq(risk_tier, "escalate") || (!is_builtin_tool(tool_name) && !is_always_allowed)) + // PAUSE-CONTRACT fix (2026-07-16): ask_all bridges EVERYTHING — stricter only. + // Escalate keeps its unconditional bridge; "always allow" shortcuts never apply + // under ask_all (the client owns its own standing grants at its tier gate). + let needs_bridge: Bool = is_tool_turn && (ask_all || str_eq(risk_tier, "escalate") || (!is_builtin_tool(tool_name) && !is_always_allowed)) // Built-in tools dispatch locally; bridged tools yield "" (never sent upstream). let tool_result_raw: String = if is_tool_turn && !needs_bridge { dispatch_tool(tool_name, tool_input) } else { "" } @@ -2352,6 +2385,10 @@ fn agentic_resume(session_id: String, tool_use_id: String, content: String) -> S if str_eq(blob, "") { return "{\"error\":\"unknown session_id\",\"reply\":\"\"}" } + // BUG-LEAK fix (2026-07-16): re-assert THIS session's own workspace root before the + // loop continues — a resume must never run under whatever root the last unrelated + // request happened to leave in the shared key. + state_set("agent_workspace_root", state_get("agent_workspace_root_" + session_id)) let model: String = json_get(blob, "model") let safe_sys: String = json_get(blob, "safe_sys") diff --git a/sessions.el b/sessions.el index 2f77980..6e31343 100644 --- a/sessions.el +++ b/sessions.el @@ -677,6 +677,11 @@ fn handle_session_approve(session_id: String, body: String) -> String { // path for all sessions created through handle_chat_agentic / agentic_loop. let bridge_blob: String = state_get("mcp_bridge:" + session_id) if !str_eq(bridge_blob, "") { + // BUG-LEAK fix (2026-07-16): the approved tool executes below via dispatch_tool, + // whose path/command guards read the shared workspace-root key. Re-assert THIS + // session's own root first — an approval must never execute under whatever root + // the last unrelated request left behind. + state_set("agent_workspace_root", state_get("agent_workspace_root_" + session_id)) // For "always": record tool_name in the always-allow list before resuming. // The tool_name is not stored in the bridge blob (only tool_use_id is). // Accept it from the body so the client can pass it along.