feat(studio): enforce the action policy of unattended runs

A trigger run or an agent job has nobody to approve a step, and so far
the approval mode auto_readonly and the act.* grants were only recorded.
The prompt was the only thing that kept such a run from acting.

Every tool call of an unattended run now goes through one check at the
call. A tool that only reads runs. Any other tool needs the grant of its
class, and the check fails closed:

- act.files for write_file, edit_file, move_file, delete_file and the
  built-in bash;
- act.addons, or act.addons:<id>, for add-on tools whose declared kind is
  not read, plus act.spend when the add-on gates the tool with a
  permission;
- act.spend for MCP tools.

A trigger holds its new acts list within the grants of its agent. A
trigger that declared read only runs, or that a binding in auto_readonly
mode made without asking, is refused every non-read tool whatever it
holds. A binding that cannot be read counts as auto_readonly. A job
holds the act grants of its agent. Sub-agents follow their parent, and a
saved agent cannot be started from an unattended run.

A refused call answers with a plain sentence the model can pass on, not
an error, and writes one audit log line. Triggers set acts through the
REST API; a model never can. A trigger whose plan names a file it writes
keeps act.files.

Assisted-by: Claude:claude-sonnet-5-5 go ginkgo swag
This commit is contained in:
Ettore Di Giacinto committed 2026-11-03 19:12:29 +00:00
1 parent 3cef8d5a91
commit 6b181cd20b
22 files changed
+760 -9

No files matched your search

+7
View File
@@ -2118,6 +2118,13 @@ person to confirm.
never as authority.
- **Undo.** What a tool does to the user's files is a change in the history of
their workspace, and it can be undone there.
- **Runs with nobody present.** A trigger or an agent job calls the tools of an
add-on under the action policy of unattended runs. The `kind` of the tool
decides the class: `read` always runs, and every other kind is refused unless
the run holds `act.addons` (or `act.addons:<add-on id>`). A tool that the host
gates with a permission, because the add-on holds a credential, also needs
`act.spend`. Declare `read` only for a tool that changes nothing. See
[Action policy of unattended runs](../chats-agents/#action-policy-of-unattended-runs).
## Host operations
+52 -2
View File
@@ -276,8 +276,8 @@ on the list. The moment you grant any built-in, the list is the whole answer, so
add the connector grants you want to keep (granting only `memory.write` to an agent
that proposed triggers from a messaging app turns those off; nothing is deleted
and they work again when you grant `triggers.propose`). The `act.*` grants are
recorded but not enforced yet: the action policy of unattended runs that uses them
is still to come.
the ceiling of the action policy of unattended runs (see
[Action policy of unattended runs](#action-policy-of-unattended-runs)).
### Export and import
@@ -399,6 +399,56 @@ message as rules for the run. Treat it as text from whoever can write there.
"the agent's folder is missing" and switches the trigger off. Each of these
tells the owner.
### Action policy of unattended runs
A trigger run or an agent job has nobody at the screen to approve a step, so it
starts read only. LocalAI checks every tool call of such a run at the moment of
the call, not in the prompt. A tool that only reads always runs. Any other tool
runs only if the run holds the grant for its class. Add-on tools and MCP tools
always have a class. The other built-in tools either read or need a grant that a
run which started by itself never holds. Nothing is allowed on a doubt: a trigger or a connection
that cannot be read at that moment gives a read only run.
| Class | Grant | Tools |
|---|---|---|
| Read | none | Tools that only read: the add-on tools of kind `read`, the file reading tools, web search and fetch, and the other built-in tools that only read. |
| Files | `act.files` | `write_file`, `edit_file`, `move_file`, `delete_file`, and the built-in `bash` of this machine. |
| Add-on actions | `act.addons`, or `act.addons:<add-on id>` for one add-on | Every add-on tool whose kind is not `read` (`action`, `write`, `schema`, `create`), including an add-on that serves `bash`. |
| Outside | `act.spend` | MCP tools (of a server or a connector), and an add-on action tool that the add-on declares with a permission, which means it acts outside LocalAI with a credential. The last one needs `act.addons` as well. |
| Send | `act.send` | Reserved for messages beyond the delivery target. No tool of this class exists yet. |
What a run holds:
- **A trigger run** holds the `acts` of its trigger, limited to the `act.*`
grants of its agent (`builtins`). A trigger without an agent has no ceiling but
its own `acts`. Set `acts` in the body of `POST /api/chats/triggers` or
`PUT /api/chats/triggers/{id}`: leave it out to keep the value, send `[]` to
clear it. Only a person can set it. A trigger that a model proposes in a chat
never carries `acts`, and neither does the trigger a connector makes without
asking. A trigger whose plan names a file it writes (`plan.writes`) also holds
`act.files`, because you saw that file on the proposal card. Triggers that exist
before this change keep running, but they now read only unless they set `acts`
or `plan.writes`.
- **An agent job run** holds the `act.*` grants of its agent, and nothing without
an agent. A job has no `acts` of its own.
- **Read only runs.** A trigger whose proposal said its runs only read
(`access: read_only`), or that a connection made without asking while its
approval mode is `auto_readonly` (see
[Approval modes](../chats-connectors/#approval-modes)), holds nothing: every
tool that is not a read is refused, whatever `acts` and the agent say. A
connection that cannot be read is treated as `auto_readonly`.
- **Sub-agents.** A sub-agent of an unattended run has the same limits as its
parent. A run that nobody watches cannot start a saved agent as a sub-agent.
A refused tool stays in the tool list and answers with one plain sentence instead
of running, for example "I did not run write_file: nobody is here to approve it
and this run has no permission for act.files. Say in your answer what you wanted
to do; the owner can allow act.files for this run." The model then reports it in
its answer. The refusal is a result, not an error, so the model does not retry.
Each call of a tool that is not a read writes one log line, `studio: unattended
action`, with the chat, the trigger or job, the tool, `allowed`, `reason`
(`granted`, `not_granted` or `read_only`) and the missing grant.
### Triggers of an agent with a folder
A trigger whose agent has a [folder](#folders-of-an-agent) keeps to that
+1 -1
View File
@@ -615,7 +615,7 @@ page of the connector add-on, or in the `approval` field of the binding
|---|---|
| `ask` (default) | The bot posts the proposal and waits for a plain answer. |
| `auto` | The trigger is made at once. The bot tells the person what was set up and that `cancel` (within 30 minutes) or `cancel CODE` (any time) removes it. |
| `auto_readonly` | Like `auto` when the proposal says its runs only read and report (`access: read_only`, no file written); any other proposal asks. |
| `auto_readonly` | Like `auto` when the proposal says its runs only read and report (`access: read_only`, no file written); any other proposal asks. The runs of a trigger made this way can use only tools that read: every other tool is refused at the call, whatever the agent holds (see [Action policy of unattended runs](../chats-agents/#action-policy-of-unattended-runs)). |
The automatic modes are bounded by limits of the owner. Anything over a limit is not
refused but asked instead, and the proposal says why.