feat(connectors): add Runs may act to the approval settings

A trigger proposed from a chat can carry act.* permissions only when
the connection lists them in approval.acts. The connector settings
page had no control for it, so runs always read only.

Add a switch "Runs may act" under the approval mode of Propose
triggers. When on, four sensitive checkboxes (files, add-ons, send,
spend) say what a run may do. A box is disabled with a reason when the
agent lacks the matching grant, but a ticked one stays ticked and can
be unticked. A line says how the approval modes treat acts, and the
row of Propose triggers lists the acts that are set.

The page sends acts together with the mode and limits, because the
server replaces the whole approval object. An empty list means runs
only read. Names outside the four are rejected before saving.

Assisted-by: Claude:claude-sonnet-5-5
This commit is contained in:
Ettore Di Giacinto committed 2026-11-04 13:19:09 +00:00
1 parent 23594dcc60
commit af570ff17d
8 files changed
+183 -14

No files matched your search

@@ -3,9 +3,9 @@
// under the permission "Propose triggers" (see PermissionFields), as an option
// of it. The same on every platform, so the words live here as defaults, like the other shared
// fields. The server enforces the limits; the page only bounds what is typed.
import { useId } from 'react'
import { Field, RadioCards, TextInput } from './controls'
import { APPROVAL_LIMITS, APPROVAL_MODES, approvalProblem } from './connector'
import { useId, useState } from 'react'
import { Field, RadioCards, Switch, TextInput } from './controls'
import { APPROVAL_ACTS, APPROVAL_LIMITS, APPROVAL_MODES, agentHoldsAct, approvalProblem } from './connector'
export const APPROVAL_TEXT = {
'approval.title': 'Timers and triggers from the chat',
@@ -23,21 +23,50 @@ export const APPROVAL_TEXT = {
'approval.summary': 'Triggers: {{mode}}',
'approval.effect': 'Proposals: {{mode}}',
'approval.limits': 'Limits',
'approval.acts.title': 'Runs may act',
'approval.acts.help': 'Off: the runs of a trigger made from this chat only read and report. On: they may also do what you tick below, and only what the agent has.',
'approval.acts.modes': 'Ask me: you always decide. At once: only when you are paired as sender and every act is ticked here.',
'approval.acts.act.files': 'Write files in the agent\'s folders',
'approval.acts.act.files.effect': 'A run can create and change files the agent keeps.',
'approval.acts.act.addons': 'Use the actions of the agent\'s add-ons',
'approval.acts.act.addons.effect': 'A run can use what the add-ons of the agent do, including commands.',
'approval.acts.act.send': 'Send messages beyond where it reports',
'approval.acts.act.send.effect': 'A run can write to other chats and channels on its own.',
'approval.acts.act.spend': 'Use paid or external tools and the network',
'approval.acts.act.spend.effect': 'A run can call services outside LocalAI, which may cost money.',
'approval.acts.lacks': 'The agent does not have this.',
'approval.acts.sensitive': 'Sensitive',
'approval.effectActs': ', runs may: {{acts}}',
'approval.actShort.act.files': 'write files',
'approval.actShort.act.addons': 'use add-ons',
'approval.actShort.act.send': 'send messages',
'approval.actShort.act.spend': 'use paid or external tools',
'approval.short.ask': 'asks first',
'approval.short.auto': 'set up at once',
'approval.short.auto_readonly': 'at once if read-only',
}
/** The line under "Propose triggers": the mode, and the acts a run may do. */
export function approvalEffect(a, s) {
const base = s('approval.effect', { mode: s(`approval.short.${a.mode || 'ask'}`) })
const acts = APPROVAL_ACTS.filter((n) => (a.acts || []).includes(n))
return acts.length === 0 ? base : base + s('approval.effectActs', { acts: acts.map((n) => s(`approval.actShort.${n}`)).join(', ') })
}
/**
* The approval mode and its limits, as an option under "Propose triggers".
* With off set (the permission is not ticked) the choice is greyed, with the
* reason, and nothing can be changed. The limits sit in a closed disclosure.
*/
export default function ApprovalOptions({ form, setForm, s, off = false }) {
export default function ApprovalOptions({ form, setForm, s, off = false, held = null }) {
const a = form.approval
const reasonId = useId()
const [actsOpen, setActsOpen] = useState(false)
const set = (patch) => setForm({ ...form, approval: { ...a, ...patch } })
const bad = approvalProblem(a)
const acts = a.acts || []
const actsOn = acts.length > 0 || actsOpen
const toggle = (name, v) => set({ acts: APPROVAL_ACTS.filter((n) => (n === name ? v : acts.includes(n))) })
return (
<fieldset className="cn-option" data-testid="connector-approval" data-off={off ? 'true' : undefined} disabled={off} aria-describedby={off ? reasonId : undefined}>
<legend className="cn-vh">{s('approval.title')}</legend>
@@ -46,6 +75,36 @@ export default function ApprovalOptions({ form, setForm, s, off = false }) {
{off && <p className="cn-muted" id={reasonId} data-testid="connector-approval-off">{s('perm.triggersNeed')}</p>}
<RadioCards name="approval" legend={s('approval.title')} value={a.mode} onChange={(mode) => set({ mode })}
options={APPROVAL_MODES.map((m) => ({ value: m, label: s(`approval.mode.${m}`) }))} />
<div className="cn-acts" data-testid="connector-acts">
<Switch checked={actsOn} testId="connector-acts-switch" onChange={(v) => { setActsOpen(v); if (!v) set({ acts: [] }) }}>
<span className="cn-cap__title">{s('approval.acts.title')}</span>
</Switch>
<p className="dk-hint">{s('approval.acts.help')}</p>
{actsOn && (
<ul className="cn-acts__list">
{APPROVAL_ACTS.map((n) => {
const on = acts.includes(n)
const lacks = !agentHoldsAct(held, n)
const blocked = lacks && !on
return (
<li key={n} className="cn-acts__item" data-testid={`connector-act-${n}`} data-lacks={lacks ? 'true' : undefined}>
<label className="cn-acts__label">
<input type="checkbox" className="dk-check" data-testid={`connector-act-check-${n}`} checked={on} disabled={blocked}
aria-describedby={lacks ? `${reasonId}-${n}` : undefined} onChange={(e) => toggle(n, e.target.checked)} />
<span className="cn-acts__text">
<span className="cn-cap__title">{s(`approval.acts.${n}`)}</span>
<span className="cn-badge cn-badge--warn">{s('approval.acts.sensitive')}</span>
</span>
</label>
<p className="cn-cap__effect">{s(`approval.acts.${n}.effect`)}</p>
{lacks && <p className="cn-cap__lacks" id={`${reasonId}-${n}`} data-testid={`connector-act-lacks-${n}`}>{s('approval.acts.lacks')}</p>}
</li>
)
})}
</ul>
)}
<p className="dk-hint" data-testid="connector-acts-modes">{s('approval.acts.modes')}</p>
</div>
<details className="cn-option__limits" data-testid="connector-approval-limits" open={bad ? true : undefined}>
<summary>{s('approval.limits')}</summary>
<p className="dk-hint">{s('approval.limitsHelp')}</p>
@@ -10,7 +10,7 @@ import { Field, Hint, Notice, Switch, TextInput } from './controls'
import Icon from '../../host-compat/src/components/Icon'
import { MAX_CHANNELS, MAX_CHANNEL_ID, MAX_CHANNEL_LABEL, agentHas, agentHeld, channelsProblem, copyFromAgent, grantableFor, hasNoCaps, presetBlock, roleHas, setCap, setGrant, unpairedSensitive, usualFor, withAddedCaps, withCopiedCaps } from './connector'
import PairingFields from './PairingFields'
import ApprovalOptions from './ApprovalFields'
import ApprovalOptions, { approvalEffect } from './ApprovalFields'
export const PERMISSION_TEXT = {
'perm.title': 'What senders may do',
@@ -307,8 +307,8 @@ export default function PermissionFields({ form, setForm, kind, capabilities, s,
<Presets key={`${form.id || 'new'}:${form.agent_id || ''}`} form={form} setForm={setForm} capabilities={caps} agent={agent} s={s} />
<ul className="cn-caps" aria-label={s('perm.capsLegend')}>
{caps.map((c) => <Cap key={c.name} cap={c} on={on(c.name)} s={s}
effect={c.name === 'triggers.propose' && form.approval ? s('approval.effect', { mode: s(`approval.short.${form.approval.mode || 'ask'}`) }) : undefined} lacks={Boolean(form.agent_id) && held !== null && !agentHas(held, c.name)} onChange={(v) => setForm(setCap(form, c.name, v))}>
{c.name === 'triggers.propose' && form.approval && <ApprovalOptions form={form} setForm={setForm} s={s} off={!on(c.name)} />}
effect={c.name === 'triggers.propose' && form.approval ? approvalEffect(form.approval, s) : undefined} lacks={Boolean(form.agent_id) && held !== null && !agentHas(held, c.name)} onChange={(v) => setForm(setCap(form, c.name, v))}>
{c.name === 'triggers.propose' && form.approval && <ApprovalOptions form={form} setForm={setForm} s={s} off={!on(c.name)} held={held} />}
</Cap>)}
</ul>
<Roles form={form} setForm={setForm} kind={kind} capabilities={caps} s={s} />
@@ -207,3 +207,10 @@ fieldset.cn-group {
.cn-option__title { margin: 0; font-size: var(--fs-small); line-height: var(--lh-small); font-weight: var(--font-weight-semibold); color: var(--color-text-primary); }
.cn-option__limits summary { cursor: pointer; font-size: var(--fs-small); color: var(--color-text-secondary); }
.cn-option__limits[open] summary { margin-bottom: var(--dk-space-2); }
.cn-acts { display: flex; flex-direction: column; gap: var(--dk-space-2); }
.cn-acts__list { list-style: none; margin: 0; padding: 0 0 0 var(--dk-space-3); display: flex; flex-direction: column; gap: var(--dk-space-2); }
.cn-acts__item { display: flex; flex-direction: column; gap: 2px; }
.cn-acts__label { display: flex; align-items: flex-start; gap: var(--dk-space-2); }
.cn-acts__label .dk-check { flex: none; margin-top: 2px; }
.cn-acts__text { display: flex; align-items: center; flex-wrap: wrap; gap: 2px var(--dk-space-2); min-width: 0; }
.cn-acts__item[data-lacks="true"] .cn-acts__label { opacity: 0.7; }
+15 -3
View File
@@ -17,14 +17,23 @@ export const APPROVAL_LIMITS = [
{ key: 'max_lifetime_days', max: 365, fallback: 30 },
]
// The acts a run of a trigger proposed from a chat may be allowed to do. The
// names are also the grants of the agent, which must hold the same one. Every
// one is sensitive.
export const APPROVAL_ACTS = ['act.files', 'act.addons', 'act.send', 'act.spend']
export function emptyApproval() {
return { mode: 'ask', max_pending: '', min_gap_minutes: '', max_runs_per_day: '', max_lifetime_days: '' }
return { mode: 'ask', acts: [], max_pending: '', min_gap_minutes: '', max_runs_per_day: '', max_lifetime_days: '' }
}
// Whether the agent holds the grant behind an act. held is agentHeld(agent);
// null (permissions unreadable) gates nothing.
export const agentHoldsAct = (held, act) => held === null || held === undefined || held.includes(act)
// The approval of a saved binding as a form: a limit of zero is the default
// and shows empty.
export function approvalForm(a) {
const f = { ...emptyApproval(), mode: a?.mode || 'ask' }
const f = { ...emptyApproval(), mode: a?.mode || 'ask', acts: Array.isArray(a?.acts) ? [...a.acts] : [] }
for (const { key } of APPROVAL_LIMITS) f[key] = a?.[key] > 0 ? String(a[key]) : ''
return f
}
@@ -34,6 +43,7 @@ export function approvalForm(a) {
export function approvalProblem(a) {
if (!a) return ''
if (!APPROVAL_MODES.includes(a.mode)) return 'mode'
if ((a.acts || []).some((n) => !APPROVAL_ACTS.includes(n))) return 'acts'
for (const { key, max } of APPROVAL_LIMITS) {
const v = String(a[key] ?? '').trim()
if (v === '') continue
@@ -44,7 +54,9 @@ export function approvalProblem(a) {
}
export function approvalPayload(a) {
const body = { mode: APPROVAL_MODES.includes(a?.mode) ? a.mode : 'ask' }
// The whole approval is sent each time (the server replaces it), acts too;
// an empty list means runs only read.
const body = { mode: APPROVAL_MODES.includes(a?.mode) ? a.mode : 'ask', acts: APPROVAL_ACTS.filter((n) => (a?.acts || []).includes(n)) }
for (const { key } of APPROVAL_LIMITS) body[key] = Number(String(a?.[key] ?? '').trim()) || 0
return body
}
+24 -3
View File
@@ -142,14 +142,14 @@ test('chat connectors: a saved limit reads back to the same number of bytes', ()
test('chat connectors: the approval of chat triggers asks by default and sends its limits as numbers', () => {
const base = { ...emptyConnector(), name: 'B', agent_id: 'a' }
assert.deepEqual(connectorPayload(base, 'telegram').approval, { mode: 'ask', max_pending: 0, min_gap_minutes: 0, max_runs_per_day: 0, max_lifetime_days: 0 })
assert.deepEqual(connectorPayload(base, 'telegram').approval, { mode: 'ask', acts: [], max_pending: 0, min_gap_minutes: 0, max_runs_per_day: 0, max_lifetime_days: 0 })
const auto = connectorPayload({ ...base, approval: { mode: 'auto', max_pending: '3', min_gap_minutes: '', max_runs_per_day: ' 8 ', max_lifetime_days: '14' } }, 'telegram')
assert.deepEqual(auto.approval, { mode: 'auto', max_pending: 3, min_gap_minutes: 0, max_runs_per_day: 8, max_lifetime_days: 14 })
assert.deepEqual(auto.approval, { mode: 'auto', acts: [], max_pending: 3, min_gap_minutes: 0, max_runs_per_day: 8, max_lifetime_days: 14 })
assert.equal(connectorPayload({ ...base, approval: { mode: 'sometimes' } }, 'telegram').approval.mode, 'ask', 'an unknown mode falls back to ask')
})
test('chat connectors: a saved approval reads back, with a zero limit shown as empty', () => {
assert.deepEqual(approvalForm({ mode: 'auto_readonly', max_pending: 4, min_gap_minutes: 0 }), { mode: 'auto_readonly', max_pending: '4', min_gap_minutes: '', max_runs_per_day: '', max_lifetime_days: '' })
assert.deepEqual(approvalForm({ mode: 'auto_readonly', max_pending: 4, min_gap_minutes: 0 }), { mode: 'auto_readonly', acts: [], max_pending: '4', min_gap_minutes: '', max_runs_per_day: '', max_lifetime_days: '' })
assert.equal(connectorForm({ id: 'b' }).approval.mode, 'ask')
})
@@ -163,3 +163,24 @@ test('chat connectors: an approval limit is a whole number within its bound, or
assert.equal(canSave({ ...ok, approval: { mode: 'auto', max_pending: '500' } }, false), false)
assert.equal(canSave(ok, false), true)
})
test('chat connectors: approval acts round trip, are sent with the mode and limits, and an empty list is sent as []', () => {
const saved = { mode: 'auto', acts: ['act.spend', 'act.files'], max_pending: 3 }
const form = approvalForm(saved)
assert.deepEqual(form.acts, ['act.spend', 'act.files'])
const out = approvalPayload(form)
assert.deepEqual(out, { mode: 'auto', acts: ['act.files', 'act.spend'], max_pending: 3, min_gap_minutes: 0, max_runs_per_day: 0, max_lifetime_days: 0 })
assert.deepEqual(approvalPayload({ ...form, acts: [] }).acts, [])
assert.deepEqual(approvalPayload({ mode: 'ask' }).acts, [])
assert.deepEqual(approvalForm({ mode: 'ask' }).acts, [])
})
test('chat connectors: approval acts replace the whole object, so a mode change keeps them, and only the four names are valid', () => {
const base = { ...connectorForm({ id: 'b', approval: { mode: 'ask', acts: ['act.addons'] } }) }
const changed = connectorPayload({ ...base, approval: { ...base.approval, mode: 'auto' } }, 'telegram')
assert.equal(changed.approval.mode, 'auto')
assert.deepEqual(changed.approval.acts, ['act.addons'])
assert.deepEqual(approvalPayload({ mode: 'ask', acts: ['act.files', 'act.bogus'] }).acts, ['act.files'])
assert.equal(approvalProblem({ mode: 'ask', acts: ['act.send'] }), '')
assert.equal(approvalProblem({ mode: 'ask', acts: ['act.bogus'] }), 'acts')
})
+1
View File
@@ -5,6 +5,7 @@
export const AGENTS = [
{ id: 'a1', name: 'Support', model: 'qwen3-8b', mcp_servers: [], addons: [], collections: [], skills: [], builtins: ['memory.write', 'files.upload', 'triggers.propose', 'triggers.run', 'talkback.channels', 'talkback.new_channel', 'profile.write'] },
{ id: 'a2', name: 'No model', model: '', mcp_servers: [], addons: [], collections: [], skills: [], builtins: [] },
{ id: 'a4', name: 'Builder', model: 'qwen3-8b', mcp_servers: [], addons: [], collections: [], skills: [], builtins: ['memory.write', 'triggers.propose', 'act.files', 'act.addons'] },
{ id: 'a3', name: 'Careful', model: 'qwen3-8b', mcp_servers: [], addons: [], collections: [], skills: [], builtins: ['memory.write', 'files.upload'] },
]
@@ -353,7 +353,74 @@ test.describe('Connector permissions', () => {
await limits.getByText('Limits', { exact: true }).click()
await limits.getByTestId('connector-approval-max_pending').fill('3')
await page.getByRole('button', { name: 'Save' }).click()
await expect.poll(() => a.s.calls.find(c => c.method === 'PUT')?.body.approval).toEqual({ mode: 'auto_readonly', max_pending: 3, min_gap_minutes: 0, max_runs_per_day: 0, max_lifetime_days: 0 })
await expect.poll(() => a.s.calls.find(c => c.method === 'PUT')?.body.approval).toEqual({ mode: 'auto_readonly', acts: [], max_pending: 3, min_gap_minutes: 0, max_runs_per_day: 0, max_lifetime_days: 0 })
})
test('Runs may act sits under the approval mode, is off by default, lists the four acts, disables what the agent lacks and is sent with the whole approval', async ({ page }) => {
const a = api(page, [{ ...LEGACY, agent_id: 'a4', caps: ['triggers.propose'], approval: { mode: 'auto', acts: [], max_pending: 3 } }])
await a.install()
await page.goto(PAGE)
await page.getByRole('button', { name: 'Edit' }).click()
const row = page.getByTestId('connector-cap-triggers.propose')
const acts = row.getByTestId('connector-acts')
await expect(acts.getByTestId('connector-acts-switch')).toHaveAttribute('aria-checked', 'false')
await expect(acts.getByRole('checkbox')).toHaveCount(0)
await expect(acts.getByTestId('connector-acts-modes')).toHaveText('Ask me: you always decide. At once: only when you are paired as sender and every act is ticked here.')
await shot(page, 'acts-1-off')
await acts.getByTestId('connector-acts-switch').click()
await expect(acts.getByRole('checkbox')).toHaveCount(4)
await expect(acts.getByTestId('connector-act-act.files')).toContainText("Write files in the agent's folders")
await expect(acts.getByTestId('connector-act-act.addons')).toContainText("Use the actions of the agent's add-ons")
await expect(acts.getByTestId('connector-act-act.send')).toContainText('Send messages beyond where it reports')
await expect(acts.getByTestId('connector-act-act.spend')).toContainText('Use paid or external tools and the network')
await expect(acts.getByText('Sensitive')).toHaveCount(4)
// The agent holds files and add-ons only.
await expect(acts.getByTestId('connector-act-check-act.files')).toBeEnabled()
await expect(acts.getByTestId('connector-act-check-act.send')).toBeDisabled()
await expect(acts.getByTestId('connector-act-lacks-act.send')).toHaveText('The agent does not have this.')
await expect(acts.getByTestId('connector-act-check-act.send')).toHaveAccessibleDescription('The agent does not have this.')
await expect(acts.getByTestId('connector-act-lacks-act.files')).toHaveCount(0)
await shot(page, 'acts-3-lacks')
await acts.getByTestId('connector-act-check-act.addons').check()
await acts.getByTestId('connector-act-check-act.files').check()
await expect(row.getByTestId('connector-cap-effect-triggers.propose')).toHaveText('Proposals: set up at once, runs may: write files, use add-ons')
await shot(page, 'acts-2-on')
await page.getByRole('button', { name: 'Save' }).click()
await expect.poll(() => a.s.calls.find(c => c.method === 'PUT')?.body.approval).toEqual({ mode: 'auto', acts: ['act.files', 'act.addons'], max_pending: 3, min_gap_minutes: 0, max_runs_per_day: 0, max_lifetime_days: 0 })
})
test('a ticked act the agent no longer holds stays ticked and can be unticked; turning Runs may act off sends an empty list', async ({ page }) => {
const a = api(page, [{ ...LEGACY, agent_id: 'a1', caps: ['triggers.propose'], approval: { mode: 'ask', acts: ['act.send', 'act.spend'] } }])
await a.install()
await page.goto(PAGE)
await page.getByRole('button', { name: 'Edit' }).click()
const acts = page.getByTestId('connector-acts')
await expect(acts.getByTestId('connector-acts-switch')).toHaveAttribute('aria-checked', 'true')
await expect(acts.getByTestId('connector-act-check-act.send')).toBeChecked()
await expect(acts.getByTestId('connector-act-check-act.send')).toBeEnabled()
await expect(acts.getByTestId('connector-act-lacks-act.send')).toBeVisible()
await expect(acts.getByTestId('connector-act-check-act.files')).toBeDisabled()
await acts.getByTestId('connector-act-check-act.send').uncheck()
await expect(acts.getByTestId('connector-act-check-act.send')).toBeDisabled()
await acts.getByTestId('connector-acts-switch').click()
await expect(acts.getByRole('checkbox')).toHaveCount(0)
await page.getByRole('button', { name: 'Save' }).click()
await expect.poll(() => a.s.calls.find(c => c.method === 'PUT')?.body.approval).toEqual({ mode: 'ask', acts: [], max_pending: 0, min_gap_minutes: 0, max_runs_per_day: 0, max_lifetime_days: 0 })
})
test('Runs may act is greyed with the reason while Propose triggers is off', async ({ page }) => {
await api(page, [{ ...LEGACY, agent_id: 'a4', caps: ['memory.write'], approval: { mode: 'ask', acts: ['act.files'] } }]).install()
await page.goto(PAGE)
await page.getByRole('button', { name: 'Edit' }).click()
const row = page.getByTestId('connector-cap-triggers.propose')
await expect(row.getByTestId('connector-approval-off')).toHaveText('Turn on Propose triggers to use this.')
await expect(row.getByTestId('connector-acts-switch')).toBeDisabled()
await expect(row.getByTestId('connector-act-check-act.files')).toBeDisabled()
await shot(page, 'acts-4-greyed')
await row.getByTestId('connector-cap-switch-triggers.propose').click()
await expect(row.getByTestId('connector-act-check-act.files')).toBeEnabled()
await expect(row.getByTestId('connector-cap-effect-triggers.propose')).toHaveText('Proposals: asks first, runs may: write files')
await shot(page, 'acts-5-summary')
})
test('an older host that sends no capability list shows no permission section', async ({ page }) => {
@@ -704,6 +704,8 @@ the network). The default is an empty list. Any other name is refused with `400`
present `approval` object replaces the whole object, so send `acts` again when you change
the mode or a limit.
On the settings page of the connector add-on, **Runs may act** is a switch under the approval mode of the permission **Propose triggers** (greyed while that permission is off). It is off by default. When it is on, four checkboxes name the acts in plain words: write files in the agent's folders, use the actions of the agent's add-ons, send messages beyond where it reports, and use paid or external tools and the network. All four are marked sensitive. A box is disabled with "The agent does not have this." when the agent lacks the matching grant; a box that is already ticked stays ticked and can be unticked. The line of the permission shows the choice, for example "Proposals: set up at once, runs may: write files, use add-ons". The page always sends the mode, the limits and `acts` together.
The list is a ceiling, not a grant. The acts of a proposal are what the model asks for in
`acts`, limited to this list, limited to what the agent holds: