diff --git a/core/models.py b/core/models.py index 56f05dc4e5..21570b7c57 100644 --- a/core/models.py +++ b/core/models.py @@ -8,6 +8,11 @@ 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 @@ def get_session_manager_instance() -> Optional["SessionManager"]: 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 @@ def get_context_messages(self) -> List[Dict[str, Any]]: 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 efde7bd791..3d87da2b08 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 0b181796ff..fb080f77be 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 @@ async def chat_stream(request: Request) -> StreamingResponse: 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 @@ async def chat_stream(request: Request) -> StreamingResponse: ) 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 @@ async def chat_stream(request: Request) -> StreamingResponse: 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 @@ async def chat_stream(request: Request) -> StreamingResponse: 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 @@ async def chat_stream(request: Request) -> StreamingResponse: 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 @@ async def chat_stream(request: Request) -> StreamingResponse: 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 _on_research_done(_sid, _result, _sources, _findings): 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 _commit_chat_compaction(candidate_index: int) -> bool: 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 _commit_chat_compaction(candidate_index: int) -> bool: 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 _commit_chat_compaction(candidate_index: int) -> bool: 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 befe8445f5..4b42835d95 100644 --- a/routes/skills_routes.py +++ b/routes/skills_routes.py @@ -1603,6 +1603,9 @@ async def approve_skill_test_action(request: Request, skill_id: str): 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 eb1ebe65ea..296c0ddce4 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 @@ def _finalize_round_usage(*, include_empty: bool = True): "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 @@ def _finalize_round_usage(*, include_empty: bool = True): 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 0000000000..8ff79ac54b --- /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 d3707c5f6f..bfe352b1c9 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 @@ 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 @@ def public_payload(self, *, reason: str | None = None) -> dict[str, Any]: @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 @@ def _matches_unlocked( 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 @@ def create( 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 @@ def create( 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 @@ def create( 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 @@ def consume( 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 @@ def consume( # 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 2bdca6afd6..11378ece3b 100644 --- a/src/tool_capabilities.py +++ b/src/tool_capabilities.py @@ -14,6 +14,7 @@ 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 426be5f66a..bc6ed0f42a 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 4dd4c67954..3693ffab10 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 b197300503..a5c95e4347 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 dfb936d4f3..b5ed364f9f 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 19bf66753c..5e0a0e2634 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 1c64e084bb..120fb58361 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 5bb7f9bccf..7f41797fdd 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 0000000000..2634c1381b --- /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 e2ceb8762b..fc77956acf 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 0000000000..51935c79c2 --- /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 0000000000..f3673b3319 --- /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 0000000000..00803939a4 --- /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