fix(agent): close approval continuation gaps

This commit is contained in:
RaresKeY
2026-08-15 06:14:37 +00:00
parent fd50561af6
commit 58b2a4bfa9
24 changed files with 654 additions and 47 deletions
+8 -2
View File
@@ -64,7 +64,10 @@ async def chat_with_model(content: str, session_id: Optional[str] = None, owner:
return {"model": model, "response": response}
except Exception as e:
logger.error(f"chat_with_model failed: {e}")
return {"error": f"Failed to get response from {model_spec}: {e}"}
return {
"error": f"Failed to get response from {model_spec}: {e}",
"untrusted_content": True,
}
async def ask_teacher(content: str, session_id: Optional[str] = None, owner: Optional[str] = None) -> Dict:
@@ -110,7 +113,10 @@ async def ask_teacher(content: str, session_id: Optional[str] = None, owner: Opt
return {"model": model, "response": response, "teacher": True}
except Exception as e:
logger.error(f"ask_teacher failed: {e}")
return {"error": f"Teacher call failed ({model_spec}): {e}"}
return {
"error": f"Teacher call failed ({model_spec}): {e}",
"untrusted_content": True,
}
async def list_models(content: str, session_id: Optional[str] = None, owner: Optional[str] = None) -> Dict:
+4 -1
View File
@@ -240,7 +240,10 @@ async def send_to_session(content: str, session_id: Optional[str] = None, owner:
}
except Exception as e:
logger.error(f"send_to_session failed: {e}")
return {"error": f"Failed to send to session: {e}"}
return {
"error": f"Failed to send to session: {e}",
"untrusted_content": True,
}
async def manage_session(content: str, session_id: Optional[str] = None, owner: Optional[str] = None) -> Dict:
"""Manage sessions: rename, archive, delete, important, truncate, fork.
+6 -1
View File
@@ -66,6 +66,7 @@ class WebSearchTool:
return {
"error": f"web_search failed: {type(e).__name__}: {str(e) or 'no details'}",
"exit_code": 1,
"untrusted_content": True,
}
if progress_cb:
await progress_cb({
@@ -136,7 +137,11 @@ class WebFetchTool:
if not text:
if err:
return {"error": f"web_fetch: {url}: {err}", "exit_code": 1}
return {
"error": f"web_fetch: {url}: {err}",
"exit_code": 1,
"untrusted_content": True,
}
return {"error": f"web_fetch: {url}: no readable text content (not HTML, or the page needs JS/login)", "exit_code": 1}
# Tell the model when the download budget cut the body short and how
+24 -6
View File
@@ -324,7 +324,10 @@ async def do_pipeline(content: str, session_id: Optional[str] = None, owner: Opt
}
except Exception as e:
logger.error(f"pipeline failed at step {len(step_outputs) + 1}: {e}")
return {"error": f"Pipeline failed at step {len(step_outputs) + 1}: {e}"}
return {
"error": f"Pipeline failed at step {len(step_outputs) + 1}: {e}",
"untrusted_content": True,
}
# ---------------------------------------------------------------------------
@@ -1089,7 +1092,10 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
error_text = err_json.get("error", {}).get("message", error_text) if isinstance(err_json.get("error"), dict) else str(err_json.get("error", error_text))
except Exception:
pass
return {"error": f"Image generation failed ({resp.status_code}): {error_text}"}
return {
"error": f"Image generation failed ({resp.status_code}): {error_text}",
"untrusted_content": True,
}
data = resp.json()
images = data.get("data", [])
@@ -1173,7 +1179,10 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
except httpx.TimeoutException:
return {"error": "Image generation timed out (300s). The model may be overloaded — try again or use quality=low."}
except Exception as e:
return {"error": f"Image generation error: {str(e)}"}
return {
"error": f"Image generation error: {str(e)}",
"untrusted_content": True,
}
async def do_edit_image(
@@ -1310,7 +1319,10 @@ async def do_edit_image(
error_text = err_json.get("detail") or err_json.get("error") or error_text
except Exception:
pass
return {"error": f"Image edit fallback failed ({fallback_resp.status_code}): {error_text}"}
return {
"error": f"Image edit fallback failed ({fallback_resp.status_code}): {error_text}",
"untrusted_content": True,
}
fallback_data = fallback_resp.json()
image_b64 = fallback_data.get("image")
if not image_b64:
@@ -1394,7 +1406,10 @@ async def do_edit_image(
"model for attached-image prompts."
)
}
return {"error": f"Image edit failed ({resp.status_code}): {error_text}"}
return {
"error": f"Image edit failed ({resp.status_code}): {error_text}",
"untrusted_content": True,
}
data = resp.json()
images = data.get("data", [])
@@ -1434,7 +1449,10 @@ async def do_edit_image(
except httpx.TimeoutException:
return {"error": "Image edit timed out. The model may still be loading or overloaded."}
except Exception as e:
return {"error": f"Image edit error: {str(e)}"}
return {
"error": f"Image edit error: {str(e)}",
"untrusted_content": True,
}
# ---------------------------------------------------------------------------
+8 -2
View File
@@ -73,13 +73,19 @@ async def _drain_agent(sess, messages):
round_num = d.get("round", round_num)
elif d.get("type") == "tool_output":
# Mirror the live chat's tool_event shape (chat_routes / chatRenderer).
tool_events.append({
tool_event = {
"round": round_num,
"tool": d.get("tool"),
"command": d.get("command"),
"output": d.get("output"),
"exit_code": d.get("exit_code"),
})
}
if isinstance(d.get("ask_user"), dict):
# Preserve exact-approval cards from a tainted background-job
# continuation so the user can authorize the sealed action on
# the next foreground turn instead of losing it headlessly.
tool_event["ask_user"] = d["ask_user"]
tool_events.append(tool_event)
return full, tool_events
+2
View File
@@ -530,6 +530,8 @@ class McpManager:
"stderr": output if is_error else "",
"exit_code": 1 if is_error else 0,
}
if is_error and output:
result_dict["untrusted_content"] = True
if images:
result_dict["images"] = images
return result_dict
+8 -3
View File
@@ -285,15 +285,20 @@ class ToolApprovalStore:
)
with self._lock:
self._purge_expired_locked(now)
# The UI exposes one pending card per chat. Supersede any older
# action for the same owner/session so stale history cannot retain
# parallel grants and the in-memory registry stays bounded.
# The chat UI exposes one pending card per session, so supersede an
# older action there. Headless/manual-test callers use an empty
# session id; keep independent origin runs separate so two skill
# tests owned by the same user cannot invalidate each other.
superseded = [
approval_id
for approval_id, existing in self._pending.items()
if (
existing.owner == pending.owner
and existing.session_id == pending.session_id
and (
bool(pending.session_id)
or existing.origin_run_id == pending.origin_run_id
)
)
]
for approval_id in superseded:
+17 -4
View File
@@ -457,9 +457,11 @@ def tool_result_should_arm_gate(
) -> bool:
"""Return whether a result introduced non-system content to the model.
A content-free transport or validation failure does not change authority.
Producers set ``untrusted_content`` when a failed response still carries a
remote/private body, so HTTP status alone cannot launder that body.
A blocked/approval placeholder and a genuinely content-free failure do not
change authority. Once a non-system tool returns text or structured data
that will be folded into model context, however, failure status cannot make
that payload trusted: MCP ``isError`` text, provider exception messages,
and HTTP error bodies are all attacker-controlled input surfaces.
"""
if not isinstance(result, dict):
return False
@@ -468,7 +470,18 @@ def tool_result_should_arm_gate(
capabilities = capabilities_for_action(tool_name, content)
if capabilities.result_integrity is ResultIntegrity.SYSTEM:
return False
return tool_result_is_successful(result) or result.get("untrusted_content") is True
if tool_result_is_successful(result) or result.get("untrusted_content") is True:
return True
model_visible_keys = (
"stderr",
"stdout",
"output",
"content",
"response",
"results",
"images",
)
return any(result.get(key) not in (None, "", [], {}, ()) for key in model_visible_keys)
POST_EXTERNAL_BLOCKED_EFFECTS = frozenset(
+10 -2
View File
@@ -954,7 +954,11 @@ async def _cookbook_kill_session(session_id: str, *, remote_host: str = "",
resp = await client.post(f"{_INTERNAL_BASE}/api/shell/exec",
json={"command": cmd}, headers=headers)
if resp.status_code >= 400:
return {"error": f"shell/exec returned HTTP {resp.status_code}: {resp.text[:200]}", "exit_code": 1}
return {
"error": f"shell/exec returned HTTP {resp.status_code}: {resp.text[:200]}",
"exit_code": 1,
"untrusted_content": True,
}
try:
data = resp.json()
except Exception:
@@ -1083,7 +1087,11 @@ async def do_tail_serve_output(content: str, owner: Optional[str] = None) -> Dic
resp = await client.post(f"{_INTERNAL_BASE}/api/shell/exec",
json={"command": cmd}, headers=headers)
if resp.status_code >= 400:
return {"error": f"shell/exec returned HTTP {resp.status_code}: {resp.text[:200]}", "exit_code": 1}
return {
"error": f"shell/exec returned HTTP {resp.status_code}: {resp.text[:200]}",
"exit_code": 1,
"untrusted_content": True,
}
data = resp.json() if resp.content else {}
output_text = (data.get("stdout") or "").strip()
stderr_text = (data.get("stderr") or "").strip()
+5 -1
View File
@@ -123,7 +123,11 @@ async def do_trigger_research(content: str, owner: Optional[str] = None) -> Dict
resp = await client.post(f"{_INTERNAL_BASE}/api/research/start",
json=payload, headers=_internal_headers(owner))
if resp.status_code >= 400:
return {"error": f"research/start returned HTTP {resp.status_code}: {resp.text[:200]}", "exit_code": 1}
return {
"error": f"research/start returned HTTP {resp.status_code}: {resp.text[:200]}",
"exit_code": 1,
"untrusted_content": True,
}
data = resp.json()
sid = data.get("session_id", "?")
return {