mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-09-25 09:32:20 +02:00
* fix(security): stop API tokens reaching privileged agent tools A bearer API token resolves to the human who minted it, and minting is admin-only, so every owner-keyed privilege check in the agent path answers "admin". A token issued for a narrow integration therefore reached bash and python with the authority of the account that created it. Three independent routes to that sink, each closed here. The token could answer its own tool-approval prompt. An approval records that a person authorized one dangerous action, and a token cannot make that statement, so /api/chat_stream now refuses an approval resume from a bearer caller. The chat-session grant was reconstructable from caller-supplied message metadata. Two routes persist a metadata blob on the caller's behalf, so the shape of a resolved approval card could be written straight into a transcript and was then read back as authority. The server now signs the grant when it resolves an approval and verifies that signature when reading it back, binding it to the chat and the approval it was issued for. Both routes also drop server-owned keys from an inbound blob. A run driven by a token inherited its owner's tool set. Such a run is now capped at the non-admin policy regardless of who minted the credential, which holds even where no approval is raised at all. The human path is unchanged: a browser session still receives the prompt, still approves, and a granted chat-session scope still carries to later turns in that chat. Scope enforcement across the wider route surface is a separate gap and is not addressed here. * fix scoped chat delegation boundaries * fix(auth): reject malformed chat approval signatures --------- Co-authored-by: RaresKeY <158580472+RaresKeY@users.noreply.github.com>
161 lines
5.5 KiB
Python
161 lines
5.5 KiB
Python
"""Shared wire values and scope markers for tool approval continuations."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hmac
|
|
import logging
|
|
from enum import Enum
|
|
from hashlib import sha256
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# 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"
|
|
|
|
# The server's proof that IT resolved this approval. More than one route
|
|
# writes caller-supplied metadata into session history, so a client can write
|
|
# the shape of a resolved card directly; only the server can produce this.
|
|
CHAT_SESSION_APPROVAL_SIGNATURE_FIELD = "_server_grant"
|
|
|
|
|
|
def _grant_key() -> bytes | None:
|
|
"""Key material for grant signatures, or None when it is unavailable.
|
|
|
|
Reuses the persistent application key so a grant survives a restart the
|
|
way the transcript holding it does.
|
|
"""
|
|
try:
|
|
from src.secret_storage import _load_or_create_key
|
|
|
|
return _load_or_create_key()
|
|
except Exception as exc:
|
|
logger.warning("Tool approval grant key unavailable: %s", exc)
|
|
return None
|
|
|
|
|
|
def sign_chat_session_grant(
|
|
session_id: object,
|
|
approval_id: object,
|
|
decision: object,
|
|
) -> str | None:
|
|
"""Return the server's signature for one resolved chat-session grant."""
|
|
|
|
key = _grant_key()
|
|
if key is None:
|
|
return None
|
|
payload = "\x00".join(
|
|
(
|
|
str(session_id or ""),
|
|
str(approval_id or ""),
|
|
str(decision or "").strip().lower(),
|
|
)
|
|
)
|
|
return hmac.new(key, payload.encode("utf-8"), sha256).hexdigest()
|
|
|
|
|
|
# Message-metadata keys the server writes and a caller never should. Both are
|
|
# read back as authority: ``tool_events`` carries the approval cards, and the
|
|
# context marker is projected onto a turn once a grant is found.
|
|
_SERVER_OWNED_METADATA_KEYS = (
|
|
"tool_events",
|
|
CHAT_SESSION_APPROVAL_CONTEXT_MARKER,
|
|
)
|
|
|
|
|
|
def sanitize_client_message_metadata(metadata):
|
|
"""Drop server-owned keys from a caller-supplied message metadata blob.
|
|
|
|
Routes that persist a message on the caller's behalf accept this blob
|
|
verbatim, which lets a caller write the shape of a resolved approval into
|
|
its own transcript. The grant check verifies a signature, so this is not
|
|
the control that closes that path; it keeps the state out of the
|
|
transcript in the first place. Anything else in the blob is left alone.
|
|
"""
|
|
if not isinstance(metadata, dict):
|
|
return metadata
|
|
if not any(key in metadata for key in _SERVER_OWNED_METADATA_KEYS):
|
|
return metadata
|
|
return {
|
|
key: value
|
|
for key, value in metadata.items()
|
|
if key not in _SERVER_OWNED_METADATA_KEYS
|
|
}
|
|
|
|
|
|
def stamp_chat_session_grant(
|
|
ask_user: dict,
|
|
session_id: object,
|
|
decision: object,
|
|
) -> None:
|
|
"""Record the server's grant on a card it has just resolved.
|
|
|
|
Call this only from the server-side resolve path. A decision that does not
|
|
grant chat-session scope leaves no signature behind, so downgrading a
|
|
``deny`` to an ``approve`` in the transcript does not carry a usable one.
|
|
"""
|
|
if not isinstance(ask_user, dict):
|
|
return
|
|
if str(decision or "").strip().lower() != CHAT_SESSION_APPROVAL_DECISION:
|
|
ask_user.pop(CHAT_SESSION_APPROVAL_SIGNATURE_FIELD, None)
|
|
return
|
|
signature = sign_chat_session_grant(
|
|
session_id,
|
|
ask_user.get("approval_id"),
|
|
CHAT_SESSION_APPROVAL_DECISION,
|
|
)
|
|
if signature:
|
|
ask_user[CHAT_SESSION_APPROVAL_SIGNATURE_FIELD] = signature
|
|
|
|
|
|
def verify_chat_session_grant(
|
|
signature: object,
|
|
session_id: object,
|
|
approval_id: object,
|
|
decision: object,
|
|
) -> bool:
|
|
"""Whether *signature* is this server's grant for that exact approval.
|
|
|
|
Fails CLOSED: an absent, malformed, or unverifiable signature is not a
|
|
grant. Binding the session and approval ids into the payload means a
|
|
signature lifted from one chat cannot be replayed into another.
|
|
"""
|
|
# compare_digest accepts only ASCII strings. Treat arbitrary persisted
|
|
# metadata as untrusted and require the exact representation we sign.
|
|
if (
|
|
not isinstance(signature, str)
|
|
or len(signature) != sha256().digest_size * 2
|
|
or any(character not in "0123456789abcdef" for character in signature)
|
|
):
|
|
return False
|
|
expected = sign_chat_session_grant(session_id, approval_id, decision)
|
|
if expected is None:
|
|
return False
|
|
return hmac.compare_digest(signature, expected)
|
|
|
|
|
|
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
|