b6aab8de55
The four tools an agent chat has -- shell_run, file_read, file_write, file_list -- and the mode table wired into the loop that decides which of them stop for approval. Verified end to end against a real Kali container over SSH: the card shows the command, allowing it runs it there, and the file it writes is visible from outside. The mode is enforced in `_authorise`, in the generation loop, server-side, keyed on each tool's declared risk. Not in the prompt: a model is told which mode it is in so it behaves sensibly, but everything it reads -- a web page, a README, the output of the last command -- is untrusted, and a rule written only into a system message is one a poisoned file can argue with. Within an agent chat every call goes through the table, including the built-in ones, because notes_edit writes and Plan mode meaning "look but do not touch" has to mean that too. Two things this turned up. The runners re-check the mode as a backstop, and that backstop refused the very thing a person had just approved -- the mode says "ask", and asking was exactly what happened. Approval is now threaded per call, on a copy of the context, because a round runs its calls together and only some of them were allowed. And the harness said nothing at all, because `registry` maps an offered tool *name* back to a family and did not know the agent tools existed. So shell_run resolved to no family and the fragment naming the machine, the directory and the mode was never admitted. The same omission cost custom tools their guidance once already; there is a test for it now. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
131 lines
5.1 KiB
Python
131 lines
5.1 KiB
Python
"""What one agent chat is pointed at, resolved while a session is open.
|
|
|
|
Everything a runner needs travels in `AgentContext`: the machine, the decrypted
|
|
credential, the mode in force, and the two lists that adjust it. Nothing is
|
|
looked up later, for the reason `Endpoint` is a frozen copy of a `Connection`
|
|
and `ToolContext` carries an owner id rather than a `User` -- a generation
|
|
outlives the request that started it, and a detached instance is a trap.
|
|
|
|
The mode is read **once, at the start of the reply**, and deliberately does not
|
|
change under a reply already in flight. Somebody switching to Auto halfway
|
|
through must not retroactively approve what is already queued.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from dataclasses import dataclass, field, replace
|
|
from typing import Any
|
|
|
|
from sqlalchemy.orm import Session as DBSession
|
|
|
|
from lembas.db.models import KIND_AGENT, Chat, SshProfile, User
|
|
from lembas.services import settings_store
|
|
from lembas.services.agent import policy
|
|
from lembas.services.agent import ssh as ssh_service
|
|
from lembas.services.agent.base import Executor
|
|
from lembas.services.agent.policy import Limits
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclass
|
|
class AgentContext:
|
|
"""The machine an agent chat acts on, and what it may do there."""
|
|
|
|
chat_id: str
|
|
label: str
|
|
project_dir: str
|
|
mode: str = policy.MODE_MANUAL
|
|
allow: tuple[str, ...] = ()
|
|
deny: tuple[str, ...] = ()
|
|
limits: Limits = field(default_factory=Limits)
|
|
# Per-command bounds, from the instance settings.
|
|
timeout: float = 60.0
|
|
max_timeout: float = 600.0
|
|
max_output: int = 64 * 1024
|
|
# The decrypted credential. Held here and nowhere else, and cleared by
|
|
# `generation` when the reply ends -- a finished Generation lingers five
|
|
# minutes so late followers get the final frames, and a private key should
|
|
# not linger with it.
|
|
spec: dict[str, Any] = field(default_factory=dict)
|
|
# Set only on the per-call copy handed to a runner whose call a person has
|
|
# just allowed. The runners re-check the mode as a backstop, and without
|
|
# this they would refuse the very thing that was approved -- the mode says
|
|
# "ask", and asking is exactly what happened.
|
|
approved: bool = False
|
|
|
|
def executor(self) -> Executor:
|
|
return ssh_service.SshExecutor(self.spec, self.project_dir)
|
|
|
|
def clear(self) -> None:
|
|
self.spec = {}
|
|
|
|
def as_approved(self) -> AgentContext:
|
|
"""A copy of this context for one call a person has allowed."""
|
|
return replace(self, approved=True)
|
|
|
|
|
|
def profile_for(db: DBSession, chat: Chat, user: User | None) -> SshProfile | None:
|
|
"""The connection this chat is pointed at, if it is still usable.
|
|
|
|
Ownership is re-checked here rather than trusted from when the chat was
|
|
created: a profile can be deleted, disabled, or moved to a host whose key
|
|
has not been confirmed since, and any of those should stop the chat acting
|
|
rather than be discovered at the first command.
|
|
"""
|
|
if chat is None or chat.kind != KIND_AGENT or not chat.ssh_profile_id:
|
|
return None
|
|
|
|
profile = db.get(SshProfile, chat.ssh_profile_id)
|
|
if profile is None or not profile.enabled:
|
|
return None
|
|
if user is not None and profile.owner_id != user.id:
|
|
return None
|
|
return profile
|
|
|
|
|
|
def resolve(db: DBSession, chat: Chat, user: User | None) -> AgentContext | None:
|
|
"""This chat's agent setup, or None if it has none it can use.
|
|
|
|
None is the answer to every "no": not an agent chat, the feature switched
|
|
off, the connection gone or disabled, SSH not installed. Each of those means
|
|
the agent tools are not offered at all, which is better than offering a tool
|
|
that fails on its first call.
|
|
|
|
A profile whose host key has never been confirmed is deliberately *not* one
|
|
of them. The tools are offered and the failure is explicit, because "check
|
|
the connection and accept its fingerprint" is a thing the reader can act on,
|
|
while a silently missing tool is not.
|
|
"""
|
|
profile = profile_for(db, chat, user)
|
|
if profile is None:
|
|
return None
|
|
|
|
values = settings_store.agents(db)
|
|
if not values.get("enabled"):
|
|
return None
|
|
if ssh_service.available():
|
|
return None
|
|
|
|
return AgentContext(
|
|
chat_id=chat.id,
|
|
label=profile.label,
|
|
project_dir=chat.project_dir or profile.default_dir or "",
|
|
mode=chat.agent_mode if chat.agent_mode in policy.MODES else policy.MODE_MANUAL,
|
|
allow=tuple(values.get("allow_default") or ()),
|
|
deny=tuple(values.get("deny_default") or ()),
|
|
limits=Limits(
|
|
steps=int(values.get("max_steps") or 40),
|
|
wall_seconds=float(values.get("max_wall_seconds") or 900),
|
|
output_bytes=int(values.get("max_total_output_bytes") or 1024 * 1024),
|
|
),
|
|
timeout=float(values.get("default_timeout") or 60),
|
|
max_timeout=float(values.get("max_timeout") or 600),
|
|
max_output=int(values.get("max_output_bytes") or 64 * 1024),
|
|
spec=ssh_service.spec_from(profile),
|
|
)
|
|
|
|
|
|
__all__ = ["AgentContext", "profile_for", "resolve"]
|