Agent chats run commands, and stop to ask first
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>
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
"""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"]
|
||||
Reference in New Issue
Block a user