Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/540-always-allow-approval.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Tool approval cards in a group and in the Inbox add **Always allow for this Bot** and **Always allow for this group** next to Reject / Allow once, so a Bot stops asking for the same kind of permission (for example a `bash` sandbox escalation to full access). Standing approvals are kept across restarts and listed under **Always allowed** in Bot settings and Group settings, each with Remove ([#540](https://github.com/ThinkFlowLab/FlyBot/issues/540)).
4,231 changes: 2,140 additions & 2,091 deletions core/client.js

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions core/src/client/bot-settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { BotConnectorsCard } from './connector-bots.js'
import { useMemoryCount } from './memory-panel.js'
import { SPRING_SHAPE, SPRING_TAP, spring, useGlide } from './motion.js'
import { t } from './i18n.js'
import { StandingApprovalsCard } from './standing-approvals.js'

const capitalize = word => word[0].toUpperCase() + word.slice(1)

Expand Down Expand Up @@ -243,6 +244,7 @@ export function BotSettingsForm({ bot, roster, actions, run }) {
h(BotConnectorsCard, { bot, roster, actions, run, Card, SwitchRow }),
roster.memory === false ? null : h(MemoryCard, { bot, actions }),
h(BotSchedules, { bot, roster, actions }),
h(StandingApprovalsCard, { scope: 'bot', target: bot, roster, actions, run, Card }),
h(Card, { title: t('Main Bot') },
onlyMain
? h('div', { className: 'bt-set-row bt-bs-row' },
Expand Down
2 changes: 2 additions & 0 deletions core/src/client/group-settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Card, Row, InlineText, SwitchRow } from './bot-settings.js'
import { Segmented } from './motion.js'
import { FolderMenu } from './folder-menu.js'
import { t } from './i18n.js'
import { StandingApprovalsCard } from './standing-approvals.js'
import { remoteBotTitle } from '../shared/remote-label.js'

export const ROOM_MODES = [
Expand Down Expand Up @@ -209,6 +210,7 @@ export function GroupSettingsForm({ room, roster, actions, run, onDeleted }) {
members.length < 2 ? t('Add a Bot first: a group chat needs at least two.')
: admin ? t('{name} leads this group: it can call members with @Name, change the group, and post here.', { name: admin.name })
: t('No admin yet. An admin can call members with @Name, change the group, and post here.')))),
h(StandingApprovalsCard, { scope: 'room', target: room, roster, actions, run, Card }),
h(Card, { title: t('Sidebar') },
h(SwitchRow, { label: t('Pin'), hint: t('Kept above the other chats.'), on: room.pinned === true, onChange: pinned => run(actions.setFlags(room.id, { pinned })) }),
h(SwitchRow, { label: t('Hide from sidebar'), hint: t('Search still finds it.'), on: room.hidden === true, onChange: hidden => run(actions.setFlags(room.id, { hidden })) })),
Expand Down
8 changes: 8 additions & 0 deletions core/src/client/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -670,6 +670,14 @@ const ZH = {
'in its own chat': '在自己的对话里',
Reject: '拒绝',
'Allow once': '允许一次',
'Always allow for this Bot': '此 Bot 始终允许',
'Always allow for this group': '本群始终允许',
'Always allowed for {name}': '已为 {name} 始终允许',
'Always allowed in this group': '已在本群始终允许',
'Always allowed': '始终允许',
'Your Bots in this group do these without asking.': '你在本群的 Bot 做这些时不再询问。',
'This Bot does these without asking.': '此 Bot 做这些时不再询问。',
'Remove always allow for {kind}': '移除 {kind} 的始终允许',
"Couldn't message {name}": '没能给 {name} 发消息',
Messaged: '已发消息给',
"Couldn't stop {name}": '没能叫停 {name}',
Expand Down
6 changes: 5 additions & 1 deletion core/src/client/inbox.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { CloseIcon } from './icons.js'
import { workPhrase } from './cells.js'
import { elapsedLabel } from './activity-row.js'
import { useEscape } from './overlay-hooks.js'
import { alwaysChoices } from './member-status.js'
import { currentPart, previewOf, relativeShort } from './text.js'
import { hostText, t } from './i18n.js'

Expand Down Expand Up @@ -270,6 +271,7 @@ export function InboxCard({ card, roster, now, busy, error, onAnswer, onOpen })
h('span', { className: 'bt-inbox-actions' },
answerable ? h('button', { type: 'button', className: 'bt-soft bt-soft-sm', disabled: busy, onClick: () => onAnswer(card, 'reject') }, t('Reject')) : null,
answerable ? h('button', { type: 'button', className: 'bt-send bt-soft-sm', disabled: busy, onClick: () => onAnswer(card, 'allow') }, t('Allow once')) : null,
...(answerable ? alwaysChoices(card.approval, card.bot) : []).map(([choice, label]) => h('button', { key: choice, type: 'button', className: 'bt-soft bt-soft-sm', 'data-choice': choice, disabled: busy, onClick: () => onAnswer(card, choice) }, t(label))),
h('button', { type: 'button', className: 'bt-soft bt-soft-sm', 'aria-label': who ? t('Open {name}', { name: card.room?.name ?? who }) : t('Open'), onClick: () => onOpen(card) }, t('Open'))))
}

Expand Down Expand Up @@ -308,7 +310,9 @@ export function InboxDialog({ roster, useWork, useSessions, actions, leaving = f
.then(() => {
setAnswered(value => ({ ...value, [card.id]: choice }))
const name = card.bot?.name ?? ''
setFeedback(choice === 'allow' ? t('Allowed once for {name}', { name }) : t('Rejected for {name}', { name }))
setFeedback(choice === 'allow' ? t('Allowed once for {name}', { name })
: choice === 'always-bot' ? t('Always allowed for {name}', { name })
: choice === 'always-room' ? t('Always allowed in this group') : t('Rejected for {name}', { name }))
}, failure => setErrors(value => ({ ...value, [card.id]: hostText(failure?.message ?? String(failure)) })))
.finally(() => setBusy(''))
}
Expand Down
1 change: 1 addition & 0 deletions core/src/client/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -585,6 +585,7 @@ export function apply(ctx) {
answerQuestion: (sessionId, questionId, answer) => after(call('answer-question', { sessionId: partFor(sessionId), questionId, answer })),
dismissQuestion: sessionId => after(call('dismiss-question', { sessionId })),
answerApproval: (id, choice) => after(call('approval-answer', { id, choice })),
removeStandingApproval: (scope, id, kind) => after(call('standing-approval-remove', { scope, id, kind })),
stopMemberWork: (roomId, botId) => after(call('stop-member-work', { roomId, botId })),
// Values go out in these requests and never come back in a response.
secretSet: payload => after(call('secret-set', payload)),
Expand Down
10 changes: 9 additions & 1 deletion core/src/client/member-status.js
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,13 @@ function useNow(active) {

const since = (now, at) => (typeof at === 'number' && now - at >= 60000 ? ` · ${elapsedLabel(now - at)}` : '')

// "Always allow" is the owner's standing grant (#540): only on their own Bot's card,
// and the group option only when the request comes from a group.
export const alwaysChoices = (approval, bot) => (bot?.remote ? [] : [
['always-bot', 'Always allow for this Bot'],
...(approval.roomId ? [['always-room', 'Always allow for this group']] : []),
])

function ApprovalCard({ approval, bot, actions }) {
const [busy, setBusy] = useState(false)
const answer = (choice) => {
Expand All @@ -176,7 +183,8 @@ function ApprovalCard({ approval, bot, actions }) {
approval.answerable
? h('div', { className: 'bt-mw-actions' },
h('button', { type: 'button', className: 'bt-q-btn', disabled: busy, onClick: () => answer('reject') }, t('Reject')),
h('button', { type: 'button', className: 'bt-q-submit', disabled: busy, onClick: () => answer('allow') }, t('Allow once')))
h('button', { type: 'button', className: 'bt-q-submit', disabled: busy, onClick: () => answer('allow') }, t('Allow once')),
...alwaysChoices(approval, bot).map(([choice, label]) => h('button', { key: choice, type: 'button', className: 'bt-q-btn', 'data-choice': choice, disabled: busy, onClick: () => answer(choice) }, t(label))))
: h('div', { className: 'bt-mw-actions' },
h('button', { type: 'button', className: 'bt-q-btn', onClick: () => actions.openSession(bot.id) }, t("Open {name}'s chat", { name: bot.name }))))
}
Expand Down
27 changes: 27 additions & 0 deletions core/src/client/standing-approvals.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// Standing approvals (#540): what the owner chose "Always allow" for, on a Bot or a
// group, each with Remove. A group lists only the grants this person gave (all of them
// on a host nobody signed in to).
import { createElement as h } from 'react'
import { t } from './i18n.js'

// Without a sign-in the host has one person, who gave every grant.
export const standingOf = (target, roster) => {
const me = roster?.self?.memberId
return (target?.standingApprovals ?? []).filter(grant => me === undefined || grant.ownerId === me)
}

export function StandingApprovalsCard({ scope, target, roster, actions, run, Card }) {
const grants = scope === 'room' ? standingOf(target, roster) : (target?.standingApprovals ?? [])
if (grants.length === 0) return null
const hint = scope === 'room' ? t('Your Bots in this group do these without asking.') : t('This Bot does these without asking.')
return h(Card, { title: t('Always allowed') },
grants.map(grant => h('div', { key: grant.kind, className: 'bt-set-row bt-bs-row', 'data-standing': grant.kind },
h('span', { className: 'bt-set-copy' },
h('span', { className: 'bt-bs-label' }, grant.label || grant.kind),
h('span', { className: 'bt-set-hint' }, hint)),
h('button', {
type: 'button', className: 'bt-soft bt-soft-sm',
'aria-label': t('Remove always allow for {kind}', { kind: grant.label || grant.kind }),
onClick: () => run(actions.removeStandingApproval(scope, target.id, grant.kind)),
}, t('Remove')))))
}
71 changes: 68 additions & 3 deletions core/src/host/approvals.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,21 @@
// the group chat. A Bot's own chat keeps DSH's card; its requests are only noted, so
// the team view can say who is waiting.
import { randomUUID } from 'node:crypto'
import { OWNER, settled } from './turn-origin.js'

const OUTCOMES = { allow: 'allowed-once', reject: 'rejected' }
const OUTCOMES = { allow: 'allowed-once', reject: 'rejected', 'always-bot': 'allowed-once', 'always-room': 'allowed-once' }

// DSH has no standing grant (its outcomes are one-shot, #540), so the owner's "Always
// allow" lives here: on the Bot or on the group, keyed by the kind of permission asked.
// A sandbox escalation names its mode in DSH's reason; any other ask is keyed by tool.
export function approvalKind(tool, reason) {
const mode = /^escalate sandbox to ([\w-]+):/.exec(String(reason ?? ''))?.[1]
return mode ? `${tool}:escalate:${mode}` : String(tool)
}
const kindLabel = (tool, reason) => {
const mode = /^escalate sandbox to ([\w-]+):/.exec(String(reason ?? ''))?.[1]
return mode ? `${tool} · ${mode}` : String(tool)
}

export function install(rt) {
const { ctx } = rt
Expand All @@ -17,6 +30,7 @@ export function install(rt) {
const owner = sessionId === undefined ? undefined : rt.groupOwners.get(sessionId)
const botId = owner?.botId ?? (sessionId === undefined ? undefined : rt.chatOwners.get(sessionId))
if (botId === undefined) return next()
if (standingCovers(sessionId, botId, owner?.roomId, approvalKind(String(request.toolName ?? ''), request.reason))) return 'allowed-once'
const id = randomUUID()
const entry = {
id,
Expand Down Expand Up @@ -47,14 +61,65 @@ export function install(rt) {
})
}, { prepend: true })

// A group grant covers the owner's Bots working in that group: the Bot it was granted
// from and any other Bot of the same owner. Before anyone signs in there is one person,
// and a Bot made then carries no owner, so every local Bot is theirs.
function standingGrant(botId, roomId, kind) {
const bot = rt.botOf(botId)
if (bot === undefined || bot.remote !== undefined) return false
if ((bot.standingApprovals ?? []).some(grant => grant.kind === kind)) return true
const room = roomId === undefined ? undefined : rt.roomOf(roomId)
const sameOwner = grant => !rt.authzEnforced?.() || grant.ownerId === bot.ownerId
return (room?.standingApprovals ?? []).some(grant => grant.kind === kind && sameOwner(grant))
}

// The grant is the owner's: it answers only a turn the owner started (turn-origin.js). A
// friend's message, a room with other people in it, or a turn with no verdict still asks.
const standingCovers = (sessionId, botId, roomId, kind) => settled(rt, sessionId) === OWNER && standingGrant(botId, roomId, kind)

async function grantStanding(entry, scope) {
await rt.load()
const bot = rt.botOf(entry.botId)
const target = scope === 'room' ? rt.roomOf(entry.roomId) : bot
if (bot === undefined || bot.remote !== undefined) throw Object.assign(new Error('Only your own Bot can be given a standing approval'), { code: 'bot/forbidden' })
if (target === undefined) throw Object.assign(new Error('This approval did not come from a group'), { code: 'bot/invalid' })
const kind = approvalKind(entry.tool, entry.reason)
const grants = target.standingApprovals ?? []
if (!grants.some(grant => grant.kind === kind && grant.ownerId === bot.ownerId)) {
const grant = { kind, label: kindLabel(entry.tool, entry.reason), at: Date.now(), ...(bot.ownerId === undefined ? {} : { ownerId: bot.ownerId }) }
target.standingApprovals = [...grants, grant]
await rt.save()
}
// Other cards the new grant already covers stop waiting too.
for (const other of pending.values()) {
if (other.id !== entry.id && other.answer !== undefined && standingCovers(other.sessionId, other.botId, other.roomId, approvalKindOf(other))) other.answer('allowed-once')
}
}
const approvalKindOf = entry => approvalKind(entry.tool, entry.reason)

// A group's grants are per owner: a signed-in remover (`ownerId`) takes only their own.
async function removeStandingApproval(scope, id, kind, ownerId) {
await rt.load()
const target = scope === 'room' ? rt.roomOf(String(id ?? '')) : rt.botOf(String(id ?? ''))
if (target === undefined) throw Object.assign(new Error('Not found'), { code: 'bot/not-found' })
const before = target.standingApprovals ?? []
const after = before.filter(grant => grant.kind !== String(kind ?? '') || (scope === 'room' && ownerId !== undefined && grant.ownerId !== ownerId))
if (after.length === before.length) return false
if (after.length === 0) delete target.standingApprovals
else target.standingApprovals = after
await rt.save()
return true
}

const approvalsView = () => [...pending.values()].map(({ answer, ...entry }) => ({ ...entry, answerable: answer !== undefined }))
const waitsForApproval = sessionId => [...pending.values()].some(entry => entry.sessionId === sessionId)

function answerApproval(id, choice) {
async function answerApproval(id, choice) {
const entry = pending.get(String(id ?? ''))
if (entry?.answer === undefined) throw Object.assign(new Error('This approval is no longer open'), { code: 'bot/stale' })
const outcome = OUTCOMES[choice]
if (outcome === undefined) throw Object.assign(new Error('Choose allow or reject'), { code: 'bot/invalid' })
if (choice === 'always-bot' || choice === 'always-room') await grantStanding(entry, choice === 'always-room' ? 'room' : 'bot')
entry.answer(outcome)
}

Expand All @@ -65,5 +130,5 @@ export function install(rt) {
}
}

Object.assign(rt, { approvalsView, waitsForApproval, answerApproval, cancelApprovals })
Object.assign(rt, { approvalsView, waitsForApproval, answerApproval, cancelApprovals, removeStandingApproval })
}
9 changes: 9 additions & 0 deletions core/src/host/authz.js
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,13 @@ export function install(rt) {
const requireRoom = id => (roomOwnedBy(actor, id) ? ALLOW : deny('bot/forbidden', 'You do not own that room'))
const requireSession = id => (sessionOwnedBy(actor, id) ? ALLOW : deny('bot/forbidden', 'You do not own that session'))

// A group's standing grant belongs to the owner who gave it, a Bot's to the Bot's owner (#540).
if (ep === 'standing-approval-remove') {
if (body?.scope !== 'room') return requireBot(body?.id)
const grant = (state.rooms?.[String(body?.id ?? '')]?.standingApprovals ?? []).find(entry => entry.kind === String(body?.kind ?? ''))
return grant !== undefined && grant.ownerId === actor.memberId ? ALLOW : deny('bot/forbidden', 'You did not give that approval')
}

if (BOT_TARGETED.has(ep)) {
if (ep === 'schedules' || ep.startsWith('schedule-')) {
// The create endpoint keys off `body.target` (Bot or room id); `sessionId` is
Expand Down Expand Up @@ -336,6 +343,8 @@ export function install(rt) {
// room, and those decide — never a caller-chosen sessionId next to it.
const approval = (rt.approvalsView?.() ?? []).find(entry => entry.id === String(body?.id ?? ''))
if (approval === undefined) return deny('bot/forbidden', 'You do not own that approval')
// A standing grant outlives this one request: only the Bot's own owner gives it (#540).
if (String(body?.choice ?? '').startsWith('always')) return botOwnedBy(actor, approval.botId) ? ALLOW : deny('bot/forbidden', 'Only the Bot\'s owner can always allow')
return botOwnedBy(actor, approval.botId) || roomOwnedBy(actor, approval.roomId) ? ALLOW : deny('bot/forbidden', 'You do not own that approval')
}
return requireSession(body?.sessionId)
Expand Down
4 changes: 3 additions & 1 deletion core/src/host/channel.js
Original file line number Diff line number Diff line change
Expand Up @@ -339,8 +339,10 @@ export function install(rt) {
await rt.deleteRoom(body.id)
return ok(view())
case 'approval-answer':
rt.answerApproval(body.id, body.choice)
await rt.answerApproval(body.id, body.choice)
return ok(true)
case 'standing-approval-remove':
return ok(await rt.removeStandingApproval(body.scope === 'room' ? 'room' : 'bot', body.id, body.kind, rt.authzEnforced() && actor ? actor.memberId : undefined))
case 'stop-member-work':
return ok(await rt.stopMemberWork(String(body.roomId ?? ''), String(body.botId ?? '')))
case 'dismiss-question':
Expand Down
21 changes: 21 additions & 0 deletions core/test/authz.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,27 @@ test('approval-answer resolves the approval, not caller-chosen fields', async (t
assert.equal(rt.authorize(actorA, 'approval-answer', { id: 'ap-other', sessionId: 'a1', choice: 'approve' }).ok, false)
})

test('only the Bot\'s owner gives a standing approval, and only its giver removes a group\'s (#540)', async (t) => {
const { rt } = runtime(t, TEAM)
await rt.load()
const sessionA = await redeemOwner(rt)
await redeemOwner(rt)
const actorA = await rt.authenticate(sessionA.sessionToken)
rt.state.bots.a1 = { id: 'a1', sessionId: 'a1', name: 'A', instructions: '', createdAt: 2, ownerId: actorA.memberId }
rt.state.bots.b1 = { id: 'b1', sessionId: 'b1', name: 'B', instructions: '', createdAt: 3, ownerId: 'owner-2' }
rt.state.rooms.r1 = { id: 'r1', name: 'R', members: ['a1', 'b1'], sessions: {}, log: [], seq: 0, seen: {}, standingApprovals: [{ kind: 'bash', ownerId: 'owner-2' }, { kind: 'pwsh', ownerId: actorA.memberId }] }
rt.state.rooms.r2 = { id: 'r2', name: 'Mine', members: ['a1'], sessions: {}, log: [], seq: 0, seen: {} }
rt.approvalsView = () => [{ id: 'ap-own', sessionId: 'a1', botId: 'a1', roomId: 'r2' }, { id: 'ap-other', sessionId: 'gb', botId: 'b1', roomId: 'r2' }]
assert.equal(rt.authorize(actorA, 'approval-answer', { id: 'ap-own', choice: 'always-room' }).ok, true)
// Owning the room is enough for Allow once, never for a standing grant on another owner's Bot.
assert.equal(rt.authorize(actorA, 'approval-answer', { id: 'ap-other', choice: 'always-bot' }).ok, false)
assert.equal(rt.authorize(actorA, 'approval-answer', { id: 'ap-other', choice: 'always-room' }).ok, false)
assert.equal(rt.authorize(actorA, 'standing-approval-remove', { scope: 'bot', id: 'a1', kind: 'bash' }).ok, true)
assert.equal(rt.authorize(actorA, 'standing-approval-remove', { scope: 'bot', id: 'b1', kind: 'bash' }).ok, false)
assert.equal(rt.authorize(actorA, 'standing-approval-remove', { scope: 'room', id: 'r1', kind: 'pwsh' }).ok, true)
assert.equal(rt.authorize(actorA, 'standing-approval-remove', { scope: 'room', id: 'r1', kind: 'bash' }).ok, false)
})

test('flybot plugin endpoints follow the read/shared rules', async (t) => {
const { rt } = runtime(t, TEAM)
await rt.load()
Expand Down
Loading
Loading