mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-09-17 21:52:21 +02:00
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:
+16
-1
@@ -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,
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user