Correcting a command before allowing it
An approval card was Allow, Always, or Don't. A model proposing the right command with one flag wrong therefore cost a whole round trip to explain in prose. There is an Edit button on it now. Where the edit lands is the whole of the feature, and it is one line. `arguments` is the list `_run_calls` hands to `run_tool` as `parsed=`, and `run_tool` never re-parses -- so writing into it inside `_authorise` is the only mutation the runner can see. Editing the Item would do nothing: it is frozen and display-only. Two things had to move with it. The raw `call["arguments"]` string is rewritten beside the parsed dict, and the assistant turn is now built *after* `_authorise` rather than before it -- the old order told the model it ran what it proposed while something else ran, and every later round would have reasoned from a transcript that was quietly false. And `_remember_always` reads the edit, or "always allow this" would store a standing permission for a command nobody approved; it still derives the pattern itself through `policy.subject`, which yields nothing for a composed command line. Nothing is re-checked against the mode or the lists, and that is not a shortcut. The deny list resolves to ASK rather than to a refusal -- it means "always ask about this" -- so a person who has typed the command and pressed Allow is exactly the asking it was demanding, and re-asking would put the same card up with no way past it. It is the line the terminal panel already draws. The box is only offered where the detail *is* an argument and can be put back: a tool with no entry in `tool_labels.DETAIL_KEYS` gets a `k=repr(v)` summary, and a box there would silently change nothing. Both halves are always in the DOM with one hidden, rather than the field being created on click -- a field that does not exist until a handler runs is a field that submits nothing if the handler fails, and this one decides what runs on somebody's machine. The transcript says "edited by you". Attributing somebody's own typing to a model is the same misattribution as the other way round. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+37
-17
@@ -1330,15 +1330,6 @@ async def answer_interaction(
|
||||
form = await request.form()
|
||||
verdict = str(form.get("verdict") or "").strip()
|
||||
|
||||
# Read and recorded *before* resolving: `interaction.wait_for` clears
|
||||
# `generation.pending` in its `finally`, so a moment later there is nothing
|
||||
# left to remember and "always" would quietly mean "once".
|
||||
remembered = 0
|
||||
if verdict == interaction.ALLOW_ALWAYS:
|
||||
remembered = _remember_always(
|
||||
db, chat, generation_service.pending_items(chat.id, interaction_id)
|
||||
)
|
||||
|
||||
answers: dict[str, str] = {}
|
||||
for field, value in form.multi_items():
|
||||
kind, _, key = str(field).partition(".")
|
||||
@@ -1350,6 +1341,23 @@ async def answer_interaction(
|
||||
elif kind == "choice" and written:
|
||||
answers.setdefault(key, written)
|
||||
|
||||
# Read and recorded *before* resolving: `interaction.wait_for` clears
|
||||
# `generation.pending` in its `finally`, so a moment later there is nothing
|
||||
# left to remember and "always" would quietly mean "once".
|
||||
#
|
||||
# `answers` is gathered first because an approval card can now carry a
|
||||
# corrected command, and "always allow this" has to mean the command that is
|
||||
# about to run rather than the one the model asked for. Remembering the
|
||||
# proposed one would grant a standing permission nobody approved.
|
||||
remembered = 0
|
||||
if verdict == interaction.ALLOW_ALWAYS:
|
||||
remembered = _remember_always(
|
||||
db,
|
||||
chat,
|
||||
generation_service.pending_items(chat.id, interaction_id),
|
||||
answers=answers,
|
||||
)
|
||||
|
||||
answered = generation_service.answer(
|
||||
chat.id,
|
||||
interaction_id,
|
||||
@@ -1377,15 +1385,23 @@ async def answer_interaction(
|
||||
return response
|
||||
|
||||
|
||||
def _remember_always(db: DBSession, chat: Chat, items) -> int:
|
||||
def _remember_always(
|
||||
db: DBSession, chat: Chat, items, *, answers: dict[str, str] | None = None
|
||||
) -> int:
|
||||
"""Record what "always allow" was said about. Returns how many were new.
|
||||
|
||||
The pattern is derived **here**, from the item that was approved, and never
|
||||
taken from the request -- the endpoint accepts an interaction id and a
|
||||
verdict and nothing else. `agent_policy.subject` is the same normaliser
|
||||
`decide` matches with, so what is stored is exactly what will be compared
|
||||
later; it returns None for a command line carrying a shell metacharacter,
|
||||
which is precisely the shape that must never become a standing permission.
|
||||
The pattern is derived **here**, and still never taken from the request as a
|
||||
pattern: `answers` carries the command a person may have corrected on the
|
||||
card, and it goes through `agent_policy.subject` exactly as `item.detail`
|
||||
does. That is the same normaliser `decide` matches with, so what is stored
|
||||
is exactly what will be compared later; it returns None for a command line
|
||||
carrying a shell metacharacter, which is precisely the shape that must never
|
||||
become a standing permission.
|
||||
|
||||
Reading the edit matters rather than being a nicety. Somebody who corrects a
|
||||
command and presses "always allow" has approved the corrected one, and
|
||||
storing what the model originally asked for would be a standing permission
|
||||
for something nobody ever agreed to.
|
||||
|
||||
A tool name for everything that is not a command, which is the convention
|
||||
the shipped `allow_default` already uses: `file_read` and `file_list` are
|
||||
@@ -1393,12 +1409,16 @@ def _remember_always(db: DBSession, chat: Chat, items) -> int:
|
||||
"""
|
||||
scope = dict(chat.scope_json or {})
|
||||
entries = list(scope.get("allow") or [])
|
||||
written = answers or {}
|
||||
added = 0
|
||||
|
||||
for item in items:
|
||||
if item.kind != interaction.KIND_APPROVAL or len(entries) >= MAX_SCOPE_KEYS:
|
||||
continue
|
||||
pattern = agent_policy.subject(item.tool_name, item.detail)
|
||||
detail = item.detail
|
||||
if item.editable:
|
||||
detail = (written.get(item.key) or "").strip() or item.detail
|
||||
pattern = agent_policy.subject(item.tool_name, detail)
|
||||
if not pattern or pattern in entries:
|
||||
continue
|
||||
entries.append(pattern)
|
||||
|
||||
Reference in New Issue
Block a user