mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-09-11 10:42:22 +02:00
422 lines
15 KiB
Python
422 lines
15 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 core.database import Session as DbSession
|
|
from tests.helpers.sqlite_db import make_temp_sqlite
|
|
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(monkeypatch):
|
|
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")
|
|
|
|
# The browser-shaped resolution card is not enough to create authority.
|
|
# Persist the separate grant through the same helper used by the route,
|
|
# backed by a fresh database so reload behavior is real rather than a
|
|
# monkeypatched history predicate.
|
|
monkeypatch.delenv("AUTH_ENABLED", raising=False)
|
|
import core.database as database
|
|
from src.tool_approval_provenance import create_chat_session_approval_grant
|
|
|
|
db_factory, _engine, _tmpfile = make_temp_sqlite(database.Base.metadata)
|
|
monkeypatch.setattr(database, "SessionLocal", db_factory)
|
|
db = db_factory()
|
|
try:
|
|
db.add(
|
|
DbSession(
|
|
id="session-1",
|
|
name="Chat",
|
|
endpoint_url="http://example.invalid",
|
|
model="test",
|
|
owner="Alice",
|
|
)
|
|
)
|
|
db.commit()
|
|
finally:
|
|
db.close()
|
|
interactive_request = SimpleNamespace(
|
|
state=SimpleNamespace(
|
|
api_token=False,
|
|
current_user="alice",
|
|
),
|
|
headers={},
|
|
)
|
|
assert create_chat_session_approval_grant(
|
|
interactive_request,
|
|
approval=grant,
|
|
approval_id=pending.approval_id,
|
|
session_id="session-1",
|
|
owner="alice",
|
|
) is True
|
|
|
|
resolved_card = pending.public_payload()
|
|
resolved_card["resolved"] = "approve"
|
|
resolved_card["approved_by_interactive_session"] = True
|
|
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",
|
|
owner="Alice",
|
|
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
|
|
|
|
# A fresh in-memory Session object with the same durable id/owner sees the
|
|
# grant after a simulated reload; the transcript card itself is still only
|
|
# display metadata.
|
|
reloaded = Session(
|
|
id="session-1",
|
|
name="Reloaded",
|
|
endpoint_url="http://example.invalid",
|
|
model="test",
|
|
owner="alice",
|
|
history=[ChatMessage("user", "after reload")],
|
|
)
|
|
assert reloaded.get_context_messages()[-1]["metadata"][CHAT_SESSION_APPROVAL_CONTEXT_MARKER] is True
|
|
|
|
wrong_owner = Session(
|
|
id="session-1",
|
|
name="Wrong owner",
|
|
endpoint_url="http://example.invalid",
|
|
model="test",
|
|
owner="bob",
|
|
history=[ChatMessage("user", "cross-owner")],
|
|
)
|
|
assert CHAT_SESSION_APPROVAL_CONTEXT_MARKER not in (
|
|
wrong_owner.get_context_messages()[-1].get("metadata") or {}
|
|
)
|
|
|
|
# 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"
|
|
assert ask_user["approved_by_interactive_session"] is True
|
|
persisted = json.loads(db_message.meta_data)
|
|
assert persisted["tool_events"][0]["ask_user"]["resolved"] == "approve"
|
|
assert persisted["tool_events"][0]["ask_user"]["approved_by_interactive_session"] is True
|
|
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
|