From a01b81785fe4747df40bd669aec370e28045c962 Mon Sep 17 00:00:00 2001
From: probe
Date: Mon, 19 Oct 2026 16:19:58 +0800
Subject: [PATCH 1/3] =?UTF-8?q?feat(buddy):=20=E8=87=AA=E5=8A=A8=E5=8C=96?=
=?UTF-8?q?=E6=94=AF=E6=8C=81=E6=8C=89=E4=BB=BB=E5=8A=A1=E8=AE=BE=E7=BD=AE?=
=?UTF-8?q?=E5=AE=A1=E6=89=B9=E7=AD=96=E7=95=A5=E4=B8=8E=E5=85=8D=E7=A1=AE?=
=?UTF-8?q?=E8=AE=A4=E7=99=BD=E5=90=8D=E5=8D=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
自动化的审批策略原来是写死的,无人值守时所有需要确认的操作都会被直接拒绝,
需要读写工作区外目录或访问外部域名的任务因此无法完成。现在每个自动化保存
自己的权限模式,并在智能审批下维护一份目录与域名白名单,无人值守的运行命中
白名单时免确认放行。
- 新增 approval_policy 与 approval_allowlist_json(迁移 v31),随执行快照与运行记录传递。
- 权限引擎仅在白名单覆盖时对无人值守运行保留批准;deny、宿主机 shell、系统动作、
人工审批与只读限制保持不变。
- 创建工具与确认卡片支持 approvalPolicy / approvalAllowlist,编辑器只读展示白名单;
权限选择器恢复人工审批,并补充后台说明。
- 新增无人值守权限矩阵回归测试。
Co-Authored-By: Claude Sonnet 5.5
---
apps/buddy/service/src/BuddyService.ts | 2 +
.../createBuddySessionExtensions.ts | 11 ++
.../src/agent/sessions/BuddySessionFactory.ts | 3 +
.../__tests__/ApprovalService.spec.ts | 4 +
.../src/automations/AutomationService.ts | 4 +
.../AutomationChangeCoordinator.spec.ts | 2 +
.../__tests__/AutomationDispatcher.spec.ts | 2 +
...tomationOccurrenceLifecycleService.spec.ts | 6 +-
.../__tests__/AutomationScheduler.spec.ts | 6 +
.../__tests__/AutomationService.spec.ts | 2 +
.../src/automations/createAutomationTool.ts | 24 +++
.../src/permissions/PermissionEngine.ts | 9 +-
.../permissions/ToolAuthorizationService.ts | 12 +-
.../ToolAuthorizationService.spec.ts | 23 ++-
.../__tests__/approvalAllowlist.spec.ts | 56 +++++++
.../unattendedPermissionMatrix.spec.ts | 138 ++++++++++++++++++
.../src/permissions/approvalAllowlist.ts | 55 +++++++
.../src/permissions/permissionContract.ts | 2 +
.../spaces/__tests__/registerSpaceRpc.spec.ts | 2 +
.../__tests__/StorageSnapshots.spec.ts | 2 +-
.../automationActorSnapshots.spec.ts | 8 +-
.../automationTurnRepository.spec.ts | 13 +-
.../automationDefinitionCommandRepository.ts | 10 +-
.../service/src/storage/automationRecord.ts | 6 +
.../src/storage/automationRunAllowlist.ts | 20 +++
.../src/storage/automationTurnRepository.ts | 10 +-
apps/buddy/service/src/storage/database.ts | 2 +-
.../migrations/v31AutomationApproval.ts | 6 +
apps/buddy/service/src/storage/schema.ts | 4 +-
.../automation/__tests__/automation.spec.ts | 27 +++-
.../shared/automation/approvalAllowlist.ts | 35 +++++
apps/buddy/shared/automation/definition.ts | 4 +
apps/buddy/shared/automation/index.ts | 2 +
.../permissions/approvalReviewPayload.ts | 4 +
.../runs/__tests__/publicRunEvent.spec.ts | 2 +
.../buddy/src/i18n/locales/en-US/approvals.ts | 5 +
.../src/i18n/locales/en-US/automations.ts | 2 +
apps/buddy/src/i18n/locales/en-US/chat.ts | 2 +
.../buddy/src/i18n/locales/zh-CN/approvals.ts | 5 +
.../src/i18n/locales/zh-CN/automations.ts | 2 +
apps/buddy/src/i18n/locales/zh-CN/chat.ts | 2 +
.../__tests__/automationEditorForm.spec.ts | 10 ++
.../automations/model/automationEditorForm.ts | 6 +
.../src/modules/automations/model/typing.ts | 4 +
.../useAutomationEditorRoute.spec.ts | 2 +
.../editor/DesktopAutomationEditor.vue | 26 +++-
.../DesktopAutomationPromptComposer.vue | 9 +-
.../DesktopPermissionModeSelector.vue | 17 ++-
.../widgets/workspace/DesktopApprovalCard.vue | 17 ++-
.../DesktopApprovalAllowlist.vue | 67 +++++++++
50 files changed, 660 insertions(+), 34 deletions(-)
create mode 100644 apps/buddy/service/src/permissions/__tests__/approvalAllowlist.spec.ts
create mode 100644 apps/buddy/service/src/permissions/__tests__/unattendedPermissionMatrix.spec.ts
create mode 100644 apps/buddy/service/src/permissions/approvalAllowlist.ts
create mode 100644 apps/buddy/service/src/storage/automationRunAllowlist.ts
create mode 100644 apps/buddy/service/src/storage/migrations/v31AutomationApproval.ts
create mode 100644 apps/buddy/shared/automation/approvalAllowlist.ts
create mode 100644 apps/buddy/src/shared/ui/approval-allowlist/DesktopApprovalAllowlist.vue
diff --git a/apps/buddy/service/src/BuddyService.ts b/apps/buddy/service/src/BuddyService.ts
index 6ac36268d..71ea46905 100644
--- a/apps/buddy/service/src/BuddyService.ts
+++ b/apps/buddy/service/src/BuddyService.ts
@@ -167,6 +167,7 @@ import { createApprovalRepository } from './storage/approvalRepository'
import { createArtifactRepository } from './storage/artifactRepository'
import { createAttachmentRepository } from './storage/attachmentRepository'
import { createAutomationRepositories } from './storage/automationRepository'
+import { createAutomationRunAllowlistReader } from './storage/automationRunAllowlist'
import { createAutomationTurnRepository } from './storage/automationTurnRepository'
import { BuddyDataPaths } from './storage/BuddyDataPaths'
import { createBuddyRepository } from './storage/buddyRepository'
@@ -992,6 +993,7 @@ export async function startBuddyService(
})
const sessionFactory = await host.start('runtime.session_factory', () => {
const service = new BuddySessionFactory({
+ getApprovalAllowlist: createAutomationRunAllowlistReader(options.database),
getRunPermissionCeiling,
bindPreferences: apply => bindRuntimePreferences(options.rpc, apply),
tree: conversationTree,
diff --git a/apps/buddy/service/src/agent/extensions/createBuddySessionExtensions.ts b/apps/buddy/service/src/agent/extensions/createBuddySessionExtensions.ts
index 0dcf43461..81a51d47d 100644
--- a/apps/buddy/service/src/agent/extensions/createBuddySessionExtensions.ts
+++ b/apps/buddy/service/src/agent/extensions/createBuddySessionExtensions.ts
@@ -1,4 +1,5 @@
import type { ToolCallEvent } from '@earendil-works/pi-coding-agent'
+import type { AutomationApprovalAllowlistEntry } from '../../../../shared/automation/approvalAllowlist'
import type { BuddySettings } from '../../../../shared/buddies/buddySettings'
import type { BuddyServiceTier } from '../../../../shared/conversation/modelSelection'
import type { ApplicationDiagnosticReporter } from '../../../../shared/diagnostics/applicationDiagnostic'
@@ -21,6 +22,7 @@ import type { BuddyCapability, BuddyCapabilityFactory, BuddyCapabilityResourceRe
import type { BuddyExtensionRunContext, BuddyExtensionRunContextStore } from './BuddyExtensionRunContext'
import type { BuddyInProcessExtension } from './BuddyInProcessExtension'
import type { BuddyToolExposureResolver } from './discovery/toolDiscoveryContract'
+import { safeDiagnosticReporter } from '../../../../shared/diagnostics/applicationDiagnostic'
import { copyEventSnapshot } from '../../../../shared/events/eventSnapshot'
import { SessionDirectoryGrants } from '../../directories/SessionDirectoryGrants'
import { observeSessionPermissions } from '../../permissions/observeSessionPermissions'
@@ -61,6 +63,7 @@ export interface BuddySessionExtensionServices {
export interface CreateBuddySessionExtensionsOptions {
actorKind?: 'actor' | 'npc'
+ approvalAllowlist?: readonly AutomationApprovalAllowlistEntry[]
approvalPolicy: BuddyApprovalPolicy
canonicalRoot: string
conversationId: string
@@ -135,6 +138,7 @@ export async function createBuddySessionExtensions(
try {
creationSignal.addEventListener('abort', cancelCreation, { once: true })
creationSignal.throwIfAborted()
+ const reportDiagnostic = safeDiagnosticReporter(services.recordPermissions)
const authorization = new ToolAuthorizationService({
applySandboxDirectory: (run, grant) => sandboxDirectories.grant(run, grant),
applyGrant: async (proposal) => {
@@ -142,9 +146,16 @@ export async function createBuddySessionExtensions(
services.directoryGrants.assertCurrent(proposal.owner, mutation.grant.id)
grants.apply(mutation)
},
+ approvalAllowlist: options.approvalAllowlist,
approvalAvailable: options.sessionMode === 'interactive',
approvalPolicy: options.approvalPolicy,
approvalService: services.approvalService,
+ onAllowlistApproval: approval => reportDiagnostic({
+ conversationId: options.conversationId,
+ event: `permission.allowlist.${approval.kind}`,
+ level: 'info',
+ runId: approval.runId,
+ }),
cwd: options.canonicalRoot,
executionPermissions,
get executionProfile() { return getExecutionProfile() },
diff --git a/apps/buddy/service/src/agent/sessions/BuddySessionFactory.ts b/apps/buddy/service/src/agent/sessions/BuddySessionFactory.ts
index 0a229cf21..940a43dbc 100644
--- a/apps/buddy/service/src/agent/sessions/BuddySessionFactory.ts
+++ b/apps/buddy/service/src/agent/sessions/BuddySessionFactory.ts
@@ -1,3 +1,4 @@
+import type { AutomationApprovalAllowlistEntry } from '../../../../shared/automation/approvalAllowlist'
import type { BuddyThinkingLevel } from '../../../../shared/conversation/modelSelection'
import type { BuddyExecutionProfile } from '../../../../shared/permissions/executionProfile'
import type { BuddyPermissionSettings } from '../../../../shared/permissions/permissionMode'
@@ -17,6 +18,7 @@ import { createBuddySession } from './createBuddySession'
import { createReusableBuddySession } from './createReusableBuddySession'
export interface BuddySessionFactoryOptions {
+ getApprovalAllowlist?: (runId: string) => readonly AutomationApprovalAllowlistEntry[]
getRunPermissionCeiling: (runId: string) => BuddyExecutionProfile | undefined
bindPreferences?: (apply: (preferences: RuntimePreferences) => void) => Promise<() => void>
tree: BuddyConversationTree
@@ -102,6 +104,7 @@ export class BuddySessionFactory {
actorKind: blueprint.actor?.kind,
toolScope: run.purpose === 'conversation.compaction' ? { mode: 'none' } : blueprint.actor?.settings.tools,
fileAccessLimit: this.#options.getRunPermissionCeiling(run.id) === 'read_only' ? 'read_only' : blueprint.actor?.settings.limits.fileAccess,
+ approvalAllowlist: run.purpose === 'automation' ? this.#options.getApprovalAllowlist?.(run.id) : undefined,
approvalPolicy: blueprint.approvalPolicy,
canonicalRoot: blueprint.canonicalRoot,
conversationId: blueprint.conversationId,
diff --git a/apps/buddy/service/src/approvals/__tests__/ApprovalService.spec.ts b/apps/buddy/service/src/approvals/__tests__/ApprovalService.spec.ts
index 1cb38e90d..be3173982 100644
--- a/apps/buddy/service/src/approvals/__tests__/ApprovalService.spec.ts
+++ b/apps/buddy/service/src/approvals/__tests__/ApprovalService.spec.ts
@@ -81,6 +81,8 @@ describe('approvalService', () => {
reuseScopes: [],
arguments: { operation: 'upsert' },
automation: {
+ approvalAllowlist: [],
+ approvalPolicy: 'policy',
executionProfile: 'workspace_write',
modelMode: 'default',
name: 'Daily review',
@@ -319,6 +321,8 @@ describe('approvalService', () => {
reuseScopes: [],
arguments: { operation: 'upsert' },
automation: {
+ approvalAllowlist: [],
+ approvalPolicy: 'policy',
executionProfile: 'workspace_write',
modelMode: 'default',
name: 'Daily review',
diff --git a/apps/buddy/service/src/automations/AutomationService.ts b/apps/buddy/service/src/automations/AutomationService.ts
index d68ca83ba..844ece954 100644
--- a/apps/buddy/service/src/automations/AutomationService.ts
+++ b/apps/buddy/service/src/automations/AutomationService.ts
@@ -525,6 +525,8 @@ export class AutomationService {
blockedReason: null,
createdAt: input.createdAt,
actorId: draft.actorId ?? null,
+ approvalAllowlist: draft.approvalAllowlist,
+ approvalPolicy: draft.approvalPolicy,
executionProfile: draft.executionProfile,
id: input.id,
lastRunAt: input.lastRunAt,
@@ -623,6 +625,8 @@ function encodePage(page: AutomationPageRecord): AutomationPage {
function toDefinitionDraft(automation: Automation): AutomationDefinitionDraft {
return {
+ approvalAllowlist: automation.approvalAllowlist,
+ approvalPolicy: automation.approvalPolicy,
executionProfile: automation.executionProfile,
model: automation.model,
name: automation.name,
diff --git a/apps/buddy/service/src/automations/__tests__/AutomationChangeCoordinator.spec.ts b/apps/buddy/service/src/automations/__tests__/AutomationChangeCoordinator.spec.ts
index da923899b..c11aef4ca 100644
--- a/apps/buddy/service/src/automations/__tests__/AutomationChangeCoordinator.spec.ts
+++ b/apps/buddy/service/src/automations/__tests__/AutomationChangeCoordinator.spec.ts
@@ -133,6 +133,8 @@ function dailyDraft(
overrides: Partial = {},
): AutomationDefinitionDraft {
return {
+ approvalAllowlist: [],
+ approvalPolicy: 'policy',
executionProfile: 'workspace_write',
model: { mode: 'default' },
name: 'Automation',
diff --git a/apps/buddy/service/src/automations/__tests__/AutomationDispatcher.spec.ts b/apps/buddy/service/src/automations/__tests__/AutomationDispatcher.spec.ts
index e7add6842..a568af8df 100644
--- a/apps/buddy/service/src/automations/__tests__/AutomationDispatcher.spec.ts
+++ b/apps/buddy/service/src/automations/__tests__/AutomationDispatcher.spec.ts
@@ -297,6 +297,8 @@ function createFixture() {
}
const automation = service.create({
draft: {
+ approvalAllowlist: [],
+ approvalPolicy: 'policy',
executionProfile: input.executionProfile ?? 'workspace_write',
model: input.model,
name: 'Automation',
diff --git a/apps/buddy/service/src/automations/__tests__/AutomationOccurrenceLifecycleService.spec.ts b/apps/buddy/service/src/automations/__tests__/AutomationOccurrenceLifecycleService.spec.ts
index b578ac388..d5e5c0951 100644
--- a/apps/buddy/service/src/automations/__tests__/AutomationOccurrenceLifecycleService.spec.ts
+++ b/apps/buddy/service/src/automations/__tests__/AutomationOccurrenceLifecycleService.spec.ts
@@ -79,6 +79,8 @@ describe('automationOccurrenceLifecycleService', () => {
})
const automation = automations.create({
draft: {
+ approvalAllowlist: [],
+ approvalPolicy: 'policy',
executionProfile: 'workspace_write',
model: { mode: 'default' },
name: 'Automation',
@@ -148,6 +150,8 @@ describe('automationOccurrenceLifecycleService', () => {
})
const automation = automations.create({
draft: {
+ approvalAllowlist: [],
+ approvalPolicy: 'policy',
executionProfile: 'workspace_write',
model: { mode: 'default' },
name: 'Automation',
@@ -212,7 +216,7 @@ function createBoundFixture() {
const database = openBuddyDatabase({ databasePath: ':memory:' })
databases.push(database)
const automations = new AutomationService({ clock: { now: () => Temporal.Instant.from('2026-08-24T00:00:00.000Z') }, repositories: createAutomationRepositories(database) })
- const definition = automations.create({ requestId: 'create-recovery', draft: { executionProfile: 'workspace_write', model: { mode: 'default' }, name: 'Recovery', prompt: 'Run recovery fixture', spaceId: null, timing: { activeFrom: null, activeUntil: null, schedule: { cadence: 'daily', kind: 'calendar', localTime: '12:00' }, timezone: 'Asia/Shanghai' } } })
+ const definition = automations.create({ requestId: 'create-recovery', draft: { approvalAllowlist: [], approvalPolicy: 'policy', executionProfile: 'workspace_write', model: { mode: 'default' }, name: 'Recovery', prompt: 'Run recovery fixture', spaceId: null, timing: { activeFrom: null, activeUntil: null, schedule: { cadence: 'daily', kind: 'calendar', localTime: '12:00' }, timezone: 'Asia/Shanghai' } } })
automations.runNow({ automationId: definition.id, expectedRevision: 1, requestId: 'run-recovery' })
const occurrence = automations.leaseQueued({ now: '2026-08-24T00:00:00.000Z', leaseExpiresAt: '2026-08-24T00:01:00.000Z', limit: 1, owner: 'scheduler-recovery' })[0]!
createAutomationTurnRepository(database).bind({ boundAt: '2026-08-24T00:00:10.000Z', branchId: 'branch-recovery', conversationId: 'conversation-recovery', contextWindow: 100_000, maxTokens: 8_000, executionContext: null, leaseOwner: 'scheduler-recovery', messageId: 'message-recovery', model: 'model', occurrenceId: occurrence.id, provider: 'provider', reasoning: null, runId: 'run-recovery', spaceId: null })
diff --git a/apps/buddy/service/src/automations/__tests__/AutomationScheduler.spec.ts b/apps/buddy/service/src/automations/__tests__/AutomationScheduler.spec.ts
index 78c826015..18999ad1a 100644
--- a/apps/buddy/service/src/automations/__tests__/AutomationScheduler.spec.ts
+++ b/apps/buddy/service/src/automations/__tests__/AutomationScheduler.spec.ts
@@ -223,6 +223,8 @@ function createFixture(initialTime: string) {
function dailyDraft() {
return {
+ approvalAllowlist: [],
+ approvalPolicy: 'policy' as const,
executionProfile: 'workspace_write' as const,
model: { mode: 'default' as const },
name: 'Daily',
@@ -239,6 +241,8 @@ function dailyDraft() {
function hourlyDraft(anchorAt: string) {
return {
+ approvalAllowlist: [],
+ approvalPolicy: 'policy' as const,
executionProfile: 'workspace_write' as const,
model: { mode: 'default' as const },
name: 'Hourly',
@@ -255,6 +259,8 @@ function hourlyDraft(anchorAt: string) {
function onceDraft(runAt: string) {
return {
+ approvalAllowlist: [],
+ approvalPolicy: 'policy' as const,
executionProfile: 'workspace_write' as const,
model: { mode: 'default' as const },
name: 'Once',
diff --git a/apps/buddy/service/src/automations/__tests__/AutomationService.spec.ts b/apps/buddy/service/src/automations/__tests__/AutomationService.spec.ts
index 08022e93c..010d22836 100644
--- a/apps/buddy/service/src/automations/__tests__/AutomationService.spec.ts
+++ b/apps/buddy/service/src/automations/__tests__/AutomationService.spec.ts
@@ -427,6 +427,8 @@ function createFileService() {
function dailyDraft(name: string) {
return {
+ approvalAllowlist: [],
+ approvalPolicy: 'policy' as const,
executionProfile: 'workspace_write' as const,
model: { mode: 'default' as const },
name,
diff --git a/apps/buddy/service/src/automations/createAutomationTool.ts b/apps/buddy/service/src/automations/createAutomationTool.ts
index 10c3e133b..044624741 100644
--- a/apps/buddy/service/src/automations/createAutomationTool.ts
+++ b/apps/buddy/service/src/automations/createAutomationTool.ts
@@ -85,6 +85,24 @@ const schedule = Type.Union([
])
const draft = Type.Object({
+ approvalAllowlist: Type.Optional(Type.Array(Type.Union([
+ Type.Object({
+ access: Type.Union([Type.Literal('read'), Type.Literal('write')]),
+ kind: Type.Literal('directory'),
+ path: Type.String({ maxLength: 260 }),
+ }, { additionalProperties: false }),
+ Type.Object({
+ host: Type.String({ maxLength: 253 }),
+ kind: Type.Literal('domain'),
+ }, { additionalProperties: false }),
+ ]), {
+ description: 'Directories (absolute path, read or write) and network domains the unattended run may use without confirmation. Empty unless the task needs access outside its own workspace.',
+ maxItems: 16,
+ })),
+ approvalPolicy: Type.Optional(Type.Union([
+ Type.Literal('manual'),
+ Type.Literal('policy'),
+ ])),
executionProfile: Type.Optional(Type.Union([
Type.Literal('read_only'),
Type.Literal('workspace_write'),
@@ -179,6 +197,10 @@ export function createAutomationTool(options: CreateAutomationToolOptions) {
'Use list/get before mutating an existing automation so expectedRevision is current.',
'For upsert, omit automationId and expectedRevision to create; provide both to update.',
'executionProfile defaults to workspace_write; use read_only for inspection-only automations and full_access only when the user explicitly requests unrestricted local access.',
+ 'Scheduled runs are unattended: nobody can confirm anything while they run, so any operation that needs confirmation is denied and the run cannot finish that step.',
+ 'Before creating or updating an automation, decide whether its prompt needs to write or read directories outside its own workspace or reach external network domains. If it does, list each one in approvalAllowlist (kind directory with absolute path and read or write access, or kind domain with a host name such as api.example.com or *.example.com); if it does not, leave approvalAllowlist empty and do not add entries as a precaution.',
+ 'approvalAllowlist only applies with approvalPolicy policy (the default) and workspace_write. It covers directory access and network access from sandboxed shell commands only; host shell commands, web and connector tools, and system actions cannot be pre-approved, so tell the user when the task depends on them. The user reviews the list on the confirmation card.',
+ 'approvalPolicy manual denies every write, command, and external interaction in an unattended run, so use it only when the user asks for that.',
'Calendar schedules support daily, weekly, monthly, and yearly cadences; interval and once schedules are also supported.',
'Mutations always pause for a product confirmation card. Do not replace that confirmation with a conversational question.',
'run_now only queues one occurrence and returns immediately.',
@@ -307,6 +329,8 @@ function createAutomationReview(
automation: AutomationDefinitionDraft,
): AutomationApprovalReviewInput {
return {
+ approvalAllowlist: automation.approvalAllowlist,
+ approvalPolicy: automation.approvalPolicy,
executionProfile: automation.executionProfile,
modelMode: automation.model.mode === 'default'
? 'default'
diff --git a/apps/buddy/service/src/permissions/PermissionEngine.ts b/apps/buddy/service/src/permissions/PermissionEngine.ts
index b636d7fc3..fd91d1b6c 100644
--- a/apps/buddy/service/src/permissions/PermissionEngine.ts
+++ b/apps/buddy/service/src/permissions/PermissionEngine.ts
@@ -14,6 +14,7 @@ import { stat } from 'node:fs/promises'
import process from 'node:process'
import { sandboxDirectoryRequestSchema } from '../../../shared/permissions/shellSandbox'
import { ShellPolicy } from '../approvals/ShellPolicy'
+import { isAskCoveredByAllowlist } from './approvalAllowlist'
import { classifyPath, PathClassificationError, toGrantRoot } from './classifyPath'
import { lookupPermissionOutcome } from './decisionTable'
import { allow, ask, deny } from './permissionContract'
@@ -458,9 +459,11 @@ function finalizeDecision(
summary: request.approval?.summary ?? summaryFor(request.access ?? 'interaction'),
})
: decision
- return requiredDecision.type === 'ask' && !request.approvalAvailable
- ? deny('APPROVAL_UNAVAILABLE_IN_BACKGROUND', 'profile')
- : requiredDecision
+ if (requiredDecision.type !== 'ask' || request.approvalAvailable)
+ return requiredDecision
+ return request.allowlist && isAskCoveredByAllowlist(requiredDecision, request.allowlist)
+ ? requiredDecision
+ : deny('APPROVAL_UNAVAILABLE_IN_BACKGROUND', 'profile')
}
function toDecisionPaths(
diff --git a/apps/buddy/service/src/permissions/ToolAuthorizationService.ts b/apps/buddy/service/src/permissions/ToolAuthorizationService.ts
index 9a8f833a6..9bc48f122 100644
--- a/apps/buddy/service/src/permissions/ToolAuthorizationService.ts
+++ b/apps/buddy/service/src/permissions/ToolAuthorizationService.ts
@@ -1,3 +1,4 @@
+import type { AutomationApprovalAllowlistEntry } from '../../../shared/automation/approvalAllowlist'
import type { BuddySettings } from '../../../shared/buddies/buddySettings'
import type { BuddyApprovalPolicy } from '../../../shared/permissions/approvalPolicy'
import type { PathApprovalReviewInput } from '../../../shared/permissions/approvalReviewPayload'
@@ -15,6 +16,7 @@ import { PathClassificationError } from './classifyPath'
import { PermissionEngine } from './PermissionEngine'
export interface ToolAuthorizationOptions {
+ approvalAllowlist?: readonly AutomationApprovalAllowlistEntry[]
approvalService: Pick
approvalAvailable: boolean
approvalPolicy: BuddyApprovalPolicy
@@ -28,6 +30,7 @@ export interface ToolAuthorizationOptions {
engine?: PermissionEngine
fileAccessLimit?: BuddySettings['limits']['fileAccess']
getToolScopeDenial?: (toolName: string) => string | null
+ onAllowlistApproval?: (approval: { kind: string, runId: string }) => void
}
export interface ToolAuthorizationCall {
@@ -130,6 +133,9 @@ export class ToolAuthorizationService {
await run.flushProjectedEvents()
const decision = await this.#engine.decide({
access: declared.access,
+ allowlist: options.approvalAvailable || settings.approvalPolicy !== 'policy' || !options.approvalAllowlist?.length
+ ? undefined
+ : { entries: options.approvalAllowlist, networkHost: resource.network?.host },
approvalAvailable: options.approvalAvailable,
approvalPolicy: settings.approvalPolicy,
approval: declared.approval,
@@ -149,7 +155,11 @@ export class ToolAuthorizationService {
return decision.code
let approvedOnce = false
- if (decision.type === 'ask') {
+ if (decision.type === 'ask' && !options.approvalAvailable) {
+ // Unattended runs only reach this point when the automation's allowlist covers the operation.
+ options.onAllowlistApproval?.({ kind: decision.kind, runId: run.runId })
+ }
+ else if (decision.type === 'ask') {
const approval = await options.approvalService.request({
arguments: event.input,
automation: declared.approval?.automation,
diff --git a/apps/buddy/service/src/permissions/__tests__/ToolAuthorizationService.spec.ts b/apps/buddy/service/src/permissions/__tests__/ToolAuthorizationService.spec.ts
index 2561c3761..bd1428cc6 100644
--- a/apps/buddy/service/src/permissions/__tests__/ToolAuthorizationService.spec.ts
+++ b/apps/buddy/service/src/permissions/__tests__/ToolAuthorizationService.spec.ts
@@ -63,6 +63,7 @@ describe('harness tool authorization', () => {
const options = {
approvalService: approvals,
approvalAvailable: true,
+ approvalAllowlist: [],
approvalPolicy: 'policy' as const,
executionProfile: 'workspace_write' as const,
cwd: workspace,
@@ -212,7 +213,7 @@ describe('harness tool authorization', () => {
['lexora_browser_act', { action: { kind: 'click', ref: 'fixture' } }, { forceAsk: true, approval: { kind: 'browser', summary: 'Submit fixture', browser }, validateBeforeExecution: async () => null }],
['lexora_buddy_automation', { operation: 'pause', automationId: 'fixture' }, {
forceAsk: true,
- approval: { kind: 'automation', summary: 'Pause fixture', automation: { executionProfile: 'workspace_write', modelMode: 'default', name: 'Fixture', operation: 'pause', spaceId: null, promptSummary: 'Fixture', scheduleSummary: 'Daily', timezone: 'UTC' } },
+ approval: { kind: 'automation', summary: 'Pause fixture', automation: { approvalAllowlist: [], approvalPolicy: 'policy', executionProfile: 'workspace_write', modelMode: 'default', name: 'Fixture', operation: 'pause', spaceId: null, promptSummary: 'Fixture', scheduleSummary: 'Daily', timezone: 'UTC' } },
}],
['read', { path: join(outside, '.env') }, {}],
['mcp__fixture__search', { query: 'fixture' }, { access: 'network', requireApproval: true, approval: { kind: 'mcp', summary: 'Search', reuse: { operation: ['fixture', 1, 'search'], source: ['fixture', 1] } } }],
@@ -233,6 +234,26 @@ describe('harness tool authorization', () => {
expect(f.records.size).toBe(1)
})
+ it('lets unattended smart-approval runs use only the allowlisted directories without asking', async () => {
+ const f = fixture()
+ const target = join(outside, 'report.txt')
+ const call = () => f.call('write', { path: target, content: 'fixture' })
+ const unattended = { ...f.options, approvalAvailable: false }
+ const allowlisted: Array<{ kind: string, runId: string }> = []
+
+ await expect(new ToolAuthorizationService(unattended).authorize(call(), f.run)).resolves.toBe('APPROVAL_UNAVAILABLE_IN_BACKGROUND')
+ await expect(new ToolAuthorizationService({ ...unattended, approvalAllowlist: [{ access: 'read', kind: 'directory', path: outside }] }).authorize(call(), f.run)).resolves.toBe('APPROVAL_UNAVAILABLE_IN_BACKGROUND')
+ await expect(new ToolAuthorizationService({ ...unattended, approvalAllowlist: [{ access: 'write', kind: 'directory', path: join(root, 'another') }] }).authorize(call(), f.run)).resolves.toBe('APPROVAL_UNAVAILABLE_IN_BACKGROUND')
+ await expect(new ToolAuthorizationService({ ...unattended, approvalAllowlist: [{ access: 'write', kind: 'directory', path: outside }], approvalPolicy: 'manual' }).authorize(call(), f.run)).resolves.toBe('APPROVAL_UNAVAILABLE_IN_BACKGROUND')
+ await expect(new ToolAuthorizationService({
+ ...unattended,
+ approvalAllowlist: [{ access: 'write', kind: 'directory', path: outside }],
+ onAllowlistApproval: approval => allowlisted.push(approval),
+ }).authorize(call(), f.run)).resolves.toBeNull()
+ expect(allowlisted).toEqual([{ kind: 'write', runId: 'run-1' }])
+ expect(f.records.size).toBe(0)
+ })
+
it('rejects a path replaced while waiting without granting its new target', async () => {
const f = fixture()
const another = join(root, 'another')
diff --git a/apps/buddy/service/src/permissions/__tests__/approvalAllowlist.spec.ts b/apps/buddy/service/src/permissions/__tests__/approvalAllowlist.spec.ts
new file mode 100644
index 000000000..6d2c67abd
--- /dev/null
+++ b/apps/buddy/service/src/permissions/__tests__/approvalAllowlist.spec.ts
@@ -0,0 +1,56 @@
+import type { AutomationApprovalAllowlistEntry } from '../../../../shared/automation/approvalAllowlist'
+import type { PermissionDecision } from '../permissionContract'
+import { join, resolve } from 'node:path'
+import { describe, expect, it } from 'vitest'
+import { isAskCoveredByAllowlist } from '../approvalAllowlist'
+
+type AskDecision = Extract
+
+const base = resolve('allowlist-fixture')
+const directoryEntry = (access: 'read' | 'write', path = base): AutomationApprovalAllowlistEntry => ({ access, kind: 'directory', path })
+const domainEntry = (host: string): AutomationApprovalAllowlistEntry => ({ host, kind: 'domain' })
+const ask = (decision: Partial & Pick): AskDecision => ({ summary: 'fixture', type: 'ask', ...decision })
+const paths = (...names: string[]) => names.map(name => ({ path: join(base, name), zone: 'outside' as const }))
+
+describe('automation approval allowlist', () => {
+ it('covers file operations only inside a listed directory with enough access', () => {
+ const write = ask({ kind: 'write', paths: paths('out', 'a.txt') })
+ expect(isAskCoveredByAllowlist(write, { entries: [directoryEntry('write')] })).toBe(true)
+ expect(isAskCoveredByAllowlist(write, { entries: [directoryEntry('read')] })).toBe(false)
+ expect(isAskCoveredByAllowlist(ask({ kind: 'read', paths: paths('a.txt') }), { entries: [directoryEntry('read')] })).toBe(true)
+ expect(isAskCoveredByAllowlist(ask({ kind: 'delete', paths: paths('a.txt') }), { entries: [directoryEntry('read')] })).toBe(false)
+ expect(isAskCoveredByAllowlist(write, { entries: [directoryEntry('write', join(base, 'other'))] })).toBe(false)
+ expect(isAskCoveredByAllowlist(write, { entries: [directoryEntry('write', `${base}-sibling`)] })).toBe(false)
+ })
+
+ it('requires every touched path and the proposed grant root to be covered', () => {
+ const entries = [directoryEntry('write', join(base, 'out'))]
+ expect(isAskCoveredByAllowlist(ask({ kind: 'write', paths: [...paths('out/a.txt'), ...paths('elsewhere/b.txt')] }), { entries })).toBe(false)
+ expect(isAskCoveredByAllowlist(ask({
+ grant: { owner: { id: 'space', kind: 'space' }, root: base },
+ kind: 'write',
+ paths: paths('out/a.txt'),
+ }), { entries })).toBe(false)
+ })
+
+ it('covers sandbox directory expansion and network hosts, but never shell or system asks', () => {
+ const sandboxDirectory = { access: 'write' as const, device: '1', inode: '1', path: join(base, 'work'), reason: 'fixture' }
+ expect(isAskCoveredByAllowlist(ask({ kind: 'write', sandboxDirectory }), { entries: [directoryEntry('write')] })).toBe(true)
+ expect(isAskCoveredByAllowlist(ask({ kind: 'read', sandboxDirectory: { ...sandboxDirectory, access: 'read' } }), { entries: [directoryEntry('read')] })).toBe(true)
+ expect(isAskCoveredByAllowlist(ask({ kind: 'write', sandboxDirectory }), { entries: [directoryEntry('read')] })).toBe(false)
+
+ const entries = [domainEntry('api.example.com'), domainEntry('*.cdn.example.net')]
+ expect(isAskCoveredByAllowlist(ask({ kind: 'network' }), { entries, networkHost: 'API.example.com' })).toBe(true)
+ expect(isAskCoveredByAllowlist(ask({ kind: 'network' }), { entries, networkHost: 'img.cdn.example.net' })).toBe(true)
+ expect(isAskCoveredByAllowlist(ask({ kind: 'network' }), { entries, networkHost: 'cdn.example.net' })).toBe(false)
+ expect(isAskCoveredByAllowlist(ask({ kind: 'network' }), { entries, networkHost: 'evilapi.example.com' })).toBe(false)
+ expect(isAskCoveredByAllowlist(ask({ kind: 'network' }), { entries })).toBe(false)
+
+ expect(isAskCoveredByAllowlist(ask({ kind: 'shell', shell: { cwd: base, reason: 'forced-confirmation' } }), { entries: [directoryEntry('write')] })).toBe(false)
+ expect(isAskCoveredByAllowlist(ask({ kind: 'system' }), { entries: [directoryEntry('write')] })).toBe(false)
+ })
+
+ it('covers nothing when the allowlist is empty', () => {
+ expect(isAskCoveredByAllowlist(ask({ kind: 'write', paths: paths('a.txt') }), { entries: [] })).toBe(false)
+ })
+})
diff --git a/apps/buddy/service/src/permissions/__tests__/unattendedPermissionMatrix.spec.ts b/apps/buddy/service/src/permissions/__tests__/unattendedPermissionMatrix.spec.ts
new file mode 100644
index 000000000..3d1c063d3
--- /dev/null
+++ b/apps/buddy/service/src/permissions/__tests__/unattendedPermissionMatrix.spec.ts
@@ -0,0 +1,138 @@
+import type { AutomationApprovalAllowlistEntry } from '../../../../shared/automation/approvalAllowlist'
+import type { BuddyApprovalPolicy } from '../../../../shared/permissions/approvalPolicy'
+import type { BuddyExecutionProfile } from '../../../../shared/permissions/executionProfile'
+import type { BuddyExtensionRunContext } from '../../agent/extensions/BuddyExtensionRunContext'
+import { mkdir, mkdtemp, rm } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import { ToolAuthorizationService } from '../ToolAuthorizationService'
+import { ToolExecutionPermissions } from '../ToolExecutionPermissions'
+
+interface Scenario {
+ allowlist?: readonly AutomationApprovalAllowlistEntry[]
+ approvalPolicy: BuddyApprovalPolicy
+ executionProfile: BuddyExecutionProfile
+ name: string
+}
+
+const DENIED = 'APPROVAL_UNAVAILABLE_IN_BACKGROUND'
+
+describe('unattended automation permission matrix', () => {
+ let root: string
+ let workspace: string
+ let allowedDirectory: string
+ let otherDirectory: string
+ const asked: string[] = []
+
+ beforeEach(async () => {
+ asked.length = 0
+ root = await mkdtemp(join(tmpdir(), 'buddy-unattended-'))
+ workspace = join(root, 'workspace')
+ allowedDirectory = join(root, 'allowed')
+ otherDirectory = join(root, 'other')
+ await Promise.all([workspace, allowedDirectory, otherDirectory].map(path => mkdir(path)))
+ })
+ afterEach(async () => {
+ await rm(root, { recursive: true, force: true })
+ })
+
+ function authorizer(scenario: Scenario) {
+ const run: BuddyExtensionRunContext = {
+ runId: 'run-1',
+ signal: new AbortController().signal,
+ flushProjectedEvents: async () => {},
+ onToolExecutionAuthorized: async () => {},
+ }
+ const service = new ToolAuthorizationService({
+ approvalAllowlist: scenario.allowlist,
+ approvalAvailable: false,
+ approvalPolicy: scenario.approvalPolicy,
+ approvalService: {
+ request: async () => {
+ asked.push('approval requested')
+ throw new Error('unattended runs must never wait for approval')
+ },
+ },
+ cwd: workspace,
+ executionPermissions: new ToolExecutionPermissions(),
+ executionProfile: scenario.executionProfile,
+ getGrants: () => [{ canonicalRoot: workspace, root: workspace, grantId: 'workspace', kind: 'workspace' as const }],
+ owner: { id: 'conversation', kind: 'conversation' },
+ })
+ let nextId = 0
+ return {
+ network: (host: string) => service.authorize({ input: {}, toolCallId: `call-${++nextId}`, toolName: 'bash' }, run, { access: 'network', requireApproval: true }, { network: { host, port: 443 } }),
+ shell: () => service.authorize({ input: { command: 'node fixture.mjs' }, toolCallId: `call-${++nextId}`, toolName: 'lexora_host_shell' }, run),
+ write: (directory: string) => service.authorize({ input: { content: 'fixture', path: join(directory, 'report.txt') }, toolCallId: `call-${++nextId}`, toolName: 'write' }, run),
+ }
+ }
+
+ const scenarios: Scenario[] = [
+ { name: 'read-only', approvalPolicy: 'policy', executionProfile: 'read_only' },
+ { name: 'manual approval', approvalPolicy: 'manual', executionProfile: 'workspace_write' },
+ { name: 'smart approval, empty allowlist', approvalPolicy: 'policy', executionProfile: 'workspace_write' },
+ {
+ allowlist: [{ access: 'write', kind: 'directory', path: '' }, { host: 'api.example.com', kind: 'domain' }],
+ approvalPolicy: 'policy',
+ executionProfile: 'workspace_write',
+ name: 'smart approval, write directory and domain allowlisted',
+ },
+ {
+ allowlist: [{ access: 'read', kind: 'directory', path: '' }],
+ approvalPolicy: 'policy',
+ executionProfile: 'workspace_write',
+ name: 'smart approval, read-only directory allowlisted',
+ },
+ {
+ allowlist: [{ access: 'write', kind: 'directory', path: '' }, { host: 'api.example.com', kind: 'domain' }],
+ approvalPolicy: 'manual',
+ executionProfile: 'workspace_write',
+ name: 'manual approval with an allowlist present',
+ },
+ { name: 'full access', approvalPolicy: 'policy', executionProfile: 'full_access' },
+ ]
+
+ function resolveScenario(scenario: Scenario): Scenario {
+ return {
+ ...scenario,
+ allowlist: scenario.allowlist?.map(entry => entry.kind === 'directory' ? { ...entry, path: allowedDirectory } : entry),
+ }
+ }
+
+ it('reports what each permission mode does for unattended operations', async () => {
+ const rows: Record> = {}
+ for (const scenario of scenarios) {
+ const run = authorizer(resolveScenario(scenario))
+ rows[scenario.name] = {
+ 'network other host': await run.network('other.example.org'),
+ 'network listed host': await run.network('api.example.com'),
+ 'shell on host': await run.shell(),
+ 'write in workspace': await run.write(workspace),
+ 'write outside, listed': await run.write(allowedDirectory),
+ 'write outside, unlisted': await run.write(otherDirectory),
+ }
+ }
+ const ok = null
+ const readOnly = 'READ_ONLY_PROFILE'
+ const row = (values: readonly [string | null, string | null, string | null, string | null, string | null, string | null]) => ({
+ 'network listed host': values[0],
+ 'network other host': values[1],
+ 'shell on host': values[2],
+ 'write in workspace': values[3],
+ 'write outside, listed': values[4],
+ 'write outside, unlisted': values[5],
+ })
+
+ expect(asked).toEqual([])
+ expect(rows).toEqual({
+ 'read-only': row([DENIED, DENIED, readOnly, readOnly, readOnly, readOnly]),
+ 'manual approval': row([DENIED, DENIED, DENIED, DENIED, DENIED, DENIED]),
+ 'smart approval, empty allowlist': row([DENIED, DENIED, DENIED, ok, DENIED, DENIED]),
+ 'smart approval, write directory and domain allowlisted': row([ok, DENIED, DENIED, ok, ok, DENIED]),
+ 'smart approval, read-only directory allowlisted': row([DENIED, DENIED, DENIED, ok, DENIED, DENIED]),
+ 'manual approval with an allowlist present': row([DENIED, DENIED, DENIED, DENIED, DENIED, DENIED]),
+ 'full access': row([DENIED, DENIED, ok, ok, ok, ok]),
+ })
+ })
+})
diff --git a/apps/buddy/service/src/permissions/approvalAllowlist.ts b/apps/buddy/service/src/permissions/approvalAllowlist.ts
new file mode 100644
index 000000000..29c082021
--- /dev/null
+++ b/apps/buddy/service/src/permissions/approvalAllowlist.ts
@@ -0,0 +1,55 @@
+import type { AutomationApprovalAllowlistEntry } from '../../../shared/automation/approvalAllowlist'
+import type { PermissionDecision } from './permissionContract'
+import { isAbsolute, relative, resolve, sep } from 'node:path'
+
+export interface ApprovalAllowlistScope {
+ entries: readonly AutomationApprovalAllowlistEntry[]
+ networkHost?: string
+}
+
+type AskDecision = Extract
+
+/**
+ * Decides whether an unattended run may proceed with an operation that would
+ * otherwise need confirmation. Only directories and network hosts that the
+ * automation lists explicitly are covered; shell and system asks never are.
+ */
+export function isAskCoveredByAllowlist(decision: AskDecision, scope: ApprovalAllowlistScope): boolean {
+ if (!scope.entries.length || decision.shell)
+ return false
+ if (decision.sandboxDirectory)
+ return isDirectoryAllowed(scope.entries, decision.sandboxDirectory.path, decision.sandboxDirectory.access)
+ if (decision.kind === 'network')
+ return !!scope.networkHost && isDomainAllowed(scope.entries, scope.networkHost)
+ const access = decision.kind === 'read' ? 'read' : decision.kind === 'write' || decision.kind === 'delete' ? 'write' : null
+ if (!access || !decision.paths?.length)
+ return false
+ const targets = [...decision.paths.map(path => path.path), ...decision.grant ? [decision.grant.root] : []]
+ return targets.every(target => isDirectoryAllowed(scope.entries, target, access))
+}
+
+function isDirectoryAllowed(
+ entries: readonly AutomationApprovalAllowlistEntry[],
+ target: string,
+ access: 'read' | 'write',
+): boolean {
+ return entries.some(entry => entry.kind === 'directory'
+ && (entry.access === 'write' || access === 'read')
+ && isWithin(entry.path, target))
+}
+
+function isDomainAllowed(entries: readonly AutomationApprovalAllowlistEntry[], host: string): boolean {
+ const normalized = host.toLowerCase()
+ return entries.some((entry) => {
+ if (entry.kind !== 'domain')
+ return false
+ return entry.host.startsWith('*.')
+ ? normalized.endsWith(entry.host.slice(1))
+ : normalized === entry.host
+ })
+}
+
+function isWithin(root: string, target: string): boolean {
+ const difference = relative(resolve(root), resolve(target))
+ return difference === '' || (difference !== '..' && !difference.startsWith(`..${sep}`) && !isAbsolute(difference))
+}
diff --git a/apps/buddy/service/src/permissions/permissionContract.ts b/apps/buddy/service/src/permissions/permissionContract.ts
index 69cab19d1..a9ae4e0bb 100644
--- a/apps/buddy/service/src/permissions/permissionContract.ts
+++ b/apps/buddy/service/src/permissions/permissionContract.ts
@@ -2,6 +2,7 @@ import type { BuddyApprovalPolicy } from '../../../shared/permissions/approvalPo
import type { ApprovalReviewKind, ShellApprovalContext } from '../../../shared/permissions/approvalReviewPayload'
import type { BuddyExecutionProfile } from '../../../shared/permissions/executionProfile'
import type { SandboxDirectoryGrant } from '../../../shared/permissions/shellSandbox'
+import type { ApprovalAllowlistScope } from './approvalAllowlist'
export type AccessKind
= 'delete'
@@ -71,6 +72,7 @@ export type PermissionDecision
export interface PermissionRequest {
access?: AccessKind
+ allowlist?: ApprovalAllowlistScope
approvalPolicy: BuddyApprovalPolicy
approvalAvailable: boolean
approval?: { kind?: ApprovalReviewKind, summary?: string }
diff --git a/apps/buddy/service/src/spaces/__tests__/registerSpaceRpc.spec.ts b/apps/buddy/service/src/spaces/__tests__/registerSpaceRpc.spec.ts
index 9d90f0cd7..379b107ba 100644
--- a/apps/buddy/service/src/spaces/__tests__/registerSpaceRpc.spec.ts
+++ b/apps/buddy/service/src/spaces/__tests__/registerSpaceRpc.spec.ts
@@ -157,6 +157,8 @@ function createRpcHarness() {
function dailyDraft(spaceId: string): AutomationDefinitionDraft {
return {
+ approvalAllowlist: [],
+ approvalPolicy: 'policy',
executionProfile: 'workspace_write',
model: { mode: 'default' },
name: 'Space automation',
diff --git a/apps/buddy/service/src/storage/__tests__/StorageSnapshots.spec.ts b/apps/buddy/service/src/storage/__tests__/StorageSnapshots.spec.ts
index 878cfa6ae..b36ad5c83 100644
--- a/apps/buddy/service/src/storage/__tests__/StorageSnapshots.spec.ts
+++ b/apps/buddy/service/src/storage/__tests__/StorageSnapshots.spec.ts
@@ -36,7 +36,7 @@ it('creates portable snapshots without credentials or recursive backups and rest
const database = openBuddyDatabase({ databasePath: join(root, 'buddy', 'buddy.sqlite3') })
database.prepare('INSERT INTO provider_states (provider_id, enabled, created_at, updated_at, request_headers_json) VALUES (?, 1, ?, ?, ?)').run('fixture', 'now', 'now', '[{"name":"Authorization","value":"synthetic-token"}]')
const automations = new AutomationService({ repositories: createAutomationRepositories(database) })
- const automation = automations.create({ requestId: randomUUID(), draft: { executionProfile: 'workspace_write', model: { mode: 'default' }, name: 'Fixture', spaceId: null, prompt: 'Fixture task', timing: { activeFrom: null, activeUntil: null, schedule: { cadence: 'daily', kind: 'calendar', localTime: '09:30' }, timezone: 'UTC' } } })
+ const automation = automations.create({ requestId: randomUUID(), draft: { approvalAllowlist: [], approvalPolicy: 'policy', executionProfile: 'workspace_write', model: { mode: 'default' }, name: 'Fixture', spaceId: null, prompt: 'Fixture task', timing: { activeFrom: null, activeUntil: null, schedule: { cadence: 'daily', kind: 'calendar', localTime: '09:30' }, timezone: 'UTC' } } })
const occurrence = automations.runNow({ automationId: automation.id, expectedRevision: 1, requestId: randomUUID() }).occurrence
database.prepare('UPDATE automation_occurrences SET lease_owner = ?, lease_expires_at = ? WHERE id = ?').run('old-scheduler', '2099-01-01T00:00:00Z', occurrence.id)
database.close()
diff --git a/apps/buddy/service/src/storage/__tests__/automationActorSnapshots.spec.ts b/apps/buddy/service/src/storage/__tests__/automationActorSnapshots.spec.ts
index b9dab2bbf..e15280c76 100644
--- a/apps/buddy/service/src/storage/__tests__/automationActorSnapshots.spec.ts
+++ b/apps/buddy/service/src/storage/__tests__/automationActorSnapshots.spec.ts
@@ -31,6 +31,8 @@ function fixture(actorId: string | null = null) {
const buddies = createBuddyRepository(database)
const automation = service.create({ requestId: 'create', draft: {
actorId,
+ approvalAllowlist: [],
+ approvalPolicy: 'policy',
executionProfile: 'full_access',
model: { mode: 'default' },
name: 'Scheduled work',
@@ -116,9 +118,13 @@ describe('automation actor claim snapshots', () => {
const row = f.database.prepare(`SELECT * FROM ${table}`).get()!
delete row.actor_id
delete row.actor_snapshot_json
+ delete row.approval_policy
+ delete row.approval_allowlist_json
if (typeof row.execution_snapshot_json === 'string') {
const snapshot = JSON.parse(row.execution_snapshot_json)
delete snapshot.actorId
+ delete snapshot.approvalPolicy
+ delete snapshot.approvalAllowlist
row.execution_snapshot_json = JSON.stringify(snapshot)
}
const columns = Object.keys(row)
@@ -238,7 +244,7 @@ describe('automation actor claim snapshots', () => {
it('keeps an explicit actor despite default changes and skips binding if that actor has been disabled', () => {
const f = fixture()
const actor = f.buddies.create({ name: 'Explicit actor' })
- const updated = f.service.update({ automationId: f.automation.id, expectedRevision: f.automation.revision, requestId: 'update', draft: { actorId: actor.id, executionProfile: f.automation.executionProfile, model: f.automation.model, name: f.automation.name, prompt: f.automation.prompt, spaceId: f.automation.spaceId, timing: f.automation.timing } })
+ const updated = f.service.update({ automationId: f.automation.id, expectedRevision: f.automation.revision, requestId: 'update', draft: { actorId: actor.id, approvalAllowlist: [], approvalPolicy: f.automation.approvalPolicy, executionProfile: f.automation.executionProfile, model: f.automation.model, name: f.automation.name, prompt: f.automation.prompt, spaceId: f.automation.spaceId, timing: f.automation.timing } })
const queued = f.service.runNow({ automationId: updated.id, expectedRevision: updated.revision, requestId: 'explicit' }).occurrence
expect(f.claim()[0]!.actorSnapshot?.id).toBe(actor.id)
f.buddies.update({ buddyId: actor.id, expectedRevision: actor.revision, enabled: false })
diff --git a/apps/buddy/service/src/storage/__tests__/automationTurnRepository.spec.ts b/apps/buddy/service/src/storage/__tests__/automationTurnRepository.spec.ts
index ce49c49c9..dd4c72577 100644
--- a/apps/buddy/service/src/storage/__tests__/automationTurnRepository.spec.ts
+++ b/apps/buddy/service/src/storage/__tests__/automationTurnRepository.spec.ts
@@ -3,6 +3,7 @@ import { afterEach, describe, expect, it } from 'vitest'
import { AutomationService } from '../../automations/AutomationService'
import { createAutomationRepositories } from '../automationRepository'
+import { createAutomationRunAllowlistReader } from '../automationRunAllowlist'
import {
AutomationTurnBindingError,
createAutomationTurnRepository,
@@ -27,7 +28,7 @@ describe('automationTurnRepository', () => {
})
const automation = service.create({
requestId: 'create-1',
- draft: { ...dailyDraft(), executionProfile: 'full_access' },
+ draft: { ...dailyDraft(), approvalAllowlist: [{ access: 'write' as const, kind: 'directory' as const, path: '/data/reports' }, { host: 'api.example.com', kind: 'domain' as const }], approvalPolicy: 'manual' as const, executionProfile: 'full_access' },
})
const occurrence = service.runNow({
automationId: automation.id,
@@ -64,15 +65,17 @@ describe('automationTurnRepository', () => {
status: 'bound',
})
expect(database.prepare(`
- SELECT origin, deleted_at, execution_profile FROM conversations WHERE id = 'conversation-1'
+ SELECT origin, deleted_at, execution_profile, approval_policy FROM conversations WHERE id = 'conversation-1'
`).get()).toEqual({
+ approval_policy: 'manual',
deleted_at: null,
execution_profile: 'full_access',
origin: 'automation',
})
expect(database.prepare(`
- SELECT purpose, status, execution_profile FROM runs WHERE id = 'run-1'
+ SELECT purpose, status, execution_profile, approval_policy FROM runs WHERE id = 'run-1'
`).get()).toEqual({
+ approval_policy: 'manual',
execution_profile: 'full_access',
purpose: 'automation',
status: 'queued',
@@ -80,6 +83,8 @@ describe('automationTurnRepository', () => {
expect(database.prepare(`
SELECT prompt, reasoning FROM run_inputs WHERE run_id = 'run-1'
`).get()).toEqual({ prompt: 'Run daily', reasoning: 'high' })
+ expect(createAutomationRunAllowlistReader(database)('run-1')).toEqual([{ access: 'write', kind: 'directory', path: '/data/reports' }, { host: 'api.example.com', kind: 'domain' }])
+ expect(createAutomationRunAllowlistReader(database)('missing-run')).toEqual([])
expect(database.prepare('SELECT actor_id, model_selection_json FROM conversation_members WHERE conversation_id = ?').get('conversation-1')).toEqual({ actor_id: 'default', model_selection_json: null })
})
@@ -189,6 +194,8 @@ describe('automationTurnRepository', () => {
function dailyDraft() {
return {
+ approvalAllowlist: [],
+ approvalPolicy: 'policy' as const,
executionProfile: 'workspace_write' as const,
model: { mode: 'default' as const },
name: 'Daily',
diff --git a/apps/buddy/service/src/storage/automationDefinitionCommandRepository.ts b/apps/buddy/service/src/storage/automationDefinitionCommandRepository.ts
index 5cb7c55e9..0d7462cce 100644
--- a/apps/buddy/service/src/storage/automationDefinitionCommandRepository.ts
+++ b/apps/buddy/service/src/storage/automationDefinitionCommandRepository.ts
@@ -55,15 +55,15 @@ export function createAutomationDefinitionCommandStore(
): AutomationDefinitionCommandStore {
const insertAutomation = database.prepare(`
INSERT INTO automations (
- id, actor_id, name, prompt, space_id, execution_profile, model_mode, provider_id, model_id, reasoning,
+ id, actor_id, name, prompt, space_id, execution_profile, approval_policy, approval_allowlist_json, model_mode, provider_id, model_id, reasoning,
schedule_kind, schedule_json, timezone, active_from, active_until,
status, blocked_reason, next_run_at, last_run_at, deleted_at, revision,
created_at, updated_at
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?)
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?)
`)
const replaceAutomation = database.prepare(`
UPDATE automations SET
- actor_id = ?, name = ?, prompt = ?, space_id = ?, execution_profile = ?, model_mode = ?, provider_id = ?,
+ actor_id = ?, name = ?, prompt = ?, space_id = ?, execution_profile = ?, approval_policy = ?, approval_allowlist_json = ?, model_mode = ?, provider_id = ?,
model_id = ?, reasoning = ?, schedule_kind = ?, schedule_json = ?,
timezone = ?, active_from = ?, active_until = ?, status = ?,
blocked_reason = ?, next_run_at = ?, deleted_at = ?, revision = ?, updated_at = ?
@@ -190,6 +190,8 @@ export function createAutomationDefinitionCommandStore(
input.automation.prompt,
input.automation.spaceId,
input.automation.executionProfile,
+ input.automation.approvalPolicy,
+ JSON.stringify(input.automation.approvalAllowlist),
input.automation.model.mode,
input.automation.model.mode === 'pinned' ? input.automation.model.providerId : null,
input.automation.model.mode === 'pinned' ? input.automation.model.modelId : null,
@@ -237,6 +239,8 @@ function persistAutomation(
automation.prompt,
automation.spaceId,
automation.executionProfile,
+ automation.approvalPolicy,
+ JSON.stringify(automation.approvalAllowlist),
automation.model.mode,
automation.model.mode === 'pinned' ? automation.model.providerId : null,
automation.model.mode === 'pinned' ? automation.model.modelId : null,
diff --git a/apps/buddy/service/src/storage/automationRecord.ts b/apps/buddy/service/src/storage/automationRecord.ts
index 69534e4f0..8c4179624 100644
--- a/apps/buddy/service/src/storage/automationRecord.ts
+++ b/apps/buddy/service/src/storage/automationRecord.ts
@@ -13,6 +13,8 @@ export interface AutomationRow {
actor_id: string | null
active_from: string | null
active_until: string | null
+ approval_allowlist_json: string
+ approval_policy: Automation['approvalPolicy']
blocked_reason: Automation['blockedReason']
created_at: string
deleted_at: string | null
@@ -42,6 +44,8 @@ export function createAutomationExecutionSnapshot(
const automation = toAutomationRecord(row)
return automationExecutionSnapshotSchema.parse({
actorId: automation.actorId ?? null,
+ approvalAllowlist: automation.approvalAllowlist,
+ approvalPolicy: automation.approvalPolicy,
executionProfile: automation.executionProfile,
model: automation.model,
name: automation.name,
@@ -62,6 +66,8 @@ export function requireAutomationRecord(value: unknown, id: string): Automation
export function toAutomationRecord(row: AutomationRow): Automation {
return automationSchema.parse({
actorId: row.actor_id ?? null,
+ approvalAllowlist: JSON.parse(row.approval_allowlist_json),
+ approvalPolicy: row.approval_policy,
blockedReason: row.blocked_reason,
createdAt: row.created_at,
executionProfile: row.execution_profile,
diff --git a/apps/buddy/service/src/storage/automationRunAllowlist.ts b/apps/buddy/service/src/storage/automationRunAllowlist.ts
new file mode 100644
index 000000000..0fc54e9e9
--- /dev/null
+++ b/apps/buddy/service/src/storage/automationRunAllowlist.ts
@@ -0,0 +1,20 @@
+import type { DatabaseSync } from 'node:sqlite'
+import type { AutomationApprovalAllowlistEntry } from '../../../shared/automation/approvalAllowlist'
+import { automationApprovalAllowlistSchema } from '../../../shared/automation/approvalAllowlist'
+
+export function createAutomationRunAllowlistReader(
+ database: DatabaseSync,
+): (runId: string) => readonly AutomationApprovalAllowlistEntry[] {
+ const find = database.prepare(`
+ SELECT json_extract(execution_snapshot_json, '$.approvalAllowlist') AS allowlist
+ FROM automation_occurrences
+ WHERE run_id = ?
+ `)
+ return (runId) => {
+ const row = find.get(runId) as { allowlist: string | null } | undefined
+ if (!row?.allowlist)
+ return []
+ const parsed = automationApprovalAllowlistSchema.safeParse(JSON.parse(row.allowlist))
+ return parsed.success ? parsed.data : []
+ }
+}
diff --git a/apps/buddy/service/src/storage/automationTurnRepository.ts b/apps/buddy/service/src/storage/automationTurnRepository.ts
index 0d3e381e9..4f69c8f22 100644
--- a/apps/buddy/service/src/storage/automationTurnRepository.ts
+++ b/apps/buddy/service/src/storage/automationTurnRepository.ts
@@ -97,8 +97,8 @@ export function createAutomationTurnRepository(database: DatabaseSync): Automati
const insertConversation = database.prepare(`
INSERT INTO conversations (
id, space_id, title, active_branch_id, created_at, updated_at,
- execution_profile, origin, deleted_at, buddy_id
- ) VALUES (?, ?, ?, ?, ?, ?, ?, 'automation', NULL, ?)
+ execution_profile, approval_policy, origin, deleted_at, buddy_id
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'automation', NULL, ?)
`)
const insertMember = database.prepare(`
INSERT INTO conversation_members (conversation_id, actor_id, joined_at, model_selection_json, model_selection_source)
@@ -118,8 +118,8 @@ export function createAutomationTurnRepository(database: DatabaseSync): Automati
INSERT INTO runs (
id, conversation_id, branch_id, triggering_message_id, provider, model,
context_window, max_tokens, purpose, status, pi_session_file, error_code,
- started_at, completed_at, execution_profile, execution_context_json, buddy_id, actor_snapshot_json
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'automation', 'queued', NULL, NULL, ?, NULL, ?, ?, ?, ?)
+ started_at, completed_at, execution_profile, approval_policy, execution_context_json, buddy_id, actor_snapshot_json
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'automation', 'queued', NULL, NULL, ?, NULL, ?, ?, ?, ?, ?)
`)
const insertRunInput = database.prepare(`
INSERT INTO run_inputs (
@@ -221,6 +221,7 @@ export function createAutomationTurnRepository(database: DatabaseSync): Automati
input.boundAt,
input.boundAt,
executionProfile,
+ snapshot.approvalPolicy,
actor.id,
)
insertMember.run(input.conversationId)
@@ -248,6 +249,7 @@ export function createAutomationTurnRepository(database: DatabaseSync): Automati
input.maxTokens,
input.boundAt,
executionProfile,
+ snapshot.approvalPolicy,
input.executionContext ? JSON.stringify(input.executionContext) : null,
actor.id,
JSON.stringify(acceptedDefinition),
diff --git a/apps/buddy/service/src/storage/database.ts b/apps/buddy/service/src/storage/database.ts
index 6ed399cb3..fd2da9dde 100644
--- a/apps/buddy/service/src/storage/database.ts
+++ b/apps/buddy/service/src/storage/database.ts
@@ -18,7 +18,7 @@ export interface OpenBuddyDatabaseOptions {
}
const BUDDY_CURRENT_SCHEMA_COLUMNS = {
- automations: ['actor_id'],
+ automations: ['actor_id', 'approval_policy', 'approval_allowlist_json'],
automation_occurrences: ['actor_snapshot_json'],
runs: ['buddy_id', 'actor_snapshot_json', 'model_override_json', 'root_run_id', 'parent_run_id', 'execution_kind', 'thread_id', 'generation'],
execution_state: ['run_id', 'phase', 'phase_version', 'request_key', 'request_hash', 'admitted_root_generation', 'admitted_parent_generation', 'result_mailbox_id', 'cancel_requested_at', 'cleanup_state', 'file_access_ceiling'],
diff --git a/apps/buddy/service/src/storage/migrations/v31AutomationApproval.ts b/apps/buddy/service/src/storage/migrations/v31AutomationApproval.ts
new file mode 100644
index 000000000..2652f5349
--- /dev/null
+++ b/apps/buddy/service/src/storage/migrations/v31AutomationApproval.ts
@@ -0,0 +1,6 @@
+export const BUDDY_V31_AUTOMATION_APPROVAL_SCHEMA_SQL = `
+ALTER TABLE automations ADD COLUMN approval_policy TEXT NOT NULL DEFAULT 'policy'
+ CHECK (approval_policy IN ('manual', 'policy'));
+ALTER TABLE automations ADD COLUMN approval_allowlist_json TEXT NOT NULL DEFAULT '[]'
+ CHECK (json_valid(approval_allowlist_json) AND json_type(approval_allowlist_json) = 'array');
+`
diff --git a/apps/buddy/service/src/storage/schema.ts b/apps/buddy/service/src/storage/schema.ts
index 2071eca84..247e3fb40 100644
--- a/apps/buddy/service/src/storage/schema.ts
+++ b/apps/buddy/service/src/storage/schema.ts
@@ -28,6 +28,7 @@ import { BUDDY_V27_PROVIDER_METADATA_SCHEMA_SQL } from './migrations/v27Provider
import { BUDDY_V28_AVATARS_SCHEMA_SQL } from './migrations/v28BuddyAvatars'
import { BUDDY_V29_SESSION_REFERENCES_SCHEMA_SQL } from './migrations/v29SessionReferences'
import { BUDDY_V30_TASK_MODELS_SCHEMA_SQL } from './migrations/v30TaskModels'
+import { BUDDY_V31_AUTOMATION_APPROVAL_SCHEMA_SQL } from './migrations/v31AutomationApproval'
export interface BuddySchemaMigration {
foreignKeys?: 'off'
@@ -35,7 +36,7 @@ export interface BuddySchemaMigration {
version: number
}
-export const BUDDY_SCHEMA_VERSION = 30 as const
+export const BUDDY_SCHEMA_VERSION = 31 as const
export const BUDDY_SCHEMA_MIGRATIONS: readonly BuddySchemaMigration[] = [
{ sql: BUDDY_V1_INITIAL_SCHEMA_SQL, version: 1 },
@@ -68,4 +69,5 @@ export const BUDDY_SCHEMA_MIGRATIONS: readonly BuddySchemaMigration[] = [
{ sql: BUDDY_V28_AVATARS_SCHEMA_SQL, version: 28 },
{ sql: BUDDY_V29_SESSION_REFERENCES_SCHEMA_SQL, version: 29 },
{ sql: BUDDY_V30_TASK_MODELS_SCHEMA_SQL, version: 30 },
+ { sql: BUDDY_V31_AUTOMATION_APPROVAL_SCHEMA_SQL, version: 31 },
]
diff --git a/apps/buddy/shared/automation/__tests__/automation.spec.ts b/apps/buddy/shared/automation/__tests__/automation.spec.ts
index fecb462d2..06647403d 100644
--- a/apps/buddy/shared/automation/__tests__/automation.spec.ts
+++ b/apps/buddy/shared/automation/__tests__/automation.spec.ts
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import {
+ automationApprovalAllowlistSchema,
automationExecutionSnapshotSchema,
automationMutationRequestSchemas,
automationSchema,
@@ -75,7 +76,7 @@ describe('automation shared contract', () => {
timing: dailyTiming,
})
- expect(snapshot).toMatchObject({ executionProfile: 'workspace_write' })
+ expect(snapshot).toMatchObject({ approvalPolicy: 'policy', executionProfile: 'workspace_write' })
expect(() => automationExecutionSnapshotSchema.parse({
...snapshot,
credential: 'secret',
@@ -86,6 +87,28 @@ describe('automation shared contract', () => {
})).toThrow()
})
+ it('accepts only absolute directories and plain host names in an approval allowlist', () => {
+ const accepted = automationApprovalAllowlistSchema.parse([
+ { access: 'write', kind: 'directory', path: 'D:\\data\\reports' },
+ { access: 'read', kind: 'directory', path: '/srv/data' },
+ { host: 'API.Example.com', kind: 'domain' },
+ { host: '*.example.net', kind: 'domain' },
+ ])
+
+ expect(accepted[2]).toEqual({ host: 'api.example.com', kind: 'domain' })
+ expect(automationApprovalAllowlistSchema.parse(undefined)).toEqual([])
+ for (const entry of [
+ { access: 'write', kind: 'directory', path: 'relative/path' },
+ { access: 'write', kind: 'directory', path: '/srv/../etc' },
+ { access: 'execute', kind: 'directory', path: '/srv/data' },
+ { host: 'https://example.com/path', kind: 'domain' },
+ { host: '*', kind: 'domain' },
+ { kind: 'command', prefix: 'rm' },
+ ])
+ expect(() => automationApprovalAllowlistSchema.parse([entry])).toThrow()
+ expect(() => automationApprovalAllowlistSchema.parse(Array.from({ length: 17 }, (_, index) => ({ host: `h${index}.example.com`, kind: 'domain' })))).toThrow()
+ })
+
it('requires request identity and optimistic revisions for every mutation', () => {
expect(automationMutationRequestSchemas.create.parse({
draft: {
@@ -112,6 +135,8 @@ describe('automation shared contract', () => {
it('reserves blocked lifecycle reasons for persistent Space or pinned-model failures', () => {
const automation = {
+ approvalAllowlist: [],
+ approvalPolicy: 'policy',
blockedReason: 'AUTOMATION_SPACE_UNAVAILABLE',
createdAt: '2026-08-24T01:00:00.000Z',
executionProfile: 'workspace_write',
diff --git a/apps/buddy/shared/automation/approvalAllowlist.ts b/apps/buddy/shared/automation/approvalAllowlist.ts
new file mode 100644
index 000000000..e9b774e48
--- /dev/null
+++ b/apps/buddy/shared/automation/approvalAllowlist.ts
@@ -0,0 +1,35 @@
+import { z } from 'zod'
+
+export const AUTOMATION_APPROVAL_ALLOWLIST_MAX_ENTRIES = 16
+
+const absolutePathPattern = /^(?:[a-z]:[\\/]|\/)/i
+
+export const automationAllowedDirectorySchema = z.object({
+ access: z.enum(['read', 'write']),
+ kind: z.literal('directory'),
+ path: z.string().trim().max(260).refine(
+ value => absolutePathPattern.test(value) && !value.split(/[\\/]/).includes('..'),
+ 'Allowed directories must be absolute paths without parent segments',
+ ),
+}).strict()
+
+export const automationAllowedDomainSchema = z.object({
+ host: z.string().trim().toLowerCase().max(253).regex(
+ /^(?:\*\.)?(?:[a-z0-9-]+\.)*[a-z0-9-]+$/,
+ 'Allowed domains must be host names, optionally prefixed with "*."',
+ ),
+ kind: z.literal('domain'),
+}).strict()
+
+export const automationApprovalAllowlistEntrySchema = z.discriminatedUnion('kind', [
+ automationAllowedDirectorySchema,
+ automationAllowedDomainSchema,
+])
+
+export const automationApprovalAllowlistSchema = z
+ .array(automationApprovalAllowlistEntrySchema)
+ .max(AUTOMATION_APPROVAL_ALLOWLIST_MAX_ENTRIES)
+ .default([])
+
+export type AutomationApprovalAllowlistEntry = z.infer
+export type AutomationApprovalAllowlist = z.infer
diff --git a/apps/buddy/shared/automation/definition.ts b/apps/buddy/shared/automation/definition.ts
index d192600c9..a9e5da080 100644
--- a/apps/buddy/shared/automation/definition.ts
+++ b/apps/buddy/shared/automation/definition.ts
@@ -1,7 +1,9 @@
import { z } from 'zod'
import { BUDDY_THINKING_LEVELS } from '../conversation/modelSelection'
import { spaceExecutionContextSchema } from '../conversation/space'
+import { BUDDY_APPROVAL_POLICIES, BUDDY_DEFAULT_APPROVAL_POLICY } from '../permissions/approvalPolicy'
import { BUDDY_DEFAULT_EXECUTION_PROFILE, BUDDY_EXECUTION_PROFILES } from '../permissions/executionProfile'
+import { automationApprovalAllowlistSchema } from './approvalAllowlist'
import { AUTOMATION_EXECUTION_SNAPSHOT_MAX_BYTES, AUTOMATION_PROMPT_MAX_BYTES, automationBlockedReasonSchema, automationLifecycleStatusSchema, automationNameSchema, idSchema, utcInstantSchema } from './primitives'
import { automationTimingSchema } from './schedule'
@@ -17,6 +19,8 @@ export const automationModelTargetSchema = z.union([
export const automationDefinitionShape = {
actorId: idSchema.nullable().optional(),
+ approvalAllowlist: automationApprovalAllowlistSchema,
+ approvalPolicy: z.enum(BUDDY_APPROVAL_POLICIES).default(BUDDY_DEFAULT_APPROVAL_POLICY),
executionProfile: z.enum(BUDDY_EXECUTION_PROFILES).default(BUDDY_DEFAULT_EXECUTION_PROFILE),
model: automationModelTargetSchema,
name: automationNameSchema,
diff --git a/apps/buddy/shared/automation/index.ts b/apps/buddy/shared/automation/index.ts
index 906726561..a858be055 100644
--- a/apps/buddy/shared/automation/index.ts
+++ b/apps/buddy/shared/automation/index.ts
@@ -1,5 +1,7 @@
export { automationChangedNotificationSchema, automationListItemSchema, automationMutationRequestSchemas, automationPageSchema, automationRequestSchemas } from './api'
export type { AutomationListItem, AutomationMutationTargetRequest, CreateAutomationRequest, UpdateAutomationRequest } from './api'
+export { AUTOMATION_APPROVAL_ALLOWLIST_MAX_ENTRIES, automationApprovalAllowlistEntrySchema, automationApprovalAllowlistSchema } from './approvalAllowlist'
+export type { AutomationApprovalAllowlist, AutomationApprovalAllowlistEntry } from './approvalAllowlist'
export { automationDefinitionDraftSchema, automationExecutionSnapshotSchema, automationModelTargetSchema, automationSchema } from './definition'
export type { Automation, AutomationDefinitionDraft, AutomationExecutionSnapshot, AutomationModelTarget } from './definition'
export { automationOccurrencePageSchema, automationOccurrenceSchema, automationOccurrenceViewSchema, automationRunNowResultSchema } from './occurrences'
diff --git a/apps/buddy/shared/permissions/approvalReviewPayload.ts b/apps/buddy/shared/permissions/approvalReviewPayload.ts
index 055489dcf..9d1be436b 100644
--- a/apps/buddy/shared/permissions/approvalReviewPayload.ts
+++ b/apps/buddy/shared/permissions/approvalReviewPayload.ts
@@ -1,5 +1,7 @@
import { z } from 'zod'
+import { automationApprovalAllowlistSchema } from '../automation/approvalAllowlist'
import { BROWSER_ACTION_KINDS, BROWSER_PRESS_KEYS } from '../browser/primitives'
+import { BUDDY_APPROVAL_POLICIES, BUDDY_DEFAULT_APPROVAL_POLICY } from './approvalPolicy'
import {
BUDDY_DEFAULT_EXECUTION_PROFILE,
BUDDY_EXECUTION_PROFILES,
@@ -179,6 +181,8 @@ export const approvalReviewPayloadSchema = z.discriminatedUnion('card', [
}).strict(),
z.object({
allowForTurn: z.boolean().default(false),
+ approvalAllowlist: automationApprovalAllowlistSchema,
+ approvalPolicy: z.enum(BUDDY_APPROVAL_POLICIES).default(BUDDY_DEFAULT_APPROVAL_POLICY),
card: z.literal('automation'),
executionProfile: z.enum(BUDDY_EXECUTION_PROFILES).default(BUDDY_DEFAULT_EXECUTION_PROFILE),
modelMode: z.string().trim().min(1).max(512),
diff --git a/apps/buddy/shared/runs/__tests__/publicRunEvent.spec.ts b/apps/buddy/shared/runs/__tests__/publicRunEvent.spec.ts
index 396e16e5b..f0b09349f 100644
--- a/apps/buddy/shared/runs/__tests__/publicRunEvent.spec.ts
+++ b/apps/buddy/shared/runs/__tests__/publicRunEvent.spec.ts
@@ -83,6 +83,8 @@ describe('public run event projection', () => {
it('publishes typed approval reviews without raw arguments or internal storage fields', () => {
const review = {
allowForTurn: false,
+ approvalAllowlist: [],
+ approvalPolicy: 'manual',
card: 'automation',
executionProfile: 'full_access',
modelMode: 'pinned: offline-provider/offline-model',
diff --git a/apps/buddy/src/i18n/locales/en-US/approvals.ts b/apps/buddy/src/i18n/locales/en-US/approvals.ts
index 8d8443517..8212296b7 100644
--- a/apps/buddy/src/i18n/locales/en-US/approvals.ts
+++ b/apps/buddy/src/i18n/locales/en-US/approvals.ts
@@ -110,6 +110,11 @@ export default {
'desktop.approval.sandbox.isolatedScope': 'Runs inside the sandbox; filesystem and network restrictions remain enforced',
'desktop.approval.sandbox.networkTitle': 'Run command · Network access required',
'desktop.approval.sandbox.networkScope': 'Confirm execution allows this command to reach this host and port and may send content from authorized directories. Choose a scope in more actions to authorize later commands.',
+ 'desktop.approval.allowlist.directoryRead': 'Directory (read)',
+ 'desktop.approval.allowlist.directoryWrite': 'Directory (write)',
+ 'desktop.approval.allowlist.domain': 'Domain',
+ 'desktop.approval.allowlist.empty': 'No extra access opened',
+ 'desktop.approval.automation.allowlist': 'Runs without asking',
'desktop.approval.automation.executionProfile': 'Permissions',
'desktop.approval.kind.read': 'Read operation',
'desktop.approval.kind.write': 'Write operation',
diff --git a/apps/buddy/src/i18n/locales/en-US/automations.ts b/apps/buddy/src/i18n/locales/en-US/automations.ts
index 09cf128f7..f815329d3 100644
--- a/apps/buddy/src/i18n/locales/en-US/automations.ts
+++ b/apps/buddy/src/i18n/locales/en-US/automations.ts
@@ -38,6 +38,8 @@ export default {
'desktop.automations.editor.name': 'Name',
'desktop.automations.editor.space': 'Space (optional)',
'desktop.automations.editor.spacePlaceholder': 'Select a space',
+ 'desktop.automations.editor.allowlist': 'Runs without asking',
+ 'desktop.automations.editor.allowlistHint': 'With smart approval, unattended runs skip confirmation only for the directories and domains listed here; other operations that need confirmation are still denied. Set when the assistant creates the automation.',
'desktop.automations.editor.spaceHint': 'If left empty, every run creates a separate task with its own local workspace.',
'desktop.automations.editor.unavailableSpace': '{name} (unavailable)',
'desktop.automations.editor.unknownSpace': 'Unavailable space',
diff --git a/apps/buddy/src/i18n/locales/en-US/chat.ts b/apps/buddy/src/i18n/locales/en-US/chat.ts
index 27f8f995e..f573510ee 100644
--- a/apps/buddy/src/i18n/locales/en-US/chat.ts
+++ b/apps/buddy/src/i18n/locales/en-US/chat.ts
@@ -237,9 +237,11 @@ export default {
'desktop.chat.executionProfileOpen': 'View execution permissions',
'desktop.chat.permissionModeFullDescription': 'Run commands with local permissions.',
'desktop.chat.permissionModeManual': 'Manual approval',
+ 'desktop.chat.permissionModeManualBackgroundDescription': 'Unattended runs cannot ask: changes, commands, and external interactions are always denied.',
'desktop.chat.permissionModeManualDescription': 'Ask before changes, commands, and external interactions.',
'desktop.chat.permissionModeManualSandboxDescription': 'Keep commands in the sandbox; ask before changes, commands and external interactions.',
'desktop.chat.permissionModePolicy': 'Smart approval',
+ 'desktop.chat.permissionModePolicyBackgroundDescription': 'Automatically allow safe operations and edits in authorized directories; operations that need confirmation are denied when unattended.',
'desktop.chat.permissionModePolicyDescription': 'Automatically allow safe queries, file edits and verified ordinary-file deletions in authorized directories; review other operations according to policy.',
'desktop.chat.permissionModePolicySandboxDescription': 'Run commands in a sandbox with writes to authorized directories; ask separately for external network access, extra directories and host execution.',
'desktop.chat.permissionModeReadOnlyDescription': 'Read files and run safe read-only diagnostics.',
diff --git a/apps/buddy/src/i18n/locales/zh-CN/approvals.ts b/apps/buddy/src/i18n/locales/zh-CN/approvals.ts
index 51f09c529..d63e9f60b 100644
--- a/apps/buddy/src/i18n/locales/zh-CN/approvals.ts
+++ b/apps/buddy/src/i18n/locales/zh-CN/approvals.ts
@@ -108,6 +108,11 @@ export default {
'desktop.approval.sandbox.directoryRead': '读取目录及其内容,不增加写入或删除权限',
'desktop.approval.sandbox.directoryWrite': '读取、新建、修改和删除目录内的文件;不代表已有备份',
'desktop.approval.sandbox.approveDirectory': '允许本轮访问此目录',
+ 'desktop.approval.allowlist.directoryRead': '目录(只读)',
+ 'desktop.approval.allowlist.directoryWrite': '目录(可写)',
+ 'desktop.approval.allowlist.domain': '域名',
+ 'desktop.approval.allowlist.empty': '未开放额外权限',
+ 'desktop.approval.automation.allowlist': '免确认范围',
'desktop.approval.automation.executionProfile': '权限',
'desktop.approval.kind.read': '读取操作',
'desktop.approval.kind.write': '写入操作',
diff --git a/apps/buddy/src/i18n/locales/zh-CN/automations.ts b/apps/buddy/src/i18n/locales/zh-CN/automations.ts
index 5c4868e74..01ba5b82e 100644
--- a/apps/buddy/src/i18n/locales/zh-CN/automations.ts
+++ b/apps/buddy/src/i18n/locales/zh-CN/automations.ts
@@ -36,6 +36,8 @@ export default {
'desktop.automations.editor.name': '名称',
'desktop.automations.editor.space': '空间(可选)',
'desktop.automations.editor.spacePlaceholder': '选择空间',
+ 'desktop.automations.editor.allowlist': '免确认范围',
+ 'desktop.automations.editor.allowlistHint': '智能审批下,无人值守的运行只对这里列出的目录和域名免确认放行,其他需要确认的操作仍会被拒绝。由智能体创建自动化时设置。',
'desktop.automations.editor.spaceHint': '未选择时,每次运行都创建独立任务,并使用该任务的 Buddy 本地工作目录。',
'desktop.automations.editor.unavailableSpace': '{name}(不可用)',
'desktop.automations.editor.unknownSpace': '不可用空间',
diff --git a/apps/buddy/src/i18n/locales/zh-CN/chat.ts b/apps/buddy/src/i18n/locales/zh-CN/chat.ts
index 75b159f95..02bcab9da 100644
--- a/apps/buddy/src/i18n/locales/zh-CN/chat.ts
+++ b/apps/buddy/src/i18n/locales/zh-CN/chat.ts
@@ -235,9 +235,11 @@ export default {
'desktop.chat.executionProfileOpen': '查看执行权限',
'desktop.chat.permissionModeFullDescription': '命令使用本机权限执行。',
'desktop.chat.permissionModeManual': '人工审批',
+ 'desktop.chat.permissionModeManualBackgroundDescription': '无人值守无法确认:修改、命令与外部交互一律被拒绝。',
'desktop.chat.permissionModeManualDescription': '修改、命令与外部交互均需批准。',
'desktop.chat.permissionModeManualSandboxDescription': '命令仍在沙盒内;修改、命令与外部交互均需批准。',
'desktop.chat.permissionModePolicy': '智能审批',
+ 'desktop.chat.permissionModePolicyBackgroundDescription': '自动放行安全操作与授权目录内的编辑;需要确认的操作无人值守时会被拒绝。',
'desktop.chat.permissionModePolicyDescription': '自动放行安全查询,以及授权目录内的文件编辑和已验证的普通文件删除;其他操作按规则审批。',
'desktop.chat.permissionModePolicySandboxDescription': '命令在沙盒内自动执行,可修改授权目录;外部网络、追加目录及本机直接执行另行确认。',
'desktop.chat.permissionModeReadOnlyDescription': '读取文件并运行安全的只读诊断。',
diff --git a/apps/buddy/src/modules/automations/model/__tests__/automationEditorForm.spec.ts b/apps/buddy/src/modules/automations/model/__tests__/automationEditorForm.spec.ts
index 04c3569ca..2d1e09a89 100644
--- a/apps/buddy/src/modules/automations/model/__tests__/automationEditorForm.spec.ts
+++ b/apps/buddy/src/modules/automations/model/__tests__/automationEditorForm.spec.ts
@@ -63,6 +63,14 @@ describe('automation editor form', () => {
expect(buildAutomationTiming(form, definition.timing)).toEqual(definition.timing)
})
+ it('retains the manual approval policy chosen for an automation', () => {
+ const definition = { ...draft(schedules[1]!), approvalPolicy: 'manual' as const }
+ const form = automationEditorFormFromDefinition(definition, currentTime)
+
+ expect(form.approvalPolicy).toBe('manual')
+ expect(buildAutomationDraft(form, definition.timing)).toEqual(definition)
+ })
+
it.each(BUDDY_EXECUTION_PROFILES)('retains %s permission, pinned model and space when renamed', (executionProfile) => {
const definition = {
...draft(schedules[1]!),
@@ -188,6 +196,8 @@ describe('automation editor form', () => {
function draft(schedule: AutomationSchedule): AutomationDefinitionDraft {
return {
actorId: null,
+ approvalAllowlist: [],
+ approvalPolicy: 'policy',
executionProfile: 'workspace_write',
model: {
mode: 'pinned',
diff --git a/apps/buddy/src/modules/automations/model/automationEditorForm.ts b/apps/buddy/src/modules/automations/model/automationEditorForm.ts
index 980662fb3..d527d4c8e 100644
--- a/apps/buddy/src/modules/automations/model/automationEditorForm.ts
+++ b/apps/buddy/src/modules/automations/model/automationEditorForm.ts
@@ -17,6 +17,8 @@ export function buildAutomationDraft(
return null
const result = automationDefinitionDraftSchema.safeParse({
actorId: value.actorId,
+ approvalAllowlist: value.approvalAllowlist,
+ approvalPolicy: value.approvalPolicy,
executionProfile: value.executionProfile,
model,
name: value.name,
@@ -126,6 +128,8 @@ export function createAutomationEditorForm(currentTime: Temporal.ZonedDateTime):
.with({ minute: 0, second: 0, millisecond: 0, microsecond: 0, nanosecond: 0 })
return {
actorId: null,
+ approvalAllowlist: [],
+ approvalPolicy: 'policy',
activeFrom: null,
activeUntil: null,
anchorLocal: formatWallTime(now),
@@ -158,6 +162,8 @@ export function automationEditorFormFromDefinition(
next.actorId = automation.actorId ?? null
next.activeFrom = automation.timing.activeFrom
next.activeUntil = automation.timing.activeUntil
+ next.approvalAllowlist = automation.approvalAllowlist.map(entry => ({ ...entry }))
+ next.approvalPolicy = automation.approvalPolicy
next.executionProfile = automation.executionProfile
next.modelMode = automation.model.mode
next.name = automation.name
diff --git a/apps/buddy/src/modules/automations/model/typing.ts b/apps/buddy/src/modules/automations/model/typing.ts
index 397f8de27..1cc73b3fc 100644
--- a/apps/buddy/src/modules/automations/model/typing.ts
+++ b/apps/buddy/src/modules/automations/model/typing.ts
@@ -1,4 +1,6 @@
+import type { AutomationApprovalAllowlistEntry } from '@buddy-shared/automation/approvalAllowlist'
import type { BuddyThinkingLevel } from '@buddy-shared/conversation/modelSelection'
+import type { BuddyApprovalPolicy } from '@buddy-shared/permissions/approvalPolicy'
import type { BuddyExecutionProfile } from '@buddy-shared/permissions/executionProfile'
export type AutomationCalendarCadence = 'daily' | 'weekly' | 'monthly' | 'yearly'
@@ -23,6 +25,8 @@ export interface AutomationScheduleForm {
export interface AutomationEditorForm extends AutomationScheduleForm {
actorId: string | null
+ approvalAllowlist: AutomationApprovalAllowlistEntry[]
+ approvalPolicy: BuddyApprovalPolicy
executionProfile: BuddyExecutionProfile
modelMode: 'default' | 'pinned'
name: string
diff --git a/apps/buddy/src/modules/automations/pages/__tests__/useAutomationEditorRoute.spec.ts b/apps/buddy/src/modules/automations/pages/__tests__/useAutomationEditorRoute.spec.ts
index ef7cf86e1..a33ead051 100644
--- a/apps/buddy/src/modules/automations/pages/__tests__/useAutomationEditorRoute.spec.ts
+++ b/apps/buddy/src/modules/automations/pages/__tests__/useAutomationEditorRoute.spec.ts
@@ -7,6 +7,8 @@ import { deferred } from '../../../../../__tests__/deferred'
import { useAutomationEditorRoute } from '../useAutomationEditorRoute'
const draft: AutomationDefinitionDraft = {
+ approvalAllowlist: [],
+ approvalPolicy: 'policy',
executionProfile: 'workspace_write',
model: { mode: 'default' },
name: 'Example',
diff --git a/apps/buddy/src/modules/automations/widgets/editor/DesktopAutomationEditor.vue b/apps/buddy/src/modules/automations/widgets/editor/DesktopAutomationEditor.vue
index 5e7c4c0de..52e9ec4c7 100644
--- a/apps/buddy/src/modules/automations/widgets/editor/DesktopAutomationEditor.vue
+++ b/apps/buddy/src/modules/automations/widgets/editor/DesktopAutomationEditor.vue
@@ -4,6 +4,7 @@ import { NAlert, NButton, NForm, NFormItem, NInput, NScrollbar, NSpin } from 'na
import { shallowRef } from 'vue'
import { useBuddyI18n } from '@/i18n/buddyI18n'
import { BuddyActorSelect } from '@/modules/buddies/ui'
+import DesktopApprovalAllowlist from '@/shared/ui/approval-allowlist/DesktopApprovalAllowlist.vue'
import DesktopIcon from '@/shared/ui/icon/DesktopIcon.vue'
import DesktopSelect from '@/shared/ui/select/DesktopSelect.vue'
import DesktopAutomationPromptComposer from './DesktopAutomationPromptComposer.vue'
@@ -100,6 +101,7 @@ function close(): void {
+
+
+
+
{{ t('desktop.automations.editor.allowlistHint') }}
+
+
+
@@ -209,6 +221,18 @@ function close(): void {
display: grid;
}
+.desktop-automation-editor__allowlist {
+ display: grid;
+ gap: 6px;
+
+ p {
+ margin: 0;
+ color: var(--buddy-text-muted);
+ font-size: 11px;
+ line-height: 1.5;
+ }
+}
+
.desktop-automation-editor__space-field {
p {
margin: -10px 0 8px;
diff --git a/apps/buddy/src/modules/automations/widgets/editor/DesktopAutomationPromptComposer.vue b/apps/buddy/src/modules/automations/widgets/editor/DesktopAutomationPromptComposer.vue
index ccfde752a..9ac97e82e 100644
--- a/apps/buddy/src/modules/automations/widgets/editor/DesktopAutomationPromptComposer.vue
+++ b/apps/buddy/src/modules/automations/widgets/editor/DesktopAutomationPromptComposer.vue
@@ -1,7 +1,9 @@
+
+
+
+ -
+ {{ entryLabel(entry) }}
+
{{ entryTarget(entry) }}
+
+
+ {{ t('desktop.approval.allowlist.empty') }}
+
+
+
From 932e813572a77f23cd21039de3c998168ab078df Mon Sep 17 00:00:00 2001
From: probe
Date: Mon, 19 Oct 2026 17:09:34 +0800
Subject: [PATCH 2/3] =?UTF-8?q?fix(buddy):=20=E5=8E=BB=E6=8E=89=E5=85=8D?=
=?UTF-8?q?=E7=A1=AE=E8=AE=A4=E7=99=BD=E5=90=8D=E5=8D=95=E5=8C=B9=E9=85=8D?=
=?UTF-8?q?=E5=99=A8=E6=96=87=E4=BB=B6=E6=9C=AB=E5=B0=BE=E7=9A=84=E5=A4=9A?=
=?UTF-8?q?=E4=BD=99=E7=A9=BA=E8=A1=8C?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-Authored-By: Claude Sonnet 5.5
---
apps/buddy/service/src/permissions/approvalAllowlist.ts | 1 -
1 file changed, 1 deletion(-)
diff --git a/apps/buddy/service/src/permissions/approvalAllowlist.ts b/apps/buddy/service/src/permissions/approvalAllowlist.ts
index 599a99670..6898abf45 100644
--- a/apps/buddy/service/src/permissions/approvalAllowlist.ts
+++ b/apps/buddy/service/src/permissions/approvalAllowlist.ts
@@ -48,4 +48,3 @@ function isDomainAllowed(entries: readonly AutomationApprovalAllowlistEntry[], h
: normalized === entry.host
})
}
-
From 984eace50b15c5ea9480701fda7bdbe84435e57e Mon Sep 17 00:00:00 2001
From: probe
Date: Tue, 20 Oct 2026 16:15:05 +0800
Subject: [PATCH 3/3] =?UTF-8?q?fix(buddy):=20=E4=BF=AE=E5=A4=8D=E5=8F=98?=
=?UTF-8?q?=E6=9B=B4=E6=81=A2=E5=A4=8D=E8=BF=81=E7=A7=BB=E7=94=A8=E4=BE=8B?=
=?UTF-8?q?=E5=9C=A8=E6=96=B0=E5=A2=9E=20v32=20=E5=90=8E=E9=87=8D=E5=A4=8D?=
=?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=88=97?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
该用例通过拨回 user_version 模拟 v30 旧库,重新打开时会再次执行 v31 与 v32,
而自动化审批列已存在导致 duplicate column name。模拟旧库时同步去掉这两列。
Co-Authored-By: Claude Sonnet 5.5
---
.../src/changes/__tests__/mutationRecoveryDecisions.spec.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/buddy/service/src/changes/__tests__/mutationRecoveryDecisions.spec.ts b/apps/buddy/service/src/changes/__tests__/mutationRecoveryDecisions.spec.ts
index d4e6f6f90..88a6a490e 100644
--- a/apps/buddy/service/src/changes/__tests__/mutationRecoveryDecisions.spec.ts
+++ b/apps/buddy/service/src/changes/__tests__/mutationRecoveryDecisions.spec.ts
@@ -65,7 +65,7 @@ describe('durable mutation recovery decisions', () => {
lease.markHazard('OWNED_PROCESS_CLEANUP_UNCONFIRMED')
}
await f.mutations.dispose()
- database.exec('DROP TABLE run_mutation_evidence; DROP TABLE run_mutation_executions; PRAGMA user_version = 30')
+ database.exec('DROP TABLE run_mutation_evidence; DROP TABLE run_mutation_executions; ALTER TABLE automations DROP COLUMN approval_policy; ALTER TABLE automations DROP COLUMN approval_allowlist_json; PRAGMA user_version = 30')
database.close()
database = openBuddyDatabase({ databasePath })
f = setup(database)