diff --git a/core/models.py b/core/models.py
index 56f05dc4e..21570b7c5 100644
--- a/core/models.py
+++ b/core/models.py
@@ -8,6 +8,11 @@ These are simple datacontainers. All persistence is handled by SessionManager.
from dataclasses import dataclass
from typing import Dict, List, Any, Optional, TYPE_CHECKING
+from src.tool_approval_scopes import (
+ CHAT_SESSION_APPROVAL_CONTEXT_MARKER,
+ CHAT_SESSION_APPROVAL_DECISION,
+)
+
if TYPE_CHECKING:
from .session_manager import SessionManager
@@ -31,6 +36,35 @@ set_session_manager = set_session_manager_instance
get_session_manager = get_session_manager_instance
+def _history_grants_chat_session_approval(
+ history: List["ChatMessage"],
+ session_id: str,
+) -> bool:
+ """Return whether this exact chat has a resolved session-scope grant."""
+
+ expected_session = str(session_id or "")
+ if not expected_session:
+ return False
+ for message in reversed(history or []):
+ metadata = getattr(message, "metadata", None)
+ if not isinstance(metadata, dict):
+ continue
+ tool_events = metadata.get("tool_events")
+ if not isinstance(tool_events, list):
+ continue
+ for event in reversed(tool_events):
+ ask_user = event.get("ask_user") if isinstance(event, dict) else None
+ if not isinstance(ask_user, dict):
+ continue
+ if (
+ ask_user.get("kind") == "tool_approval"
+ and ask_user.get("resolved") == CHAT_SESSION_APPROVAL_DECISION
+ and str(ask_user.get("session_id") or "") == expected_session
+ ):
+ return True
+ return False
+
+
@dataclass
class ChatMessage:
"""A single chat message."""
@@ -116,11 +150,27 @@ class Session:
the model. Display/history-load paths use the raw ``history`` and are
unaffected.
"""
- return [
+ messages = [
msg.to_dict()
for msg in self.history
if (msg.metadata or {}).get("source") != "slash"
]
+ if not _history_grants_chat_session_approval(self.history, self.id):
+ return messages
+
+ # Keep the grant close to the latest user request so route-neutral
+ # compaction/trimming preserves it. Copy the metadata instead of
+ # mutating the durable transcript object.
+ for index in range(len(messages) - 1, -1, -1):
+ if messages[index].get("role") != "user":
+ continue
+ message = dict(messages[index])
+ metadata = dict(message.get("metadata") or {})
+ metadata[CHAT_SESSION_APPROVAL_CONTEXT_MARKER] = True
+ message["metadata"] = metadata
+ messages[index] = message
+ break
+ return messages
def get(self, key: str, default=None):
"""Dict-like access for compatibility."""
diff --git a/routes/chat_helpers.py b/routes/chat_helpers.py
index efde7bd79..3d87da2b0 100644
--- a/routes/chat_helpers.py
+++ b/routes/chat_helpers.py
@@ -624,6 +624,8 @@ async def build_chat_context(
agent_mode: bool = False,
allow_tool_preprocessing: bool = True,
defer_context_shaping: bool = False,
+ continuation_context_message: str | None = None,
+ persist_user_message: bool = True,
) -> ChatContext:
"""Build the full context (preface + messages) for an LLM call.
@@ -647,14 +649,14 @@ async def build_chat_context(
# Add user message to history. Nobody/incognito uses a request-local
# transcript store instead of session history so stale saved chats cannot
# bleed into context and the turn is not persisted.
- if incognito:
+ if persist_user_message and incognito:
user_meta = {"attachments": preprocessed.attachment_meta} if preprocessed.attachment_meta else None
_append_incognito_message(session_id, "user", preprocessed.user_content, user_meta)
- else:
+ elif persist_user_message:
add_user_message(sess, chat_handler, preprocessed, incognito=False)
# Fire events
- if not incognito:
+ if persist_user_message and not incognito:
fire_message_event(request, webhook_manager, session_id, sess, message, compare_mode)
# Resolve owner-scoped prefs/context. Browser requests keep the cookie user;
@@ -666,7 +668,12 @@ async def build_chat_context(
getattr(chat_handler, "upload_handler", None),
getattr(sess, "owner", None),
)
- casual_low_signal = _is_casual_low_signal(message)
+ context_message = (
+ str(continuation_context_message).strip()
+ if continuation_context_message
+ else message
+ )
+ casual_low_signal = _is_casual_low_signal(context_message)
# Memory enabled?
mem_enabled = not incognito and not no_memory and uprefs.get("memory_enabled", True)
@@ -703,7 +710,15 @@ async def build_chat_context(
# Build context preface
# The stream path uses enhanced_message (with CoT/preprocessing applied),
# the sync path uses text_for_context.
- _ctx_msg = preprocessed.enhanced_message if use_enhanced_message else preprocessed.text_for_context
+ _ctx_msg = (
+ context_message
+ if continuation_context_message
+ else (
+ preprocessed.enhanced_message
+ if use_enhanced_message
+ else preprocessed.text_for_context
+ )
+ )
_preface_kwargs = dict(
message=_ctx_msg,
session=sess,
diff --git a/routes/chat_routes.py b/routes/chat_routes.py
index 0b181796f..fb080f77b 100644
--- a/routes/chat_routes.py
+++ b/routes/chat_routes.py
@@ -89,6 +89,65 @@ def _stream_failure_status(chunk: str) -> Optional[int]:
return None
+def _mark_tool_approval_resolved(sess, approval_id: Any, decision: Any) -> bool:
+ """Persist a consumed approval decision on its existing tool event."""
+
+ approval_key = str(approval_id or "")
+ normalized_decision = str(decision or "").strip().lower()
+ if not approval_key or normalized_decision not in {"approve", "approve_task", "deny"}:
+ return False
+
+ message_id = None
+ resolved_metadata = None
+ for item in reversed(getattr(sess, "history", []) or []):
+ metadata = getattr(item, "metadata", None)
+ if not isinstance(metadata, dict):
+ continue
+ tool_events = metadata.get("tool_events")
+ if not isinstance(tool_events, list):
+ continue
+ for event in reversed(tool_events):
+ ask_user = event.get("ask_user") if isinstance(event, dict) else None
+ if not isinstance(ask_user, dict):
+ continue
+ if str(ask_user.get("approval_id") or "") != approval_key:
+ continue
+ ask_user["resolved"] = normalized_decision
+ message_id = metadata.get("_db_id")
+ resolved_metadata = {
+ key: value for key, value in metadata.items() if key != "_db_id"
+ }
+ break
+ if resolved_metadata is not None:
+ break
+
+ if resolved_metadata is None or not message_id:
+ return False
+
+ db = SessionLocal()
+ try:
+ db_message = db.query(DBChatMessage).filter(
+ DBChatMessage.id == message_id,
+ DBChatMessage.session_id == str(getattr(sess, "id", "")),
+ ).first()
+ if db_message is None:
+ return False
+ db_message.meta_data = json.dumps(resolved_metadata)
+ db.commit()
+ return True
+ except Exception:
+ db.rollback()
+ logger.exception("Failed to persist tool approval resolution")
+ return False
+ finally:
+ db.close()
+
+
+async def _tool_approval_resolution_stream(decision: str) -> AsyncGenerator[str, None]:
+ yield f"data: {json.dumps({'type': 'tool_approval_resolved', 'decision': decision})}\n\n"
+ yield "data: [DONE]\n\n"
+
+
def _chat_candidate_request_factory(
messages,
fallback_context_length: int = 0,
@@ -917,6 +976,7 @@ def setup_chat_routes(
exact_tool_approval = None
pending_tool_approval = None
retired_tool_approval_taint = False
+ external_untrusted_context_seen = False
tool_approval_continuation = False
# Workspace: confine the agent's file/shell tools to this folder.
workspace, workspace_rejected = _resolve_request_workspace(
@@ -1050,14 +1110,14 @@ def setup_chat_routes(
)
try:
- # Attachment-only sends: skip the message-required check when the
- # user has attached one or more files (the attachment IS the action).
+ # Attachment-only sends and approval controls may omit message text.
_has_atts = (
bool(body and isinstance(body.get("attachments"), list) and body["attachments"])
or bool(form_data.get("attachments"))
)
message, session = coerce_message_and_session(
- body, message, session, session_manager, allow_empty=_has_atts,
+ body, message, session, session_manager,
+ allow_empty=(_has_atts or bool(tool_approval_id)),
)
# Verify ownership AFTER coerce (which may resolve a default session)
# but BEFORE loading. Prevents cross-user session hijack.
@@ -1076,8 +1136,14 @@ def setup_chat_routes(
409,
"This tool approval is invalid, expired, or belongs to another thread.",
)
+ pending_taint = bool(
+ pending_tool_approval.external_untrusted_context_seen
+ )
+ external_untrusted_context_seen = (
+ external_untrusted_context_seen or pending_taint
+ )
decision = str(tool_approval_decision or "").strip().lower()
- if decision not in {"approve", "deny"}:
+ if decision not in {"approve", "approve_task", "deny"}:
raise HTTPException(400, "Invalid tool approval decision.")
if plan_mode:
raise HTTPException(
@@ -1091,37 +1157,46 @@ def setup_chat_routes(
session_id=session,
)
tool_approval_continuation = True
- if decision == "approve" and exact_tool_approval is None:
+ if (
+ decision in {"approve", "approve_task"}
+ and exact_tool_approval is None
+ ):
raise HTTPException(
409,
"This tool approval could not be consumed.",
)
- if decision == "approve":
- message = (
- f"Approved the exact {pending_tool_approval.tool_name} action "
- "shown above once."
+ if not _mark_tool_approval_resolved(
+ sess,
+ tool_approval_id,
+ decision,
+ ):
+ logger.warning(
+ "Tool approval %s was consumed but its persisted card could not be marked resolved",
+ tool_approval_id,
)
- # The sealed server record, not mutable composer state,
- # restores the original action workspace.
- workspace = pending_tool_approval.workspace or None
- workspace_rejected = None
- if pending_tool_approval.document_id:
- active_doc_id = pending_tool_approval.document_id
- # The approval click is the per-turn opt-in for this exact
- # sealed action. Restore only the coarse request toggle
- # that would otherwise disable it because the synthetic
- # "Approved…" message no longer resembles the original
- # shell/web request. Current privilege, global-disable,
- # incognito, compare, and tool-policy gates still run.
- if pending_tool_approval.tool_name == "bash":
- allow_bash = "true"
- if pending_tool_approval.tool_name in WEB_TOOL_NAMES:
- allow_web_search = "true"
- _search_enabled = True
- else:
- message = (
- f"Denied the {pending_tool_approval.tool_name} action shown above."
+ if decision == "deny":
+ return StreamingResponse(
+ _tool_approval_resolution_stream(decision),
+ media_type="text/event-stream",
)
+ # Approval is a control-plane continuation, not a new user turn.
+ # Reuse the sealed interrupted request only for internal context,
+ # retrieval, and policy reconstruction; never persist or display it.
+ message = pending_tool_approval.continuation_query
+ # The sealed server record, not mutable composer state,
+ # restores the original action workspace.
+ workspace = pending_tool_approval.workspace or None
+ workspace_rejected = None
+ if pending_tool_approval.document_id:
+ active_doc_id = pending_tool_approval.document_id
+ # Restore only the coarse request toggle needed by the exact
+ # sealed action. Current privilege, global-disable, incognito,
+ # compare, and tool-policy gates still run.
+ if pending_tool_approval.tool_name == "bash":
+ allow_bash = "true"
+ if pending_tool_approval.tool_name in WEB_TOOL_NAMES:
+ allow_web_search = "true"
+ _search_enabled = True
chat_mode = "agent"
else:
# A normal user message supersedes the card that was waiting
@@ -1132,6 +1207,9 @@ def setup_chat_routes(
owner=owner,
session_id=session,
)
+ external_untrusted_context_seen = (
+ external_untrusted_context_seen or retired_tool_approval_taint
+ )
_reconcile_selected_route_from_request(request, sess, session, form_data, owner=owner)
if _clear_orphaned_session_endpoint(sess, owner=owner):
raise HTTPException(400, "Selected model endpoint was removed. Pick another model in Settings.")
@@ -1261,6 +1339,14 @@ def setup_chat_routes(
agent_mode=(chat_mode == "agent"),
allow_tool_preprocessing=allow_tool_preprocessing,
defer_context_shaping=foreground_policy.enabled,
+ continuation_context_message=(
+ pending_tool_approval.continuation_query
+ if exact_tool_approval
+ and pending_tool_approval
+ and pending_tool_approval.continuation_query
+ else None
+ ),
+ persist_user_message=not tool_approval_continuation,
)
_research_flags = {"do": do_research} # Mutable container for generator scope
@@ -1663,7 +1749,11 @@ def setup_chat_routes(
if foreground_policy.enabled
else ctx.messages
)
- messages = _ensure_current_request_is_latest_user(context_source, message)
+ messages = (
+ list(context_source)
+ if tool_approval_continuation
+ else _ensure_current_request_is_latest_user(context_source, message)
+ )
# Auto-compact notification
if ctx.was_compacted:
@@ -2134,7 +2224,10 @@ def setup_chat_routes(
incognito=incognito, compare_mode=compare_mode,
character_name=ctx.preset.character_name,
owner=_user,
- allow_background_extraction=not tool_policy.block_all_tool_calls,
+ allow_background_extraction=(
+ not tool_policy.block_all_tool_calls
+ and not tool_approval_continuation
+ ),
)
_stream_set(session, status="done")
yield chunk
@@ -2223,17 +2316,17 @@ def setup_chat_routes(
plan_mode=plan_mode,
approved_plan=approved_plan or None,
workspace=workspace or None,
+ relevant_tools=(
+ set(pending_tool_approval.selected_tools)
+ if exact_tool_approval
+ and pending_tool_approval
+ and pending_tool_approval.selected_tools
+ else None
+ ),
forced_tools=_forced_tools,
uploaded_files=ctx.uploaded_files,
defer_context_shaping=_foreground_policy.enabled,
- external_untrusted_context_seen=bool(
- retired_tool_approval_taint
- or (
- tool_approval_continuation
- and pending_tool_approval
- and pending_tool_approval.external_untrusted_context_seen
- )
- ),
+ external_untrusted_context_seen=external_untrusted_context_seen,
exact_approval=exact_tool_approval,
):
if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"):
@@ -2401,8 +2494,14 @@ def setup_chat_routes(
agent_tool_calls=_agent_tool_calls,
skills_manager=skills_manager,
owner=_user,
- extract_skills=user_requested_agent,
- allow_background_extraction=not tool_policy.block_all_tool_calls,
+ extract_skills=(
+ user_requested_agent
+ and not tool_approval_continuation
+ ),
+ allow_background_extraction=(
+ not tool_policy.block_all_tool_calls
+ and not tool_approval_continuation
+ ),
)
_stream_set(session, status="done")
yield chunk
diff --git a/routes/skills_routes.py b/routes/skills_routes.py
index befe8445f..4b42835d9 100644
--- a/routes/skills_routes.py
+++ b/routes/skills_routes.py
@@ -1603,6 +1603,9 @@ def setup_skills_routes(skills_manager: SkillsManager) -> APIRouter:
decision=decision,
owner=user,
session_id=None,
+ # The button here says "Allow once" and there is no chat to carry a
+ # scope into, so the gate must re-arm behind the sealed action.
+ allow_continuation=False,
)
if decision == "approve" and exact_approval is None:
diff --git a/src/agent_loop.py b/src/agent_loop.py
index eb1ebe65e..296c0ddce 100644
--- a/src/agent_loop.py
+++ b/src/agent_loop.py
@@ -3460,7 +3460,10 @@ async def stream_agent_loop(
and exact_approval.pending.external_untrusted_context_seen
)
or messages_contain_external_untrusted_context(messages)
- )
+ ),
+ approval_gate_bypassed=bool(
+ exact_approval and exact_approval.allow_remaining_actions
+ ),
)
mcp_mgr = get_mcp_manager()
prep_timings: Dict[str, float] = {}
@@ -5698,6 +5701,16 @@ async def stream_agent_loop(
"policy": "exact_tool_approval_target",
}
else:
+ # The approval click becomes a synthetic user turn. Seal the
+ # actual server-selected candidates now so that continuation
+ # does not lose memory, skills, MCP, documents, or other
+ # ToolIndex/RAG-selected tools by classifying that synthetic text.
+ approval_selected_tools = set(_relevant_tools or ())
+ approval_selected_tools.update(
+ name for name in _tool_names_sent if name
+ )
+ approval_selected_tools.add(block.tool_type)
+ approval_selected_tools.difference_update(disabled_tools)
pending_approval = tool_approval_store.create(
owner=owner,
session_id=session_id,
@@ -5725,6 +5738,8 @@ async def stream_agent_loop(
external_untrusted_context_seen=(
run_security.external_untrusted_context_seen
),
+ selected_tools=approval_selected_tools,
+ continuation_query=_retrieval_query or _last_user,
capabilities=capabilities_for_action(
block.tool_type,
block.content,
diff --git a/src/tool_approval_scopes.py b/src/tool_approval_scopes.py
new file mode 100644
index 000000000..8ff79ac54
--- /dev/null
+++ b/src/tool_approval_scopes.py
@@ -0,0 +1,35 @@
+"""Shared wire values and scope markers for tool approval continuations."""
+
+from __future__ import annotations
+
+from enum import Enum
+
+
+# Keep the existing wire values so the current route and no-build frontend do
+# not need a second protocol migration. ``approve`` no longer means one action;
+# it now selects chat-session scope.
+TASK_APPROVAL_DECISION = "approve_task"
+CHAT_SESSION_APPROVAL_DECISION = "approve"
+DENY_APPROVAL_DECISION = "deny"
+
+# Session.get_context_messages() adds this server-owned marker only when the
+# session history contains a matching, resolved chat-session approval.
+CHAT_SESSION_APPROVAL_CONTEXT_MARKER = "_tool_approval_chat_session_granted"
+
+
+class ToolApprovalScope(str, Enum):
+ # Surfaces without a resumable chat (the skill tester, unattended audits)
+ # keep the original one-use meaning: the sealed action runs and the gate
+ # re-arms immediately for anything after it.
+ SINGLE_ACTION = "single_action"
+ TASK = "task"
+ CHAT_SESSION = "chat_session"
+
+
+def scope_for_decision(decision: object) -> ToolApprovalScope | None:
+ normalized = str(decision or "").strip().lower()
+ if normalized == TASK_APPROVAL_DECISION:
+ return ToolApprovalScope.TASK
+ if normalized == CHAT_SESSION_APPROVAL_DECISION:
+ return ToolApprovalScope.CHAT_SESSION
+ return None
diff --git a/src/tool_approvals.py b/src/tool_approvals.py
index d3707c5f6..bfe352b1c 100644
--- a/src/tool_approvals.py
+++ b/src/tool_approvals.py
@@ -1,8 +1,9 @@
-"""Opaque, exact, one-use approvals for tainted model-requested actions.
+"""Opaque exact-action approvals with explicit task and chat scopes.
-The model may propose an action after untrusted context, but only the server
-stores and later executes the exact approved tool input. Browser-visible
-fields are display copies, never authority.
+The server still seals and claims the first displayed action exactly once. The
+selected scope then bypasses only the automatic post-external-context approval
+gate for the rest of the resumed task or chat session. Browser-visible fields
+are display copies, never authority.
"""
from __future__ import annotations
@@ -16,6 +17,13 @@ import time
from dataclasses import dataclass, field
from typing import Any
+from src.tool_approval_scopes import (
+ CHAT_SESSION_APPROVAL_DECISION,
+ DENY_APPROVAL_DECISION,
+ TASK_APPROVAL_DECISION,
+ ToolApprovalScope,
+ scope_for_decision,
+)
from src.tool_capabilities import ToolCapabilities, capabilities_for_action
@@ -33,6 +41,51 @@ def _normalized_workspace(workspace: Any) -> str:
return os.path.realpath(os.path.expanduser(workspace))
+_MAX_APPROVAL_SELECTED_TOOLS = 512
+_MAX_APPROVAL_TOOL_NAME_CHARS = 512
+_MAX_APPROVAL_CONTINUATION_QUERY_CHARS = 4000
+
+
+def _normalized_selected_tools(
+ selected_tools: Any,
+ *,
+ required_tool: Any = None,
+) -> tuple[str, ...]:
+ if isinstance(selected_tools, str):
+ selected_tools = (selected_tools,)
+ try:
+ values = selected_tools or ()
+ names = {
+ name.strip()
+ for name in values
+ if (
+ isinstance(name, str)
+ and name.strip()
+ and len(name.strip()) <= _MAX_APPROVAL_TOOL_NAME_CHARS
+ )
+ }
+ required_name = str(required_tool or "").strip()
+ if required_name and len(required_name) <= _MAX_APPROVAL_TOOL_NAME_CHARS:
+ names.add(required_name)
+ ordered = sorted(names)
+ if len(ordered) <= _MAX_APPROVAL_SELECTED_TOOLS:
+ return tuple(ordered)
+ kept = ordered[:_MAX_APPROVAL_SELECTED_TOOLS]
+ if required_name and required_name in names and required_name not in kept:
+ kept[-1] = required_name
+ kept.sort()
+ return tuple(kept)
+ except TypeError:
+ return ()
+
+
+def _normalized_continuation_query(value: Any) -> str:
+ # The query is server-derived from the interrupted run and already lives in
+ # session history. Keep the pending copy bounded because approvals are held
+ # in memory until consumed or expired.
+ return str(value or "").strip()[:_MAX_APPROVAL_CONTINUATION_QUERY_CHARS]
+
+
def _canonical_digest(payload: dict[str, Any]) -> str:
encoded = json.dumps(
payload,
@@ -60,6 +113,8 @@ def _binding_payload(
document_version: Any,
document_digest: Any,
external_untrusted_context_seen: bool,
+ selected_tools: Any,
+ continuation_query: Any,
effects: tuple[str, ...],
result_integrity: str,
) -> dict[str, Any]:
@@ -76,6 +131,10 @@ def _binding_payload(
),
"document_digest": str(document_digest or "").strip().lower(),
"external_untrusted_context_seen": bool(external_untrusted_context_seen),
+ "selected_tools": list(
+ _normalized_selected_tools(selected_tools, required_tool=tool_name)
+ ),
+ "continuation_query": _normalized_continuation_query(continuation_query),
"effects": list(effects),
"result_integrity": str(result_integrity),
}
@@ -99,26 +158,47 @@ class PendingToolApproval:
digest: str
created_at: float
expires_at: float
+ # Server-only continuation state. Both fields are digest-bound and never
+ # exposed in the browser payload.
+ selected_tools: tuple[str, ...] = ()
+ continuation_query: str = ""
def public_payload(self, *, reason: str | None = None) -> dict[str, Any]:
return {
"kind": "tool_approval",
"approval_id": self.approval_id,
- "question": "Allow this exact action once?",
+ # The browser already owns this chat id. Persisting it with the
+ # resolved card lets history-derived session grants remain bound to
+ # this exact chat and prevents inheritance by a forked session.
+ "session_id": self.session_id,
+ "question": "Allow this task to continue?",
"description": reason or (
- "Untrusted context influenced this run, so this action needs "
- "your explicit approval."
+ "Untrusted context influenced this run, so continuing with "
+ "otherwise-gated actions needs your explicit approval."
),
"options": [
{
- "label": "Allow once",
- "value": "approve",
- "description": "Execute only the sealed action shown here.",
+ "label": "Allow for this task",
+ "value": TASK_APPROVAL_DECISION,
+ "description": (
+ "Execute the sealed action and allow every otherwise-gated "
+ "action needed to finish this request. Current tool, account, "
+ "workspace, and sandbox restrictions still apply."
+ ),
+ },
+ {
+ "label": "Allow for this chat session",
+ "value": CHAT_SESSION_APPROVAL_DECISION,
+ "description": (
+ "Execute the sealed action and stop asking at this gate for "
+ "later requests in this chat. Current tool, account, workspace, "
+ "and sandbox restrictions still apply."
+ ),
},
{
"label": "Deny",
- "value": "deny",
- "description": "Do not execute it.",
+ "value": DENY_APPROVAL_DECISION,
+ "description": "Do not execute the proposed action.",
},
],
"action": {
@@ -137,12 +217,23 @@ class PendingToolApproval:
@dataclass
class ExactToolApproval:
- """A consumed grant that the dispatcher can claim exactly once."""
+ """A consumed exact first action plus an explicit continuation scope."""
pending: PendingToolApproval
+ scope: ToolApprovalScope = ToolApprovalScope.TASK
+ # The seam consumed by agent_loop. Both chat-card allow choices cover the
+ # complete resumed task, because one-action scope there immediately
+ # re-entered the same gate on the next round. Callers with no resumable
+ # chat still get SINGLE_ACTION, which leaves the gate armed behind the
+ # sealed action.
+ allow_remaining_actions: bool = True
_claimed: bool = field(default=False, init=False, repr=False)
_lock: threading.Lock = field(default_factory=threading.Lock, init=False, repr=False)
+ @property
+ def grants_chat_session(self) -> bool:
+ return self.scope is ToolApprovalScope.CHAT_SESSION
+
def _matches_unlocked(
self,
*,
@@ -175,6 +266,8 @@ class ExactToolApproval:
external_untrusted_context_seen=(
self.pending.external_untrusted_context_seen
),
+ selected_tools=self.pending.selected_tools,
+ continuation_query=self.pending.continuation_query,
effects=effects,
result_integrity=result_integrity,
)
@@ -255,6 +348,8 @@ class ToolApprovalStore:
document_id: Any = None,
document_version: Any = None,
document_digest: Any = None,
+ selected_tools: Any = None,
+ continuation_query: Any = None,
external_untrusted_context_seen: bool,
capabilities: ToolCapabilities,
) -> PendingToolApproval:
@@ -272,6 +367,8 @@ class ToolApprovalStore:
document_version=document_version,
document_digest=document_digest,
external_untrusted_context_seen=external_untrusted_context_seen,
+ selected_tools=selected_tools,
+ continuation_query=continuation_query,
effects=effects,
result_integrity=result_integrity,
)
@@ -294,6 +391,8 @@ class ToolApprovalStore:
digest=_canonical_digest(payload),
created_at=now,
expires_at=now + self._ttl_seconds,
+ selected_tools=tuple(payload["selected_tools"]),
+ continuation_query=payload["continuation_query"],
)
with self._lock:
self._purge_expired_locked(now)
@@ -331,7 +430,17 @@ class ToolApprovalStore:
decision: Any,
owner: Any,
session_id: Any,
+ allow_continuation: bool = True,
) -> ExactToolApproval | None:
+ """Consume a pending approval.
+
+ ``allow_continuation`` is the caller's assertion that it owns a
+ resumable conversation the granted scope can apply to. Callers without
+ one (the skill tester, unattended audits) pass ``False`` and get the
+ original one-use grant, so a button labelled "Allow once" cannot widen
+ into a run-long bypass just because the chat card reuses the same wire
+ value.
+ """
now = time.time()
with self._lock:
self._purge_expired_locked(now)
@@ -348,9 +457,21 @@ class ToolApprovalStore:
# another owner's pending action.
return None
self._pending.pop(approval_key, None)
- if str(decision or "").strip().lower() != "approve":
+ normalized_decision = str(decision or "").strip().lower()
+ scope = scope_for_decision(normalized_decision)
+ if scope is None:
return None
- return ExactToolApproval(pending)
+ if not allow_continuation:
+ return ExactToolApproval(
+ pending,
+ scope=ToolApprovalScope.SINGLE_ACTION,
+ allow_remaining_actions=False,
+ )
+ return ExactToolApproval(
+ pending,
+ scope=scope,
+ allow_remaining_actions=True,
+ )
def peek(self, approval_id: Any) -> PendingToolApproval | None:
now = time.time()
diff --git a/src/tool_capabilities.py b/src/tool_capabilities.py
index 2bdca6afd..11378ece3 100644
--- a/src/tool_capabilities.py
+++ b/src/tool_capabilities.py
@@ -14,6 +14,7 @@ from enum import Enum
from types import MappingProxyType
from typing import Any, Iterable, Mapping
+from src.tool_approval_scopes import CHAT_SESSION_APPROVAL_CONTEXT_MARKER
from src.tool_security import BUILTIN_EMAIL_TOOLS
@@ -618,13 +619,30 @@ class ToolRunSecurityContext:
external_untrusted_context_seen: bool = False
external_sources: list[str] = field(default_factory=list)
run_id: str = field(default_factory=lambda: uuid.uuid4().hex)
+ # Task-scope approval sets this for the resumed in-memory run. Chat-scope
+ # approval is projected from the server-owned session history marker below.
+ # The bypass affects only this automatic gate; current tool policy, ownership,
+ # workspace confinement, and execution/sandbox restrictions still apply.
+ approval_gate_bypassed: bool = False
def observe_messages(self, messages: Iterable[dict]) -> None:
- """Promote any server-labelled untrusted prompt context into the gate."""
- if messages_contain_external_untrusted_context(messages):
+ """Apply server-owned chat scope and promote untrusted prompt context."""
+ message_list = list(messages or ())
+ if any(
+ isinstance(message, dict)
+ and isinstance(message.get("metadata"), dict)
+ and message["metadata"].get(
+ CHAT_SESSION_APPROVAL_CONTEXT_MARKER
+ ) is True
+ for message in message_list
+ ):
+ self.approval_gate_bypassed = True
+ if messages_contain_external_untrusted_context(message_list):
self.external_untrusted_context_seen = True
def decision_for(self, tool_name: Any, content: Any = None) -> ToolGateDecision:
+ if self.approval_gate_bypassed:
+ return ToolGateDecision(True)
if not self.external_untrusted_context_seen:
return ToolGateDecision(True)
capabilities = capabilities_for_action(tool_name, content)
diff --git a/static/app.js b/static/app.js
index 426be5f66..bc6ed0f42 100644
--- a/static/app.js
+++ b/static/app.js
@@ -10,8 +10,8 @@ import modelsModule from './js/models.js?v=20260715startupcalm2';
import ragModule from './js/rag.js';
import presetsModule from './js/presets.js';
import searchModule from './js/search.js';
-import chatModule from './js/chat.js?v=20260815toolapproval4';
-import compareModule from './js/compare/index.js?v=20260723compareicon2';
+import chatModule from './js/chat.js?v=20260819approvalcontrol1';
+import compareModule from './js/compare/index.js?v=20260819approvalcontrol1';
import documentModule from './js/document.js?v=20260815approvalsave1';
import searchChatModule from './js/search-chat.js';
import { makeWindowDraggable } from './js/windowDrag.js';
@@ -22,7 +22,7 @@ import {
settleSessionHydration
} from './js/startupShell.js';
import markdownModule from './js/markdown.js';
-import chatRenderer from './js/chatRenderer.js?v=20260815toolapproval4';
+import chatRenderer from './js/chatRenderer.js?v=20260819approvalcontrol1';
import sessionModule from './js/sessions.js';
import memoryModule from './js/memory.js?v=20260722memoryloading1';
import voiceRecorderModule from './js/voiceRecorder.js';
diff --git a/static/index.html b/static/index.html
index 4dd4c6795..3693ffab1 100644
--- a/static/index.html
+++ b/static/index.html
@@ -2572,10 +2572,10 @@
-
+
-
-
+
+
diff --git a/static/js/chat.js b/static/js/chat.js
index b19730050..a5c95e434 100644
--- a/static/js/chat.js
+++ b/static/js/chat.js
@@ -8,8 +8,8 @@
import Storage from './storage.js';
import uiModule from './ui.js';
import sessionModule from './sessions.js';
-import chatRenderer from './chatRenderer.js?v=20260815toolapproval4';
-import chatStream from './chatStream.js?v=20260815approvalsave1';
+import chatRenderer from './chatRenderer.js?v=20260819approvalcontrol1';
+import chatStream from './chatStream.js?v=20260819approvalcontrol1';
import { addAITTSButton } from './tts-ai.js';
import markdownModule from './markdown.js';
import spinnerModule from './spinner.js';
@@ -62,20 +62,18 @@ import { loadPanel } from './panels.js';
let _contextHeaderBound = false;
let _pendingToolApproval = null;
- function _submitToolApprovalWhenIdle(approvalId, label) {
+ function _submitToolApprovalWhenIdle(approvalId) {
if (
!_pendingToolApproval
|| _pendingToolApproval.approval_id !== approvalId
) return;
if (isStreaming || _sendInFlight) {
- setTimeout(() => _submitToolApprovalWhenIdle(approvalId, label), 120);
+ setTimeout(() => _submitToolApprovalWhenIdle(approvalId), 120);
return;
}
const input = document.getElementById('message');
if (input) {
_pendingToolApproval.draft = input.value || '';
- input.value = label;
- input.dispatchEvent(new Event('input', { bubbles: true }));
}
const sendButton = document.querySelector('.send-btn');
if (sendButton) sendButton.click();
@@ -84,16 +82,13 @@ import { loadPanel } from './panels.js';
document.addEventListener('odysseus:tool-approval', (event) => {
const detail = event && event.detail ? event.detail : {};
const decision = String(detail.decision || '').toLowerCase();
- if (!detail.approval_id || !['approve', 'deny'].includes(decision)) return;
+ if (!detail.approval_id || !['approve', 'approve_task', 'deny'].includes(decision)) return;
_pendingToolApproval = {
approval_id: String(detail.approval_id),
decision,
document_id: String(detail.document_id || ''),
};
- _submitToolApprovalWhenIdle(
- _pendingToolApproval.approval_id,
- detail.label || (decision === 'approve' ? 'Allow once' : 'Deny'),
- );
+ _submitToolApprovalWhenIdle(_pendingToolApproval.approval_id);
});
function _fmtContextNumber(n) {
@@ -1309,10 +1304,10 @@ import { loadPanel } from './panels.js';
}
const el = uiModule.el;
- const msg = el('message').value;
+ const msg = approvalForSend ? '' : el('message').value;
// Allow empty text when a regen carries over the original message's
// attachment ids — a photo-only message still has something to send.
- if (!msg.trim() && !fileHandlerModule.getPendingCount() && !(_pendingRegenAttachments && _pendingRegenAttachments.length)) { _releaseSendFlag(); return; }
+ if (!msg.trim() && !approvalForSend && !fileHandlerModule.getPendingCount() && !(_pendingRegenAttachments && _pendingRegenAttachments.length)) { _releaseSendFlag(); return; }
// --- Slash commands: execute directly without AI (no session needed) ---
if (!approvalForSend && isCommand(msg.trim())) {
@@ -1590,7 +1585,7 @@ import { loadPanel } from './panels.js';
const userDisplay = _displayOverride || msg;
_displayOverride = null;
- const skipBubble = _hideUserBubble;
+ const skipBubble = _hideUserBubble || !!approvalForSend;
_hideUserBubble = false;
// Auto-recovery counter: carries across a turn's auto-continues, but resets
// when the user genuinely sends a new message (so each task gets a fresh cap).
@@ -1833,7 +1828,7 @@ import { loadPanel } from './panels.js';
if (_inject.suffix) _finalMsgWithInject = _finalMsgWithInject + ' ' + _inject.suffix;
const fd = new FormData();
- fd.append('message', _finalMsgWithInject);
+ fd.append('message', approvalForSend ? '' : _finalMsgWithInject);
fd.append('session', streamSessionId);
if (approvalForSend) {
fd.append('tool_approval_id', approvalForSend.approval_id);
@@ -2873,7 +2868,7 @@ import { loadPanel } from './panels.js';
if (spinner && spinner.element) spinner.destroy();
break;
}
- if (json.delta || json.type === 'agent_prep' || json.type === 'generated_image' || json.type === 'tool_start' || json.type === 'tool_output' || json.type === 'tool_progress' || json.type === 'agent_step' || json.type === 'loop_breaker_triggered' || json.type === 'intent_nudge_exhausted' || json.type === 'doc_stream_open' || json.type === 'doc_stream_delta' || json.type === 'research_progress') {
+ if (json.delta || json.type === 'agent_prep' || json.type === 'tool_approval_resolved' || json.type === 'generated_image' || json.type === 'tool_start' || json.type === 'tool_output' || json.type === 'tool_progress' || json.type === 'agent_step' || json.type === 'loop_breaker_triggered' || json.type === 'intent_nudge_exhausted' || json.type === 'doc_stream_open' || json.type === 'doc_stream_delta' || json.type === 'research_progress') {
clearResponseTimeout();
clearProcessingProbe();
clearFirstTokenWaitTimers();
@@ -2890,6 +2885,14 @@ import { loadPanel } from './panels.js';
}
continue;
}
+ if (json.type === 'tool_approval_resolved') {
+ _cancelThinkingTimer();
+ _removeThinkingSpinner();
+ if (spinner && spinner.element) spinner.destroy();
+ if (!_isBg && roundHolder && roundHolder !== holder) roundHolder.remove();
+ if (!_isBg && holder) holder.remove();
+ continue;
+ }
if (json.delta) {
_cancelThinkingTimer();
_removeThinkingSpinner();
diff --git a/static/js/chatRenderer.js b/static/js/chatRenderer.js
index dfb936d4f..b5ed364f9 100644
--- a/static/js/chatRenderer.js
+++ b/static/js/chatRenderer.js
@@ -2327,6 +2327,42 @@ export function removeAskUserCards(root) {
scope.querySelectorAll('.ask-user-card').forEach((node) => node.remove());
}
+// While a choice card is visible, let plain 1–3 activate the corresponding
+// rendered option. Reuse the option's click path so the question keeps its
+// existing submission semantics. Tool approval cards are excluded: that card
+// exists to make consent deliberate after untrusted context influenced the
+// run, and its first option is the widest grant, so a stray digit must not
+// answer it.
+function _handleAskUserShortcut(event) {
+ if (
+ event.defaultPrevented
+ || event.repeat
+ || event.isComposing
+ || event.ctrlKey
+ || event.altKey
+ || event.metaKey
+ || event.shiftKey
+ ) return;
+ if (!/^[1-3]$/.test(event.key)) return;
+
+ const target = event.target;
+ if (target?.closest?.('input, textarea, select, [contenteditable="true"]')) return;
+
+ const focusedCard = document.activeElement?.closest?.('.ask-user-card') || null;
+ const mainCard = document.querySelector('#chat-history .ask-user-card');
+ const compareCards = document.querySelectorAll('.compare-pane .ask-user-card');
+ const card = focusedCard || mainCard || (compareCards.length === 1 ? compareCards[0] : null);
+ if (!card) return;
+ if (card.dataset.askUserKind === 'tool_approval') return;
+ const option = card.querySelectorAll('.ask-user-option')[Number(event.key) - 1];
+ if (!option || option.disabled) return;
+
+ event.preventDefault();
+ option.click();
+}
+
+document.addEventListener('keydown', _handleAskUserShortcut);
+
/**
* Render an ask_user payload as a durable choice card.
*
@@ -2336,11 +2372,15 @@ export function removeAskUserCards(root) {
*/
export function renderAskUserCard(payload, options) {
const aq = payload || {};
+ if (aq.resolved) return null;
const opts = Array.isArray(aq.options) ? aq.options : [];
- const chatBox = document.getElementById('chat-history');
+ const renderOptions = options || {};
+ const chatBox = renderOptions.root || document.getElementById('chat-history');
+ const onSubmit = typeof renderOptions.onSubmit === 'function'
+ ? renderOptions.onSubmit
+ : null;
if (!chatBox || !aq.question || opts.length < 2) return null;
- const renderOptions = options || {};
removeAskUserCards(chatBox);
const card = document.createElement('div');
@@ -2349,6 +2389,7 @@ export function renderAskUserCard(payload, options) {
card.tabIndex = -1;
const multi = !!aq.multi;
const isToolApproval = aq.kind === 'tool_approval' && !!aq.approval_id;
+ card.dataset.askUserKind = isToolApproval ? 'tool_approval' : 'question';
const emojiText = (value) => svgifyEmoji(uiModule.esc(String(value)));
const head = document.createElement('div');
@@ -2357,7 +2398,6 @@ export function renderAskUserCard(payload, options) {
closeBtn.type = 'button';
closeBtn.className = 'modal-close ask-user-close';
closeBtn.setAttribute('aria-label', 'Dismiss question');
- closeBtn.textContent = '×';
closeBtn.addEventListener('click', () => {
card.remove();
const input = uiModule.el('message');
@@ -2400,6 +2440,17 @@ export function renderAskUserCard(payload, options) {
const send = (text) => {
if (!text) return;
+ if (onSubmit) {
+ const accepted = onSubmit({
+ kind: 'answer',
+ text,
+ label: text,
+ payload: aq,
+ card,
+ });
+ if (accepted !== false) card.remove();
+ return;
+ }
card.remove();
const input = uiModule.el('message');
if (input) input.value = text;
@@ -2433,17 +2484,26 @@ export function renderAskUserCard(payload, options) {
row.type = 'button';
row.addEventListener('click', () => {
if (isToolApproval) {
- card.remove();
- document.dispatchEvent(new CustomEvent('odysseus:tool-approval', {
- detail: {
- approval_id: aq.approval_id,
- decision: String((opt && opt.value) || '').toLowerCase(),
- label,
- document_id: aq.action && aq.action.document_id
- ? String(aq.action.document_id)
- : '',
- },
- }));
+ const detail = {
+ approval_id: aq.approval_id,
+ decision: String((opt && opt.value) || '').toLowerCase(),
+ label,
+ document_id: aq.action && aq.action.document_id
+ ? String(aq.action.document_id)
+ : '',
+ };
+ if (onSubmit) {
+ const accepted = onSubmit({
+ kind: 'tool_approval',
+ ...detail,
+ payload: aq,
+ card,
+ });
+ if (accepted !== false) card.remove();
+ } else {
+ card.remove();
+ document.dispatchEvent(new CustomEvent('odysseus:tool-approval', { detail }));
+ }
} else {
send(label);
}
@@ -2628,7 +2688,7 @@ export function addMessage(role, content, modelName, metadata) {
box.appendChild(threadWrap);
}
for (const ev of roundTools) {
- if (ev.ask_user) pendingAskUser = ev.ask_user;
+ if (ev.ask_user && !ev.ask_user.resolved) pendingAskUser = ev.ask_user;
const ok = (ev.exit_code === 0 || ev.exit_code == null);
let outHtml = '';
if (ev.output && ev.output.trim()) {
diff --git a/static/js/chatStream.js b/static/js/chatStream.js
index 19bf66753..5e0a0e263 100644
--- a/static/js/chatStream.js
+++ b/static/js/chatStream.js
@@ -9,6 +9,35 @@ import markdownModule from './markdown.js';
import sessionModule from './sessions.js';
import documentModule from './document.js?v=20260815approvalsave1';
+// Tool approvals are control-plane submits for the current chat. chat.js
+// deliberately leaves the composer untouched, then programmatically clicks the
+// shared send button after it records the sealed approval id/decision. That
+// button is polymorphic: with an empty composer it can mean New chat or Record
+// voice instead of Send. Intercept only the programmatic approval click and
+// route it through the form submit path, which already reaches chat.js directly.
+document.addEventListener('odysseus:tool-approval', () => {
+ const sendButton = document.querySelector('.send-btn');
+ const chatForm = document.getElementById('chat-form');
+ if (!sendButton || !chatForm) return;
+
+ const interceptApprovalClick = (event) => {
+ // A real user click must retain the normal send/new-chat/STT behavior.
+ if (event.isTrusted) return;
+ sendButton.removeEventListener('click', interceptApprovalClick, true);
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ if (chatForm.requestSubmit) chatForm.requestSubmit();
+ else chatForm.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
+ };
+
+ sendButton.addEventListener('click', interceptApprovalClick, true);
+ // Fail-safe cleanup if the approval continuation never reaches its deferred
+ // synthetic click (for example because the surrounding view is torn down).
+ setTimeout(() => {
+ sendButton.removeEventListener('click', interceptApprovalClick, true);
+ }, 60000);
+}, true);
+
/**
* Handle a ui_control SSE event — AI-driven UI manipulation.
* Extracted from the duplicated ui_control + tool_output.ui_event handlers.
diff --git a/static/js/compare/index.js b/static/js/compare/index.js
index 1c64e084b..120fb5836 100644
--- a/static/js/compare/index.js
+++ b/static/js/compare/index.js
@@ -21,7 +21,7 @@ import { EVAL_PROMPTS, WAVE_FRAMES,
import { fetchModels, _persistSelections, _modelDisplayNames, getExcludedModels, setExcludedModels } from './models.js';
import { showModelSelector, disableToolToggles, restoreToolToggles, _syncToolbarIndicator } from './selector.js?v=20260723compareicon2';
import { _checkUnprobed, _clearProbeWaves } from './probe.js';
-import { streamToPane, _renderSearchResults, _runSynthForPane, _formatMs, registerStreamActions } from './stream.js';
+import { streamToPane, _renderSearchResults, _runSynthForPane, _formatMs, registerStreamActions } from './stream.js?v=20260819approvalcontrol1';
import {
stopAll, stopPane, rerollPane, shufflePanePositions, resetCompare,
_addPane, _removePane, toggleExpandPane, togglePanePreview, copyPaneResponse,
@@ -1006,11 +1006,16 @@ async function _executeCompare(message) {
console.error('Compare error:', err);
if (uiModule) uiModule.showError('Compare failed: ' + err.message);
} finally {
- state._streaming = false;
- _setSendBtn('send');
- // Re-enable header buttons
- document.querySelectorAll('#compare-shuffle-btn, #compare-check-btn, #compare-add-btn').forEach(b => {
- b.disabled = false; b.style.opacity = '0.7'; b.style.pointerEvents = '';
+ // A pane may have started its own ask_user/approval continuation while the
+ // original all-pane Promise was settling. Keep Compare busy until every
+ // pane-owned controller is gone instead of exposing a second broadcast send.
+ const compareStillStreaming = state._abortControllers.some(Boolean);
+ state._streaming = compareStillStreaming;
+ _setSendBtn(compareStillStreaming ? 'stop' : 'send');
+ document.querySelectorAll('#compare-shuffle-btn, #compare-check-btn, #compare-add-btn').forEach((button) => {
+ button.disabled = compareStillStreaming;
+ button.style.opacity = compareStillStreaming ? '0.25' : '0.7';
+ button.style.pointerEvents = compareStillStreaming ? 'none' : '';
});
}
}
@@ -1514,7 +1519,7 @@ async function showShufflePoolEditor() {
// ────────────────────────────────────────────────────────────────────────────
registerCompareActions({ stopAll, resetCompare });
-registerStreamActions({ rerollPane, autoPreviewHtml: _autoPreviewHtml });
+registerStreamActions({ rerollPane, autoPreviewHtml: _autoPreviewHtml, setSendBtn: _setSendBtn });
registerPaneActions({ setSendBtn: _setSendBtn, deactivate, streamToPane, renderSearchResults: _renderSearchResults, fetchModels });
// ────────────────────────────────────────────────────────────────────────────
diff --git a/static/js/compare/stream.js b/static/js/compare/stream.js
index 5bb7f9bcc..7f41797fd 100644
--- a/static/js/compare/stream.js
+++ b/static/js/compare/stream.js
@@ -1,7 +1,7 @@
// compare/stream.js — SSE streaming to panes
import state from './state.js';
import { addFinishBadge } from './vote.js';
-import { getModelCost, safeDisplayImageSrc } from '../chatRenderer.js';
+import { getModelCost, renderAskUserCard, safeDisplayImageSrc } from '../chatRenderer.js?v=20260819approvalcontrol1';
import markdownModule from '../markdown.js';
import spinnerModule from '../spinner.js';
import uiModule from '../ui.js';
@@ -24,11 +24,157 @@ function _safeHttpHref(raw) {
// ── Lazy-registered functions from compare.js (avoids circular deps) ──
let _rerollPane = null;
let _autoPreviewHtml = null;
+let _setSendBtn = null;
/** Register external functions that live in compare.js. */
-function registerStreamActions({ rerollPane, autoPreviewHtml }) {
+function registerStreamActions({ rerollPane, autoPreviewHtml, setSendBtn }) {
_rerollPane = rerollPane;
_autoPreviewHtml = autoPreviewHtml;
+ _setSendBtn = setSendBtn;
+}
+
+function _paneSessionIsCurrent(paneIdx, sessionId) {
+ return Boolean(
+ state.isActive
+ && state._paneSessionIds[paneIdx] === sessionId
+ && document.getElementById('cmp-history-' + paneIdx)
+ );
+}
+
+function _setCompareBusy(active) {
+ state._streaming = Boolean(active);
+ if (_setSendBtn) _setSendBtn(active ? 'stop' : 'send');
+ document.querySelectorAll('#compare-shuffle-btn, #compare-check-btn, #compare-add-btn').forEach((button) => {
+ button.disabled = Boolean(active);
+ button.style.opacity = active ? '0.25' : '0.7';
+ button.style.pointerEvents = active ? 'none' : '';
+ });
+}
+
+function _syncCompareBusyFromPanes() {
+ _setCompareBusy((state._abortControllers || []).some(Boolean));
+}
+
+function _appendPaneMessage(hist, role, text) {
+ const message = document.createElement('div');
+ message.className = 'msg ' + (role === 'user' ? 'msg-user' : 'msg-ai');
+ const roleEl = document.createElement('div');
+ roleEl.className = 'role';
+ roleEl.textContent = role === 'user' ? 'You' : 'AI';
+ const body = document.createElement('div');
+ body.className = 'body';
+ body.textContent = text || '';
+ message.appendChild(roleEl);
+ message.appendChild(body);
+ hist.appendChild(message);
+ return message;
+}
+
+function _createPaneContinuationMessage(hist) {
+ const message = _appendPaneMessage(hist, 'assistant', '');
+ const body = message.querySelector('.body');
+ if (spinnerModule) {
+ const spinner = spinnerModule.create('Continuing...', 'right');
+ body.appendChild(spinner.createElement());
+ spinner.start();
+ message._spinner = spinner;
+ }
+ return message;
+}
+
+function _restorePaneAskUserCard(paneIdx, sessionId, submission, originController) {
+ const hist = document.getElementById('cmp-history-' + paneIdx);
+ const restored = _renderPaneAskUserCard(
+ paneIdx,
+ sessionId,
+ submission.payload || {},
+ hist,
+ null,
+ originController,
+ );
+ if (uiModule) {
+ uiModule.showError(
+ restored
+ ? 'This pane is still streaming — choose again once it settles.'
+ : 'Compare pane is still streaming; the choice was not sent.',
+ );
+ }
+ return restored;
+}
+
+function _resumePaneChoiceWhenIdle(paneIdx, sessionId, originController, submission) {
+ if (!_paneSessionIsCurrent(paneIdx, sessionId)) return false;
+
+ const startedAt = Date.now();
+ const resume = () => {
+ if (!_paneSessionIsCurrent(paneIdx, sessionId)) return;
+ const activeController = state._abortControllers[paneIdx];
+ if (activeController === originController) {
+ if (Date.now() - startedAt < 10000) {
+ setTimeout(resume, 25);
+ return;
+ }
+ // The originating stream never released the pane. The card was already
+ // removed when the choice was accepted, so put it back rather than
+ // swallowing a decision the user made.
+ _restorePaneAskUserCard(paneIdx, sessionId, submission, originController);
+ return;
+ }
+ // A reroll/model replacement already owns this pane. Never send the stale
+ // choice into that replacement stream or session UI.
+ if (activeController) return;
+
+ const hist = document.getElementById('cmp-history-' + paneIdx);
+ if (!hist) return;
+ hist.querySelectorAll('.ask-user-card').forEach((card) => card.remove());
+
+ const isApproval = submission.kind === 'tool_approval';
+ const message = isApproval ? '' : String(submission.text || submission.label || '');
+ if (!isApproval) _appendPaneMessage(hist, 'user', message);
+ const aiMessage = _createPaneContinuationMessage(hist);
+ hist.scrollTop = hist.scrollHeight;
+
+ const resumeOptions = { skipBadge: true };
+ if (isApproval) {
+ resumeOptions.toolApproval = {
+ approval_id: String(submission.approval_id || ''),
+ decision: String(submission.decision || '').toLowerCase(),
+ };
+ }
+
+ _setCompareBusy(true);
+ streamToPane(paneIdx, sessionId, message, aiMessage, resumeOptions)
+ .catch((error) => {
+ console.error('Compare pane continuation failed:', error);
+ if (uiModule) uiModule.showError('Compare continuation failed: ' + error.message);
+ })
+ .finally(_syncCompareBusyFromPanes);
+ };
+
+ setTimeout(resume, 0);
+ return true;
+}
+
+function _renderPaneAskUserCard(paneIdx, sessionId, payload, hist, aiMsgEl, originController) {
+ if (!hist || !hist.isConnected || !_paneSessionIsCurrent(paneIdx, sessionId)) return null;
+ if (aiMsgEl && aiMsgEl._spinner) {
+ if (aiMsgEl._spinner.element) aiMsgEl._spinner.destroy();
+ aiMsgEl._spinner = null;
+ }
+ const card = renderAskUserCard(payload, {
+ root: hist,
+ onSubmit: (submission) => _resumePaneChoiceWhenIdle(
+ paneIdx,
+ sessionId,
+ originController,
+ submission,
+ ),
+ });
+ if (card) {
+ card.dataset.comparePane = String(paneIdx);
+ card.dataset.compareSession = String(sessionId);
+ }
+ return card;
}
/** Format milliseconds as human-readable duration (e.g. "120ms", "1.23s", "4.5s"). */
@@ -164,6 +310,7 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
let metrics = null;
let timedOut = false;
let streamOk = false;
+ let awaitingChoice = false;
let currentToolBlock = null; // track active agent tool block
// Idle timeout — abort only if no data is received for this many seconds.
// Long generations (SVG, big code) are fine as long as the stream stays
@@ -219,6 +366,10 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
const fd = new FormData();
fd.append('message', message);
fd.append('session', sessionId);
+ if (opts.toolApproval) {
+ fd.append('tool_approval_id', opts.toolApproval.approval_id || '');
+ fd.append('tool_approval_decision', opts.toolApproval.decision || '');
+ }
// Compare mode determines what tools/features are enabled
const isAgent = state._compareMode === 'agent';
@@ -322,6 +473,36 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
}
}
+ // ── Pane-local question / approval selector ──
+ } else if (json.type === 'ask_user') {
+ awaitingChoice = true;
+ _renderPaneAskUserCard(
+ paneIdx,
+ sessionId,
+ json.data || {},
+ hist,
+ aiMsgEl,
+ ac,
+ );
+ if (hist) hist.scrollTop = hist.scrollHeight;
+
+ // Deny ends as a tiny resolution-only stream, so replace the
+ // continuation spinner with an explicit pane-local result.
+ } else if (json.type === 'tool_approval_resolved') {
+ if (aiMsgEl._spinner) {
+ if (aiMsgEl._spinner.element) aiMsgEl._spinner.destroy();
+ aiMsgEl._spinner = null;
+ }
+ accumulated = json.decision === 'deny' ? 'Denied.' : 'Approval recorded.';
+ let target = aiMsgEl._textEl;
+ if (!target) {
+ target = document.createElement('div');
+ target.className = 'compare-text-content';
+ aiBody.appendChild(target);
+ aiMsgEl._textEl = target;
+ }
+ target.textContent = accumulated;
+
// ── Tool start (bash, web search agent tool) ──
} else if (json.type === 'tool_start') {
// Finalize any accumulated text before the tool block
@@ -640,19 +821,21 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
// TTFT removed from the header per user request — just show total time.
_timerEl.textContent = _formatMs(_totalMs);
}
- state._abortControllers[paneIdx] = null;
+ if (state._abortControllers[paneIdx] === ac) {
+ state._abortControllers[paneIdx] = null;
+ }
// Hide stop button, show response action buttons
const _paneElFinal = document.querySelector(`.compare-pane[data-pane="${paneIdx}"]`);
if (_paneElFinal) {
const _stopBtnFinal = _paneElFinal.querySelector('.pane-stop-btn');
if (_stopBtnFinal) _stopBtnFinal.style.display = 'none';
- if (accumulated.trim()) {
+ if (!awaitingChoice && accumulated.trim()) {
_paneElFinal.querySelectorAll('.pane-needs-response').forEach(b => b.style.display = '');
}
}
state._paneMetrics[paneIdx] = metrics;
state._paneElapsed[paneIdx] = _totalMs;
- if (!opts.skipBadge) {
+ if (!opts.skipBadge && !awaitingChoice) {
if (streamOk) {
state._finishOrder++;
if (state._parallel) {
@@ -682,7 +865,7 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
}
}
// Auto-grade against expected answer — stamps ✓ or ✗ on the pane header.
- if (streamOk && state._expectedAnswer) {
+ if (streamOk && !awaitingChoice && state._expectedAnswer) {
_stampGradeBadge(paneIdx, accumulated, state._expectedAnswer);
}
// Show copy/reroll buttons now that response exists
diff --git a/tests/test_compare_ask_user_routing.py b/tests/test_compare_ask_user_routing.py
new file mode 100644
index 000000000..2634c1381
--- /dev/null
+++ b/tests/test_compare_ask_user_routing.py
@@ -0,0 +1,61 @@
+from pathlib import Path
+
+
+def test_compare_renders_ask_user_in_the_originating_pane():
+ root = Path(__file__).resolve().parents[1]
+ stream = (root / "static/js/compare/stream.js").read_text(encoding="utf-8")
+
+ assert "renderAskUserCard" in stream
+ assert "} else if (json.type === 'ask_user') {" in stream
+ assert "root: hist" in stream
+ assert "state._paneSessionIds[paneIdx] === sessionId" in stream
+ assert "streamToPane(paneIdx, sessionId, message, aiMessage, resumeOptions)" in stream
+ assert "handleCompareSubmit" not in stream
+
+
+def test_compare_submits_approval_only_to_the_pane_session():
+ root = Path(__file__).resolve().parents[1]
+ stream = (root / "static/js/compare/stream.js").read_text(encoding="utf-8")
+
+ assert "fd.append('tool_approval_id', opts.toolApproval.approval_id || '');" in stream
+ assert "fd.append('tool_approval_decision', opts.toolApproval.decision || '');" in stream
+ assert "const isApproval = submission.kind === 'tool_approval';" in stream
+ assert "const message = isApproval ? ''" in stream
+ assert "if (!isApproval) _appendPaneMessage(hist, 'user', message);" in stream
+ assert "json.type === 'tool_approval_resolved'" in stream
+ assert "json.decision === 'deny' ? 'Denied.'" in stream
+
+
+def test_compare_continuation_does_not_lose_or_replace_pane_ownership():
+ root = Path(__file__).resolve().parents[1]
+ stream = (root / "static/js/compare/stream.js").read_text(encoding="utf-8")
+ index = (root / "static/js/compare/index.js").read_text(encoding="utf-8")
+
+ assert "if (state._abortControllers[paneIdx] === ac)" in stream
+ assert "if (activeController === originController)" in stream
+ assert "if (activeController) return;" in stream
+ assert "registerStreamActions({ rerollPane, autoPreviewHtml: _autoPreviewHtml, setSendBtn: _setSendBtn });" in index
+ assert "const compareStillStreaming = state._abortControllers.some(Boolean);" in index
+ assert "_setSendBtn(compareStillStreaming ? 'stop' : 'send');" in index
+
+
+def test_compare_restores_the_card_instead_of_dropping_a_timed_out_choice():
+ """The card is removed the moment a choice is accepted.
+
+ If the originating stream still owns the pane when the resume deadline
+ passes, the decision has nowhere to go — so the card has to come back
+ rather than the click vanishing silently.
+ """
+
+ root = Path(__file__).resolve().parents[1]
+ stream = (root / "static/js/compare/stream.js").read_text(encoding="utf-8")
+
+ assert "function _restorePaneAskUserCard(" in stream
+ assert "_restorePaneAskUserCard(paneIdx, sessionId, submission, originController);" in stream
+ assert "submission.payload || {}" in stream
+
+ start = stream.index("function _resumePaneChoiceWhenIdle(")
+ end = stream.index("function _renderPaneAskUserCard(", start)
+ resume = stream[start:end]
+ # The deadline must not fall through to a bare return any more.
+ assert "if (Date.now() - startedAt < 10000) setTimeout(resume, 25);" not in resume
diff --git a/tests/test_foreground_model_routing.py b/tests/test_foreground_model_routing.py
index e2ceb8762..fc77956ac 100644
--- a/tests/test_foreground_model_routing.py
+++ b/tests/test_foreground_model_routing.py
@@ -344,7 +344,7 @@ async def test_chat_stream_approval_restores_exact_shell_turn_toggle(monkeypatch
@pytest.mark.asyncio
-async def test_chat_stream_denial_keeps_originating_run_tainted(monkeypatch):
+async def test_chat_stream_denial_returns_control_resolution(monkeypatch):
from src.tool_capabilities import capabilities_for_action
captured = {}
@@ -368,11 +368,13 @@ async def test_chat_stream_denial_keeps_originating_run_tainted(monkeypatch):
)
response = await endpoint(request)
- async for _ in response.body_iterator:
- pass
+ chunks = [chunk async for chunk in response.body_iterator]
+ event = json.loads(chunks[0][len("data: "):])
+ assert event == {"type": "tool_approval_resolved", "decision": "deny"}
+ assert chunks[-1] == "data: [DONE]\n\n"
+ assert "agent" not in captured
assert "exact_approval" not in captured
- assert captured["agent_external_untrusted_context_seen"] is True
assert chat_routes.tool_approval_store.peek(pending.approval_id) is None
diff --git a/tests/test_tool_approval_frontend_routing.py b/tests/test_tool_approval_frontend_routing.py
new file mode 100644
index 000000000..51935c79c
--- /dev/null
+++ b/tests/test_tool_approval_frontend_routing.py
@@ -0,0 +1,103 @@
+from pathlib import Path
+
+
+def test_tool_approval_bypasses_polymorphic_send_button_actions():
+ root = Path(__file__).resolve().parents[1]
+ chat = (root / "static/js/chat.js").read_text(encoding="utf-8")
+ stream = (root / "static/js/chatStream.js").read_text(encoding="utf-8")
+
+ # chat.js still defers the sealed approval through a synthetic button click.
+ assert "if (sendButton) sendButton.click();" in chat
+
+ # The capture listener must intercept only that synthetic click and route it
+ # through the chat form submit path, before app.js can reinterpret an empty
+ # composer as New chat or Record voice.
+ assert "if (event.isTrusted) return;" in stream
+ assert "event.stopImmediatePropagation();" in stream
+ assert "chatForm.requestSubmit()" in stream
+ assert "sendButton.dataset.mode = ''" not in stream
+
+
+def test_ask_user_close_button_uses_one_css_glyph():
+ root = Path(__file__).resolve().parents[1]
+ renderer = (root / "static/js/chatRenderer.js").read_text(encoding="utf-8")
+ styles = (root / "static/style.css").read_text(encoding="utf-8")
+
+ assert "closeBtn.className = 'modal-close ask-user-close';" in renderer
+ assert "closeBtn.setAttribute('aria-label', 'Dismiss question');" in renderer
+ assert "closeBtn.textContent = '×';" not in renderer
+ assert ".modal-close::before" in styles
+
+
+def test_ask_user_number_shortcuts_reuse_option_click_path():
+ root = Path(__file__).resolve().parents[1]
+ renderer = (root / "static/js/chatRenderer.js").read_text(encoding="utf-8")
+ start = renderer.index("function _handleAskUserShortcut(event)")
+ end = renderer.index("document.addEventListener('keydown', _handleAskUserShortcut);", start)
+ shortcut = renderer[start:end]
+
+ assert "if (!/^[1-3]$/.test(event.key)) return;" in shortcut
+ assert "event.repeat" in shortcut
+ assert "event.ctrlKey" in shortcut
+ assert "event.altKey" in shortcut
+ assert "event.metaKey" in shortcut
+ assert "event.shiftKey" in shortcut
+ assert "input, textarea, select, [contenteditable=\"true\"]" in shortcut
+ assert "card.querySelectorAll('.ask-user-option')[Number(event.key) - 1]" in shortcut
+ assert "event.preventDefault();" in shortcut
+ assert "option.click();" in shortcut
+
+
+def test_digit_shortcuts_never_answer_a_tool_approval_card():
+ """A stray digit must not grant a scope the user did not deliberately pick."""
+
+ root = Path(__file__).resolve().parents[1]
+ renderer = (root / "static/js/chatRenderer.js").read_text(encoding="utf-8")
+ start = renderer.index("function _handleAskUserShortcut(event)")
+ end = renderer.index("document.addEventListener('keydown', _handleAskUserShortcut);", start)
+ shortcut = renderer[start:end]
+
+ assert "if (card.dataset.askUserKind === 'tool_approval') return;" in shortcut
+ # The renderer has to label the card for that guard to ever fire.
+ assert (
+ "card.dataset.askUserKind = isToolApproval ? 'tool_approval' : 'question';"
+ in renderer
+ )
+
+
+def test_ask_user_renderer_accepts_scoped_root_and_submit_callback():
+ root = Path(__file__).resolve().parents[1]
+ renderer = (root / "static/js/chatRenderer.js").read_text(encoding="utf-8")
+
+ assert "const chatBox = renderOptions.root || document.getElementById('chat-history');" in renderer
+ assert "const onSubmit = typeof renderOptions.onSubmit === 'function'" in renderer
+ assert "kind: 'answer'" in renderer
+ assert "kind: 'tool_approval'" in renderer
+ assert "if (accepted !== false) card.remove();" in renderer
+ assert "document.dispatchEvent(new CustomEvent('odysseus:tool-approval', { detail }))" in renderer
+
+
+def test_every_changed_approval_module_is_cache_busted_together():
+ """A stale module here silently reinterprets the approval click.
+
+ chat.js leaves the composer empty and clicks the polymorphic send button,
+ so a browser that pairs the new chat.js with a cached chatStream.js has no
+ interceptor and lands on the New chat branch instead. The same holds for
+ the compare pane modules, which chatRenderer now shares a keydown listener
+ with.
+ """
+
+ root = Path(__file__).resolve().parents[1]
+ version = "20260819approvalcontrol1"
+ index = (root / "static/index.html").read_text(encoding="utf-8")
+ app = (root / "static/app.js").read_text(encoding="utf-8")
+ chat = (root / "static/js/chat.js").read_text(encoding="utf-8")
+ compare_index = (root / "static/js/compare/index.js").read_text(encoding="utf-8")
+ compare_stream = (root / "static/js/compare/stream.js").read_text(encoding="utf-8")
+
+ assert f"chatStream.js?v={version}" in index
+ assert f"chatStream.js?v={version}" in chat
+ assert f"compare/index.js?v={version}" in app
+ assert f"stream.js?v={version}" in compare_index
+ # One chatRenderer instance, so the ask_user keydown listener binds once.
+ assert f"chatRenderer.js?v={version}" in compare_stream
diff --git a/tests/test_tool_approval_single_action_scope.py b/tests/test_tool_approval_single_action_scope.py
new file mode 100644
index 000000000..f3673b331
--- /dev/null
+++ b/tests/test_tool_approval_single_action_scope.py
@@ -0,0 +1,89 @@
+"""Callers with no resumable chat keep the original one-use approval scope.
+
+The chat card reuses the wire value ``approve`` for chat-session scope, so any
+caller that still sends ``approve`` meaning "once" has to say so explicitly or
+it silently inherits a run-long gate bypass.
+"""
+
+from pathlib import Path
+
+from src.tool_approval_scopes import ToolApprovalScope
+from src.tool_approvals import ToolApprovalStore
+from src.tool_capabilities import ToolRunSecurityContext, capabilities_for_action
+
+
+def _pending(store: ToolApprovalStore, *, session_id=""):
+ content = "printf exact"
+ return store.create(
+ owner="Alice",
+ session_id=session_id,
+ origin_run_id="run-1",
+ tool_name="bash",
+ content=content,
+ workspace=None,
+ external_untrusted_context_seen=True,
+ capabilities=capabilities_for_action("bash", content),
+ )
+
+
+def test_single_action_grant_leaves_the_gate_armed_behind_the_sealed_action():
+ store = ToolApprovalStore()
+ pending = _pending(store)
+
+ grant = store.consume(
+ pending.approval_id,
+ decision="approve",
+ owner="alice",
+ session_id=None,
+ allow_continuation=False,
+ )
+
+ assert grant is not None
+ assert grant.scope is ToolApprovalScope.SINGLE_ACTION
+ assert grant.allow_remaining_actions is False
+ assert grant.grants_chat_session is False
+
+ resumed = ToolRunSecurityContext(
+ external_untrusted_context_seen=True,
+ approval_gate_bypassed=grant.allow_remaining_actions,
+ )
+ assert resumed.decision_for("bash").allowed is False
+
+
+def test_chat_callers_still_get_the_continuation_scope_they_asked_for():
+ store = ToolApprovalStore()
+ pending = _pending(store, session_id="session-1")
+
+ grant = store.consume(
+ pending.approval_id,
+ decision="approve_task",
+ owner="alice",
+ session_id="session-1",
+ )
+
+ assert grant is not None
+ assert grant.scope is ToolApprovalScope.TASK
+ assert grant.allow_remaining_actions is True
+
+
+def test_deny_is_unaffected_by_the_single_action_flag():
+ store = ToolApprovalStore()
+ pending = _pending(store)
+
+ assert store.consume(
+ pending.approval_id,
+ decision="deny",
+ owner="alice",
+ session_id=None,
+ allow_continuation=False,
+ ) is None
+ assert store.peek(pending.approval_id) is None
+
+
+def test_skill_test_approval_route_opts_out_of_continuation():
+ root = Path(__file__).resolve().parents[1]
+ skills = (root / "routes/skills_routes.py").read_text(encoding="utf-8")
+
+ approve_call = skills.index("exact_approval = tool_approval_store.consume(")
+ end = skills.index(")", skills.index("allow_continuation", approve_call))
+ assert "allow_continuation=False" in skills[approve_call:end]
diff --git a/tests/test_tool_approval_task_scope.py b/tests/test_tool_approval_task_scope.py
new file mode 100644
index 000000000..00803939a
--- /dev/null
+++ b/tests/test_tool_approval_task_scope.py
@@ -0,0 +1,351 @@
+"""Task- and chat-scoped approval continuation coverage for issue #6112."""
+
+import asyncio
+import json
+from dataclasses import replace
+from pathlib import Path
+from types import SimpleNamespace
+
+from core.models import ChatMessage, Session
+from src.tool_approval_scopes import (
+ CHAT_SESSION_APPROVAL_CONTEXT_MARKER,
+ ToolApprovalScope,
+)
+from src.tool_approvals import ExactToolApproval, ToolApprovalStore
+from src.tool_capabilities import ToolRunSecurityContext, capabilities_for_action
+
+
+def _pending(
+ store: ToolApprovalStore,
+ *,
+ selected_tools=None,
+ continuation_query="inspect the project using memory and skills",
+):
+ content = "printf exact"
+ return store.create(
+ owner="Alice",
+ session_id="session-1",
+ origin_run_id="run-1",
+ tool_name="bash",
+ content=content,
+ workspace=None,
+ external_untrusted_context_seen=True,
+ selected_tools=selected_tools,
+ continuation_query=continuation_query,
+ capabilities=capabilities_for_action("bash", content),
+ )
+
+
+def test_card_offers_task_chat_session_and_deny_without_leaking_private_state():
+ pending = _pending(
+ ToolApprovalStore(),
+ selected_tools=["manage_skills", "bash", "manage_skills"],
+ )
+
+ payload = pending.public_payload()
+
+ assert payload["session_id"] == "session-1"
+ assert [option["value"] for option in payload["options"]] == [
+ "approve_task",
+ "approve",
+ "deny",
+ ]
+ assert [option["label"] for option in payload["options"]] == [
+ "Allow for this task",
+ "Allow for this chat session",
+ "Deny",
+ ]
+ serialized = json.dumps(payload, sort_keys=True)
+ assert "Allow once" not in serialized
+ assert "selected_tools" not in serialized
+ assert "continuation_query" not in serialized
+ assert "manage_skills" not in serialized
+ assert "inspect the project" not in serialized
+
+
+def test_allow_for_task_bypasses_only_the_resumed_run_gate():
+ store = ToolApprovalStore()
+ pending = _pending(store, selected_tools=["bash", "manage_skills"])
+ grant = store.consume(
+ pending.approval_id,
+ decision="approve_task",
+ owner="alice",
+ session_id="session-1",
+ )
+
+ assert grant is not None
+ assert grant.scope is ToolApprovalScope.TASK
+ assert grant.allow_remaining_actions is True
+ assert grant.grants_chat_session is False
+ assert grant.pending.continuation_query == (
+ "inspect the project using memory and skills"
+ )
+
+ resumed = ToolRunSecurityContext(
+ external_untrusted_context_seen=True,
+ approval_gate_bypassed=grant.allow_remaining_actions,
+ )
+ assert resumed.decision_for("bash").allowed is True
+
+ # A new ordinary user turn constructs a fresh context and asks again.
+ fresh = ToolRunSecurityContext(external_untrusted_context_seen=True)
+ assert fresh.decision_for("bash").allowed is False
+
+
+def test_allow_for_chat_session_applies_to_later_turns_in_only_that_chat():
+ store = ToolApprovalStore()
+ pending = _pending(store, selected_tools=["bash", "manage_skills"])
+ grant = store.consume(
+ pending.approval_id,
+ decision="approve",
+ owner="alice",
+ session_id="session-1",
+ )
+
+ assert grant is not None
+ assert grant.scope is ToolApprovalScope.CHAT_SESSION
+ assert grant.allow_remaining_actions is True
+ assert grant.grants_chat_session is True
+ assert grant.pending.selected_tools == ("bash", "manage_skills")
+ assert grant.pending.continuation_query.startswith("inspect the project")
+
+ resolved_card = pending.public_payload()
+ resolved_card["resolved"] = "approve"
+ history = [
+ ChatMessage(
+ "assistant",
+ "approval requested",
+ {"tool_events": [{"ask_user": resolved_card}]},
+ ),
+ ChatMessage("user", "continue the work"),
+ ]
+ session = Session(
+ id="session-1",
+ name="Chat",
+ endpoint_url="http://example.invalid",
+ model="test",
+ history=history,
+ )
+
+ messages = session.get_context_messages()
+ assert messages[-1]["metadata"][CHAT_SESSION_APPROVAL_CONTEXT_MARKER] is True
+ assert history[-1].metadata is None
+
+ future_turn = ToolRunSecurityContext(external_untrusted_context_seen=True)
+ future_turn.observe_messages(messages)
+ assert future_turn.approval_gate_bypassed is True
+ assert future_turn.decision_for("bash").allowed is True
+
+ # The persisted card is bound to its original chat id, so a fork/copy does
+ # not inherit the grant merely by copying transcript metadata.
+ other_session = Session(
+ id="session-2",
+ name="Fork",
+ endpoint_url="http://example.invalid",
+ model="test",
+ history=history,
+ )
+ other_messages = other_session.get_context_messages()
+ assert CHAT_SESSION_APPROVAL_CONTEXT_MARKER not in (
+ other_messages[-1].get("metadata") or {}
+ )
+ other_turn = ToolRunSecurityContext(external_untrusted_context_seen=True)
+ other_turn.observe_messages(other_messages)
+ assert other_turn.decision_for("bash").allowed is False
+
+
+def test_deny_executes_nothing_and_grants_no_task_or_chat_scope():
+ store = ToolApprovalStore()
+ pending = _pending(store)
+
+ assert store.consume(
+ pending.approval_id,
+ decision="deny",
+ owner="alice",
+ session_id="session-1",
+ ) is None
+ assert store.peek(pending.approval_id) is None
+
+ denied_card = pending.public_payload()
+ denied_card["resolved"] = "deny"
+ session = Session(
+ id="session-1",
+ name="Chat",
+ endpoint_url="http://example.invalid",
+ model="test",
+ history=[
+ ChatMessage(
+ "assistant",
+ "approval requested",
+ {"tool_events": [{"ask_user": denied_card}]},
+ ),
+ ChatMessage("user", "another request"),
+ ],
+ )
+ messages = session.get_context_messages()
+ assert CHAT_SESSION_APPROVAL_CONTEXT_MARKER not in (
+ messages[-1].get("metadata") or {}
+ )
+
+
+def test_private_continuation_state_is_canonical_bounded_and_digest_bound():
+ selected_tools = ["manage_skills", "bash", "manage_skills", "", 7]
+ selected_tools.extend(f"tool_{index:04d}" for index in range(600))
+ selected_tools.append("x" * 513)
+ pending = _pending(
+ ToolApprovalStore(),
+ selected_tools=selected_tools,
+ continuation_query=" " + ("original request " * 500),
+ )
+ assert pending.selected_tools[:2] == ("bash", "manage_skills")
+ assert len(pending.selected_tools) == 512
+ assert all(len(name) <= 512 for name in pending.selected_tools)
+ assert "x" * 513 not in pending.selected_tools
+ assert pending.continuation_query.startswith("original request")
+ assert len(pending.continuation_query) == 4000
+
+ tampered = replace(
+ pending,
+ selected_tools=("bash", "manage_skills", "send_email"),
+ continuation_query="different request",
+ )
+ grant = ExactToolApproval(tampered)
+ assert grant.matches(
+ owner="alice",
+ session_id="session-1",
+ tool_name="bash",
+ content="printf exact",
+ workspace=None,
+ ) is False
+
+
+def test_consumed_card_resolution_updates_memory_and_persisted_metadata(monkeypatch):
+ from routes import chat_routes
+
+ ask_user = {
+ "kind": "tool_approval",
+ "approval_id": "approval-1",
+ "session_id": "session-1",
+ }
+ metadata = {
+ "_db_id": "message-1",
+ "tool_events": [{"ask_user": ask_user}],
+ }
+ sess = SimpleNamespace(
+ id="session-1",
+ history=[SimpleNamespace(metadata=metadata)],
+ )
+ db_message = SimpleNamespace(meta_data=None)
+
+ class Column:
+ def __eq__(self, value):
+ return value
+
+ class FakeDBMessage:
+ id = Column()
+ session_id = Column()
+
+ class FakeQuery:
+ def filter(self, *conditions):
+ return self
+
+ def first(self):
+ return db_message
+
+ class FakeDB:
+ committed = False
+ rolled_back = False
+ closed = False
+
+ def query(self, model):
+ assert model is FakeDBMessage
+ return FakeQuery()
+
+ def commit(self):
+ self.committed = True
+
+ def rollback(self):
+ self.rolled_back = True
+
+ def close(self):
+ self.closed = True
+
+ db = FakeDB()
+ monkeypatch.setattr(chat_routes, "DBChatMessage", FakeDBMessage)
+ monkeypatch.setattr(chat_routes, "SessionLocal", lambda: db)
+
+ assert chat_routes._mark_tool_approval_resolved(
+ sess,
+ "approval-1",
+ "approve",
+ ) is True
+ assert ask_user["resolved"] == "approve"
+ persisted = json.loads(db_message.meta_data)
+ assert persisted["tool_events"][0]["ask_user"]["resolved"] == "approve"
+ assert "_db_id" not in persisted
+ assert db.committed is True
+ assert db.rolled_back is False
+ assert db.closed is True
+
+
+def test_deny_resolution_stream_is_control_only():
+ from routes.chat_routes import _tool_approval_resolution_stream
+
+ async def collect():
+ return [chunk async for chunk in _tool_approval_resolution_stream("deny")]
+
+ chunks = asyncio.run(collect())
+ assert chunks[-1] == "data: [DONE]\n\n"
+ event = json.loads(chunks[0][len("data: "):])
+ assert event == {"type": "tool_approval_resolved", "decision": "deny"}
+ assert "Denied the" not in "".join(chunks)
+
+
+def test_route_context_agent_frontend_and_cache_bust_wire_the_contract():
+ root = Path(__file__).resolve().parents[1]
+ route = (root / "routes/chat_routes.py").read_text(encoding="utf-8")
+ helpers = (root / "routes/chat_helpers.py").read_text(encoding="utf-8")
+ agent = (root / "src/agent_loop.py").read_text(encoding="utf-8")
+ frontend = (root / "static/js/chat.js").read_text(encoding="utf-8")
+ renderer = (root / "static/js/chatRenderer.js").read_text(encoding="utf-8")
+ app = (root / "static/app.js").read_text(encoding="utf-8")
+ index = (root / "static/index.html").read_text(encoding="utf-8")
+ approvals = (root / "src/tool_approvals.py").read_text(encoding="utf-8")
+ capabilities = (root / "src/tool_capabilities.py").read_text(encoding="utf-8")
+ models = (root / "core/models.py").read_text(encoding="utf-8")
+
+ assert 'decision not in {"approve", "approve_task", "deny"}' in route
+ assert "set(pending_tool_approval.selected_tools)" in route
+ assert "pending_tool_approval.continuation_query" in route
+ assert "persist_user_message=not tool_approval_continuation" in route
+ assert "_mark_tool_approval_resolved(" in route
+ assert "_tool_approval_resolution_stream(decision)" in route
+ assert "Approved the exact" not in route
+ assert "Denied the" not in route
+ assert "continuation_context_message: str | None = None" in helpers
+ assert "persist_user_message: bool = True" in helpers
+ assert "_without_latest_matching_user_message(" not in helpers
+ assert "selected_tools=approval_selected_tools" in agent
+ assert "continuation_query=_retrieval_query or _last_user" in agent
+ assert "approval_gate_bypassed=bool(" in agent
+ assert "['approve', 'approve_task', 'deny']" in frontend
+ assert "input.value = label" not in frontend
+ assert "const msg = approvalForSend ? '' : el('message').value;" in frontend
+ assert "const skipBubble = _hideUserBubble || !!approvalForSend;" in frontend
+ assert "fd.append('message', approvalForSend ? '' : _finalMsgWithInject);" in frontend
+ assert "json.type === 'tool_approval_resolved'" in frontend
+ assert "if (aq.resolved) return null;" in renderer
+ assert "ev.ask_user && !ev.ask_user.resolved" in renderer
+ assert '"label": "Allow once"' not in approvals
+ assert '"label": "Allow for this task"' in approvals
+ assert '"label": "Allow for this chat session"' in approvals
+ assert "scope_for_decision(normalized_decision)" in approvals
+ assert "CHAT_SESSION_APPROVAL_CONTEXT_MARKER" in capabilities
+ assert "CHAT_SESSION_APPROVAL_CONTEXT_MARKER" in models
+
+ version = "20260819approvalcontrol1"
+ assert f"chat.js?v={version}" in app
+ assert f"chat.js?v={version}" in index
+ assert f"chatRenderer.js?v={version}" in frontend
+ assert f"chatRenderer.js?v={version}" in app
+ assert f"chatRenderer.js?v={version}" in index