fix(agent): allow remaining actions for an approved task (#6113)

* fix(agent): allow remaining actions for an approved task

* fix(agent): make approval continuation control-only

* fix(ci): preserve approval taint and cache-buster contract

* fix(ui): keep tool approvals in current chat

* fix(ui): route tool approvals through chat submit

* test(ui): pin approval submit routing

* fix(agent): complete approval denial flow

* fix(ui): avoid duplicate ask-user close icon

* fix(agent): retain approved tool in continuation set

* revert(ui): keep PR 6113 scoped to approval continuation

* fix(agent): add task and chat approval scopes

* fix(ui): prevent duplicate ask-user close icon

* feat(ui): add ask-user option shortcuts

* fix(compare): route ask-user choices per pane

* fix(agent): keep skill-test approvals to a single action

The chat card now reuses the wire value `approve` to mean chat-session
scope, and `consume()` returned `allow_remaining_actions=True` for it
unconditionally. The skill-test approval route was never updated: it still
sends `approve` meaning "once", and its button still reads "Allow once",
but the grant it got back set `approval_gate_bypassed` for the rest of the
resumed run. That surface wraps the skill body and every transcript byte
as untrusted context, so it is the last place where one click should
ungate everything that follows.

Give `consume()` an explicit `allow_continuation` flag. Callers that own a
resumable chat keep the scope the user picked; callers that do not — the
skill tester, unattended audits — get SINGLE_ACTION and the gate re-arms
behind the sealed action, which is what their label promises.

* fix(ui): cache-bust every module the approval click depends on

chatStream.js, compare/index.js and compare/stream.js all changed
behaviour but kept their old `?v=`, while chat.js and chatRenderer.js were
bumped. A returning browser therefore serves the new chat.js — which now
deliberately leaves the composer empty and clicks the send button — next to
the cached chatStream.js that has no interceptor. With an empty composer
that button sits at `data-mode="newchat"`, so the click opens a new chat
and the approval is dropped.

Bump the three, and version compare/stream.js's chatRenderer import to
match everyone else's so the ask_user keydown listener binds to one module
instance instead of two.

* fix(ui): keep the digit shortcuts off tool approval cards

With an approval card on screen and focus anywhere outside an input, a bare
`1` fired `approve_task` — the widest of the three grants — with no
modifier and no confirmation. That card is the one control whose entire
purpose is deliberate consent after untrusted context influenced the run,
and Deny sits at 3.

Label the card with its kind and skip the shortcut for approvals. Ordinary
ask_user questions keep 1-3.

* fix(compare): restore a pane's ask_user card instead of dropping the choice

renderAskUserCard removes the card as soon as onSubmit accepts, but the
resume loop gave up silently after 10s if the originating stream still owned
the pane. The user saw the click land, the card vanish, and nothing happen,
with no way to get it back.

Re-render the card on that deadline and say why. The reroll case still
returns without sending — that choice belongs to a stream that no longer
exists.

* refactor(chat): drop the unreachable deny branch

`if decision != "deny"` is always true — the deny path returns a
StreamingResponse a few lines above. It reads as if deny still falls
through to the toggle restore.

---------

Co-authored-by: Léo <leograndcontact@gmail.com>
This commit is contained in:
RaresKeY
2026-08-19 08:01:34 -06:00
committed by GitHub
co-authored by Léo
parent 5c835014ac
commit 981652358e
20 changed files with 1361 additions and 119 deletions
+16 -1
View File
@@ -3460,7 +3460,10 @@ async def stream_agent_loop(
and exact_approval.pending.external_untrusted_context_seen
)
or messages_contain_external_untrusted_context(messages)
)
),
approval_gate_bypassed=bool(
exact_approval and exact_approval.allow_remaining_actions
),
)
mcp_mgr = get_mcp_manager()
prep_timings: Dict[str, float] = {}
@@ -5698,6 +5701,16 @@ async def stream_agent_loop(
"policy": "exact_tool_approval_target",
}
else:
# The approval click becomes a synthetic user turn. Seal the
# actual server-selected candidates now so that continuation
# does not lose memory, skills, MCP, documents, or other
# ToolIndex/RAG-selected tools by classifying that synthetic text.
approval_selected_tools = set(_relevant_tools or ())
approval_selected_tools.update(
name for name in _tool_names_sent if name
)
approval_selected_tools.add(block.tool_type)
approval_selected_tools.difference_update(disabled_tools)
pending_approval = tool_approval_store.create(
owner=owner,
session_id=session_id,
@@ -5725,6 +5738,8 @@ async def stream_agent_loop(
external_untrusted_context_seen=(
run_security.external_untrusted_context_seen
),
selected_tools=approval_selected_tools,
continuation_query=_retrieval_query or _last_user,
capabilities=capabilities_for_action(
block.tool_type,
block.content,
+35
View File
@@ -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
+136 -15
View File
@@ -1,8 +1,9 @@
"""Opaque, exact, one-use approvals for tainted model-requested actions.
"""Opaque exact-action approvals with explicit task and chat scopes.
The model may propose an action after untrusted context, but only the server
stores and later executes the exact approved tool input. Browser-visible
fields are display copies, never authority.
The server still seals and claims the first displayed action exactly once. The
selected scope then bypasses only the automatic post-external-context approval
gate for the rest of the resumed task or chat session. Browser-visible fields
are display copies, never authority.
"""
from __future__ import annotations
@@ -16,6 +17,13 @@ import time
from dataclasses import dataclass, field
from typing import Any
from src.tool_approval_scopes import (
CHAT_SESSION_APPROVAL_DECISION,
DENY_APPROVAL_DECISION,
TASK_APPROVAL_DECISION,
ToolApprovalScope,
scope_for_decision,
)
from src.tool_capabilities import ToolCapabilities, capabilities_for_action
@@ -33,6 +41,51 @@ def _normalized_workspace(workspace: Any) -> str:
return os.path.realpath(os.path.expanduser(workspace))
_MAX_APPROVAL_SELECTED_TOOLS = 512
_MAX_APPROVAL_TOOL_NAME_CHARS = 512
_MAX_APPROVAL_CONTINUATION_QUERY_CHARS = 4000
def _normalized_selected_tools(
selected_tools: Any,
*,
required_tool: Any = None,
) -> tuple[str, ...]:
if isinstance(selected_tools, str):
selected_tools = (selected_tools,)
try:
values = selected_tools or ()
names = {
name.strip()
for name in values
if (
isinstance(name, str)
and name.strip()
and len(name.strip()) <= _MAX_APPROVAL_TOOL_NAME_CHARS
)
}
required_name = str(required_tool or "").strip()
if required_name and len(required_name) <= _MAX_APPROVAL_TOOL_NAME_CHARS:
names.add(required_name)
ordered = sorted(names)
if len(ordered) <= _MAX_APPROVAL_SELECTED_TOOLS:
return tuple(ordered)
kept = ordered[:_MAX_APPROVAL_SELECTED_TOOLS]
if required_name and required_name in names and required_name not in kept:
kept[-1] = required_name
kept.sort()
return tuple(kept)
except TypeError:
return ()
def _normalized_continuation_query(value: Any) -> str:
# The query is server-derived from the interrupted run and already lives in
# session history. Keep the pending copy bounded because approvals are held
# in memory until consumed or expired.
return str(value or "").strip()[:_MAX_APPROVAL_CONTINUATION_QUERY_CHARS]
def _canonical_digest(payload: dict[str, Any]) -> str:
encoded = json.dumps(
payload,
@@ -60,6 +113,8 @@ def _binding_payload(
document_version: Any,
document_digest: Any,
external_untrusted_context_seen: bool,
selected_tools: Any,
continuation_query: Any,
effects: tuple[str, ...],
result_integrity: str,
) -> dict[str, Any]:
@@ -76,6 +131,10 @@ def _binding_payload(
),
"document_digest": str(document_digest or "").strip().lower(),
"external_untrusted_context_seen": bool(external_untrusted_context_seen),
"selected_tools": list(
_normalized_selected_tools(selected_tools, required_tool=tool_name)
),
"continuation_query": _normalized_continuation_query(continuation_query),
"effects": list(effects),
"result_integrity": str(result_integrity),
}
@@ -99,26 +158,47 @@ class PendingToolApproval:
digest: str
created_at: float
expires_at: float
# Server-only continuation state. Both fields are digest-bound and never
# exposed in the browser payload.
selected_tools: tuple[str, ...] = ()
continuation_query: str = ""
def public_payload(self, *, reason: str | None = None) -> dict[str, Any]:
return {
"kind": "tool_approval",
"approval_id": self.approval_id,
"question": "Allow this exact action once?",
# The browser already owns this chat id. Persisting it with the
# resolved card lets history-derived session grants remain bound to
# this exact chat and prevents inheritance by a forked session.
"session_id": self.session_id,
"question": "Allow this task to continue?",
"description": reason or (
"Untrusted context influenced this run, so this action needs "
"your explicit approval."
"Untrusted context influenced this run, so continuing with "
"otherwise-gated actions needs your explicit approval."
),
"options": [
{
"label": "Allow once",
"value": "approve",
"description": "Execute only the sealed action shown here.",
"label": "Allow for this task",
"value": TASK_APPROVAL_DECISION,
"description": (
"Execute the sealed action and allow every otherwise-gated "
"action needed to finish this request. Current tool, account, "
"workspace, and sandbox restrictions still apply."
),
},
{
"label": "Allow for this chat session",
"value": CHAT_SESSION_APPROVAL_DECISION,
"description": (
"Execute the sealed action and stop asking at this gate for "
"later requests in this chat. Current tool, account, workspace, "
"and sandbox restrictions still apply."
),
},
{
"label": "Deny",
"value": "deny",
"description": "Do not execute it.",
"value": DENY_APPROVAL_DECISION,
"description": "Do not execute the proposed action.",
},
],
"action": {
@@ -137,12 +217,23 @@ class PendingToolApproval:
@dataclass
class ExactToolApproval:
"""A consumed grant that the dispatcher can claim exactly once."""
"""A consumed exact first action plus an explicit continuation scope."""
pending: PendingToolApproval
scope: ToolApprovalScope = ToolApprovalScope.TASK
# The seam consumed by agent_loop. Both chat-card allow choices cover the
# complete resumed task, because one-action scope there immediately
# re-entered the same gate on the next round. Callers with no resumable
# chat still get SINGLE_ACTION, which leaves the gate armed behind the
# sealed action.
allow_remaining_actions: bool = True
_claimed: bool = field(default=False, init=False, repr=False)
_lock: threading.Lock = field(default_factory=threading.Lock, init=False, repr=False)
@property
def grants_chat_session(self) -> bool:
return self.scope is ToolApprovalScope.CHAT_SESSION
def _matches_unlocked(
self,
*,
@@ -175,6 +266,8 @@ class ExactToolApproval:
external_untrusted_context_seen=(
self.pending.external_untrusted_context_seen
),
selected_tools=self.pending.selected_tools,
continuation_query=self.pending.continuation_query,
effects=effects,
result_integrity=result_integrity,
)
@@ -255,6 +348,8 @@ class ToolApprovalStore:
document_id: Any = None,
document_version: Any = None,
document_digest: Any = None,
selected_tools: Any = None,
continuation_query: Any = None,
external_untrusted_context_seen: bool,
capabilities: ToolCapabilities,
) -> PendingToolApproval:
@@ -272,6 +367,8 @@ class ToolApprovalStore:
document_version=document_version,
document_digest=document_digest,
external_untrusted_context_seen=external_untrusted_context_seen,
selected_tools=selected_tools,
continuation_query=continuation_query,
effects=effects,
result_integrity=result_integrity,
)
@@ -294,6 +391,8 @@ class ToolApprovalStore:
digest=_canonical_digest(payload),
created_at=now,
expires_at=now + self._ttl_seconds,
selected_tools=tuple(payload["selected_tools"]),
continuation_query=payload["continuation_query"],
)
with self._lock:
self._purge_expired_locked(now)
@@ -331,7 +430,17 @@ class ToolApprovalStore:
decision: Any,
owner: Any,
session_id: Any,
allow_continuation: bool = True,
) -> ExactToolApproval | None:
"""Consume a pending approval.
``allow_continuation`` is the caller's assertion that it owns a
resumable conversation the granted scope can apply to. Callers without
one (the skill tester, unattended audits) pass ``False`` and get the
original one-use grant, so a button labelled "Allow once" cannot widen
into a run-long bypass just because the chat card reuses the same wire
value.
"""
now = time.time()
with self._lock:
self._purge_expired_locked(now)
@@ -348,9 +457,21 @@ class ToolApprovalStore:
# another owner's pending action.
return None
self._pending.pop(approval_key, None)
if str(decision or "").strip().lower() != "approve":
normalized_decision = str(decision or "").strip().lower()
scope = scope_for_decision(normalized_decision)
if scope is None:
return None
return ExactToolApproval(pending)
if not allow_continuation:
return ExactToolApproval(
pending,
scope=ToolApprovalScope.SINGLE_ACTION,
allow_remaining_actions=False,
)
return ExactToolApproval(
pending,
scope=scope,
allow_remaining_actions=True,
)
def peek(self, approval_id: Any) -> PendingToolApproval | None:
now = time.time()
+20 -2
View File
@@ -14,6 +14,7 @@ from enum import Enum
from types import MappingProxyType
from typing import Any, Iterable, Mapping
from src.tool_approval_scopes import CHAT_SESSION_APPROVAL_CONTEXT_MARKER
from src.tool_security import BUILTIN_EMAIL_TOOLS
@@ -618,13 +619,30 @@ class ToolRunSecurityContext:
external_untrusted_context_seen: bool = False
external_sources: list[str] = field(default_factory=list)
run_id: str = field(default_factory=lambda: uuid.uuid4().hex)
# Task-scope approval sets this for the resumed in-memory run. Chat-scope
# approval is projected from the server-owned session history marker below.
# The bypass affects only this automatic gate; current tool policy, ownership,
# workspace confinement, and execution/sandbox restrictions still apply.
approval_gate_bypassed: bool = False
def observe_messages(self, messages: Iterable[dict]) -> None:
"""Promote any server-labelled untrusted prompt context into the gate."""
if messages_contain_external_untrusted_context(messages):
"""Apply server-owned chat scope and promote untrusted prompt context."""
message_list = list(messages or ())
if any(
isinstance(message, dict)
and isinstance(message.get("metadata"), dict)
and message["metadata"].get(
CHAT_SESSION_APPROVAL_CONTEXT_MARKER
) is True
for message in message_list
):
self.approval_gate_bypassed = True
if messages_contain_external_untrusted_context(message_list):
self.external_untrusted_context_seen = True
def decision_for(self, tool_name: Any, content: Any = None) -> ToolGateDecision:
if self.approval_gate_bypassed:
return ToolGateDecision(True)
if not self.external_untrusted_context_seen:
return ToolGateDecision(True)
capabilities = capabilities_for_action(tool_name, content)