mirror of
https://github.com/odysseus-dev/odysseus.git
synced 2026-08-29 02:11:46 +00:00
* 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>
351 lines
12 KiB
Python
351 lines
12 KiB
Python
"""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
|