mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-09-12 03:02:21 +02:00
Merge commit from fork
* 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>
This commit is contained in:
@@ -33,6 +33,7 @@ from src.settings import get_setting
|
||||
from src.prompt_security import untrusted_context_message
|
||||
from src.tool_security import (
|
||||
blocked_tools_for_owner,
|
||||
delegated_credential_blocked_tools,
|
||||
email_tool_policy_names,
|
||||
plan_mode_disabled_tools,
|
||||
)
|
||||
@@ -3443,6 +3444,7 @@ async def stream_agent_loop(
|
||||
uploaded_files: Optional[List[Dict]] = None,
|
||||
workload: str = "foreground",
|
||||
external_untrusted_context_seen: bool = False,
|
||||
delegated_credential: bool = False,
|
||||
exact_approval: Optional[ExactToolApproval] = None,
|
||||
_is_teacher_run: bool = False,
|
||||
history_session=None,
|
||||
@@ -3471,6 +3473,7 @@ async def stream_agent_loop(
|
||||
approval_gate_bypassed=bool(
|
||||
exact_approval and exact_approval.allow_remaining_actions
|
||||
),
|
||||
delegated_credential=bool(delegated_credential),
|
||||
)
|
||||
mcp_mgr = get_mcp_manager()
|
||||
prep_timings: Dict[str, float] = {}
|
||||
@@ -3490,6 +3493,10 @@ async def stream_agent_loop(
|
||||
mcp_mgr = None
|
||||
guide_only = bool(tool_policy and tool_policy.mode == "guide_only")
|
||||
public_blocked_tools = blocked_tools_for_owner(owner)
|
||||
if delegated_credential:
|
||||
# owner is the admin who minted the token, so the call above returns
|
||||
# nothing. Cap the run regardless of who it acts for.
|
||||
public_blocked_tools.update(delegated_credential_blocked_tools())
|
||||
if public_blocked_tools:
|
||||
disabled_tools.update(public_blocked_tools)
|
||||
# MCP tools are namespaced dynamically, so hide all MCP schemas for
|
||||
@@ -6434,6 +6441,10 @@ async def stream_agent_loop(
|
||||
tool_policy=tool_policy,
|
||||
active_document=active_document,
|
||||
active_email=active_email,
|
||||
external_untrusted_context_seen=(
|
||||
run_security.external_untrusted_context_seen
|
||||
),
|
||||
delegated_credential=delegated_credential,
|
||||
):
|
||||
yield evt
|
||||
except Exception as _esc_err:
|
||||
|
||||
@@ -41,6 +41,45 @@ def _is_api_token_request(request: Request) -> bool:
|
||||
return bool(getattr(request.state, "api_token", False))
|
||||
|
||||
|
||||
def is_delegated_credential(request: Request) -> bool:
|
||||
"""Whether this request arrived on a credential acting FOR a human.
|
||||
|
||||
A bearer API token is minted by a person and then handed to something
|
||||
else: an integration, a script, a third party. :func:`effective_user`
|
||||
resolves it back to that person for ownership and attribution, which is
|
||||
correct for data but wrong for authority. Only admins can mint tokens, so
|
||||
every token resolves to an admin, and any gate that asks "is the owner an
|
||||
admin?" answers yes for a credential the owner has given away.
|
||||
|
||||
Security decisions about what the AGENT may do should ask this instead, so
|
||||
a token cannot inherit the shell merely because its owner could use one.
|
||||
"""
|
||||
return _is_api_token_request(request)
|
||||
|
||||
|
||||
def require_api_token_scope(request: Request, scope: str) -> Optional[str]:
|
||||
"""Require ``scope`` when the request is authenticated by an API token.
|
||||
|
||||
Browser sessions are unaffected. Scoped bearer routes use this before
|
||||
touching owner data so resolving the token back to its owner never also
|
||||
grants the owner's interactive-session authority.
|
||||
"""
|
||||
if not _is_api_token_request(request):
|
||||
return get_current_user(request)
|
||||
scopes = set(getattr(request.state, "api_token_scopes", []) or [])
|
||||
if scope not in scopes:
|
||||
raise HTTPException(403, f"API token missing required scope: {scope}")
|
||||
owner = getattr(request.state, "api_token_owner", None)
|
||||
if not owner:
|
||||
raise HTTPException(403, "API token has no owner")
|
||||
return owner
|
||||
|
||||
|
||||
def require_chat_api_token_scope(request: Request) -> Optional[str]:
|
||||
"""FastAPI dependency for chat/session/history bearer surfaces."""
|
||||
return require_api_token_scope(request, "chat")
|
||||
|
||||
|
||||
def require_authenticated_request(request: Request) -> str:
|
||||
"""Allow either a browser session or a valid bearer API token.
|
||||
|
||||
|
||||
@@ -524,6 +524,8 @@ async def run_teacher_inline(
|
||||
tool_policy: Any = None,
|
||||
active_document: Any = None,
|
||||
active_email: Optional[Dict[str, str]] = None,
|
||||
external_untrusted_context_seen: bool = False,
|
||||
delegated_credential: bool = False,
|
||||
):
|
||||
"""Async generator. Yields SSE event strings.
|
||||
|
||||
@@ -636,6 +638,8 @@ async def run_teacher_inline(
|
||||
tool_policy=tool_policy,
|
||||
active_document=active_document,
|
||||
active_email=active_email,
|
||||
external_untrusted_context_seen=external_untrusted_context_seen,
|
||||
delegated_credential=delegated_credential,
|
||||
_is_teacher_run=True,
|
||||
):
|
||||
# Swallow teacher's own [DONE] — outer loop emits the real one
|
||||
|
||||
@@ -2,7 +2,12 @@
|
||||
|
||||
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
|
||||
@@ -16,6 +21,126 @@ DENY_APPROVAL_DECISION = "deny"
|
||||
# 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)
|
||||
|
||||
@@ -15,7 +15,7 @@ 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
|
||||
from src.tool_security import BUILTIN_EMAIL_TOOLS, is_public_blocked_tool
|
||||
|
||||
|
||||
class ToolEffect(str, Enum):
|
||||
@@ -624,10 +624,21 @@ class ToolRunSecurityContext:
|
||||
# The bypass affects only this automatic gate; current tool policy, ownership,
|
||||
# workspace confinement, and execution/sandbox restrictions still apply.
|
||||
approval_gate_bypassed: bool = False
|
||||
# Driven by a bearer API token, not a person at a browser. Privileged
|
||||
# tools are refused outright and no approval can lift that.
|
||||
delegated_credential: bool = False
|
||||
|
||||
def observe_messages(self, messages: Iterable[dict]) -> None:
|
||||
"""Apply server-owned chat scope and promote untrusted prompt context."""
|
||||
message_list = list(messages or ())
|
||||
if self.delegated_credential:
|
||||
# A delegated run has no human to grant chat-session scope, so a
|
||||
# grant sitting in this chat's history (left by the owner's own
|
||||
# browser) must not be picked up by a token driving the same chat.
|
||||
self.approval_gate_bypassed = False
|
||||
if messages_contain_external_untrusted_context(message_list):
|
||||
self.external_untrusted_context_seen = True
|
||||
return
|
||||
if any(
|
||||
isinstance(message, dict)
|
||||
and isinstance(message.get("metadata"), dict)
|
||||
@@ -641,6 +652,17 @@ class ToolRunSecurityContext:
|
||||
self.external_untrusted_context_seen = True
|
||||
|
||||
def decision_for(self, tool_name: Any, content: Any = None) -> ToolGateDecision:
|
||||
# Checked before the bypasses below, because neither may lift it, and
|
||||
# kept independent of external_untrusted_context_seen so it holds on a
|
||||
# run where that gate never arms and raises no prompt to bypass.
|
||||
if self.delegated_credential and is_public_blocked_tool(tool_name):
|
||||
return ToolGateDecision(
|
||||
False,
|
||||
(
|
||||
f"Tool '{tool_name}' is not available to API-token callers. "
|
||||
"It requires an interactive session."
|
||||
),
|
||||
)
|
||||
if self.approval_gate_bypassed:
|
||||
return ToolGateDecision(True)
|
||||
if not self.external_untrusted_context_seen:
|
||||
|
||||
@@ -269,3 +269,16 @@ def blocked_tools_for_owner(owner: Optional[str]) -> Set[str]:
|
||||
if owner_is_admin_or_single_user(owner):
|
||||
return set()
|
||||
return set(NON_ADMIN_BLOCKED_TOOLS)
|
||||
|
||||
|
||||
def delegated_credential_blocked_tools() -> Set[str]:
|
||||
"""Tools an agent run driven by a bearer API token must not reach.
|
||||
|
||||
Deliberately not owner-dependent. ``blocked_tools_for_owner`` asks whether
|
||||
the OWNER is an admin, and for a token that question is always answered
|
||||
yes: minting a token is an admin-only action, so the empty set comes back
|
||||
for every token in existence. A token is a long-lived credential the owner
|
||||
hands to a third party, so it is capped at the non-admin policy no matter
|
||||
who minted it.
|
||||
"""
|
||||
return set(NON_ADMIN_BLOCKED_TOOLS)
|
||||
|
||||
Reference in New Issue
Block a user