A reply can stop and ask you something

Three features turn out to be one mechanism: a command waiting to be
approved, a question the model wants answered, and "this reply is waiting
for you" are all — stop the generation, put an interactive block in the
bubble, wait for a POST, carry on. So there is one primitive, and the only
thing using it so far is `ask_user`: a model can offer you a few answers
and a box to write your own.

The shell executor is not here yet. This lands first on purpose, because
it is the riskiest machinery in the feature and it is worth having working
before any subprocess exists to complicate it.

Two things about where the pause sits. It pauses a round, not a call: a
round's calls run together under a semaphore, and parking four coroutines
on four separate answers inside that gather would queue them behind each
other invisibly. And Stop had to be taught about it — `cancel` is read
between streamed chunks and there are no chunks while paused, so the
button did nothing at all until `request_stop` learned to resolve the
pause itself.

Also here: a risk class on every tool (read, write, execute), which is
what the four permission modes will be a table over, and the systemd unit
loses ProtectKernelTunables. That last one is not tidying — it
bind-mounts /proc/sys read-only, which stops bubblewrap mounting /proc at
all, and the obvious workaround would expose this process's environment
and with it the encryption key.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-08-01 19:30:44 +02:00
parent ecadb66414
commit 1c659a5640
19 changed files with 1662 additions and 17 deletions
+68
View File
@@ -23,6 +23,7 @@ GENERAL = "general"
AUDIO = "audio"
SEARCH = "search"
PROMPTS = "prompts"
AGENTS = "agents"
def _general_defaults() -> dict[str, Any]:
@@ -44,6 +45,58 @@ def _general_defaults() -> dict[str, Any]:
}
def _agents_defaults() -> dict[str, Any]:
"""Agentic execution: running commands, on this machine or over SSH.
Local execution is an instance decision rather than a personal one, because
the sandbox runs on this machine and its blast radius is this machine. SSH
profiles belong to whoever made them, but whether SSH exists here at all
does not.
Everything is off until an administrator turns it on. That is not caution
for its own sake: a model reads web pages, files and command output, all of
which are untrusted, so shell access is a capability somebody has to choose
on purpose.
"""
return {
"local_enabled": False,
"ssh_enabled": False,
"bwrap_path": "bwrap",
# Read-only paths every sandbox sees, on top of /usr and the /lib
# symlinks. The deployment prefix is never here, and a bind containing
# the data directory is refused when the sandbox is built rather than
# trusted to a careful administrator.
"ro_binds": [
"/etc/ssl",
"/etc/ca-certificates",
"/etc/resolv.conf",
# /etc/resolv.conf is a symlink into here on a systemd-resolved box,
# and binding the symlink alone leaves it dangling.
"/run/systemd/resolve",
],
# Off by default, and the single most valuable setting in this group: an
# instruction injected through a file the model read cannot send
# anything anywhere from a sandbox with no network.
"network": False,
"default_timeout": 60,
"max_timeout": 600,
"max_output_bytes": 64 * 1024,
"ulimit_fsize_mb": 64,
"ulimit_nproc": 128,
# Per reply. See services/agent/policy.py:Limits.
"max_steps": 40,
"max_wall_seconds": 900,
"max_total_output_bytes": 1024 * 1024,
"workspace_max_bytes": 512 * 1024 * 1024,
# How long a reply waits for someone to answer. Clamped on read: a zero
# here would park a background task forever.
"approval_timeout": 900,
"allow_default": ["file_read", "file_list", "ls *", "pwd", "git status"],
"deny_default": ["shutdown *", "reboot *", "mkfs*"],
"ask_free_text": True,
}
def _audio_defaults() -> dict[str, Any]:
"""Speech-to-text and text-to-speech endpoints.
@@ -111,6 +164,7 @@ _DEFAULTS: dict[str, Any] = {
AUDIO: _audio_defaults,
SEARCH: _search_defaults,
PROMPTS: _prompts_defaults,
AGENTS: _agents_defaults,
}
@@ -175,3 +229,17 @@ def audio(db: DBSession) -> dict[str, Any]:
def search(db: DBSession) -> dict[str, Any]:
return get_group(db, SEARCH)
def agents(db: DBSession) -> dict[str, Any]:
"""Agent settings, with the two numbers that must not be zero clamped.
`approval_timeout` of 0 would park a background task on a question nobody
is going to answer, and nothing else prunes a generation that is not
finished. Clamped on read rather than on save, so a value already stored by
an earlier version cannot bite either.
"""
values = get_group(db, AGENTS)
values["approval_timeout"] = min(max(int(values.get("approval_timeout") or 0), 60), 3600)
values["max_timeout"] = min(max(int(values.get("max_timeout") or 0), 1), 3600)
return values