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 @@