diff --git a/apps/buddy/service/src/BuddyService.ts b/apps/buddy/service/src/BuddyService.ts index b2bc3deb9..428c8ba6e 100644 --- a/apps/buddy/service/src/BuddyService.ts +++ b/apps/buddy/service/src/BuddyService.ts @@ -169,6 +169,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' @@ -1028,6 +1029,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 42a3a0640..fdccb5580 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' @@ -62,6 +64,7 @@ export interface BuddySessionExtensionServices { export interface CreateBuddySessionExtensionsOptions { actorKind?: 'actor' | 'npc' + approvalAllowlist?: readonly AutomationApprovalAllowlistEntry[] approvalPolicy: BuddyApprovalPolicy canonicalRoot: string conversationId: string @@ -136,6 +139,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), getSandboxDirectories: run => sandboxDirectories.get(run), @@ -144,9 +148,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/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) diff --git a/apps/buddy/service/src/permissions/PermissionEngine.ts b/apps/buddy/service/src/permissions/PermissionEngine.ts index 9019f7109..ce7fa7a23 100644 --- a/apps/buddy/service/src/permissions/PermissionEngine.ts +++ b/apps/buddy/service/src/permissions/PermissionEngine.ts @@ -16,6 +16,7 @@ import { containsCanonicalPath } from '../../../platform/filesystem/filePaths' import { sandboxDirectoryRequestSchema } from '../../../shared/permissions/shellSandbox' import { ShellPolicy } from '../approvals/ShellPolicy' import { validateSandboxDirectory } from '../sandbox/SandboxDirectoryPermissions' +import { isAskCoveredByAllowlist } from './approvalAllowlist' import { classifyPath, PathClassificationError, toGrantRoot } from './classifyPath' import { lookupPermissionOutcome } from './decisionTable' import { allow, ask, deny } from './permissionContract' @@ -473,9 +474,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 2882c41c1..d24cacd42 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 @@ -29,6 +31,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 { @@ -131,6 +134,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, @@ -151,7 +157,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 7f9a6601b..2535d1672 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, @@ -213,7 +214,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] } } }], @@ -234,6 +235,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..6898abf45 --- /dev/null +++ b/apps/buddy/service/src/permissions/approvalAllowlist.ts @@ -0,0 +1,50 @@ +import type { AutomationApprovalAllowlistEntry } from '../../../shared/automation/approvalAllowlist' +import type { PermissionDecision } from './permissionContract' +import { containsCanonicalPath } from '../../../platform/filesystem/filePaths' + +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') + && containsCanonicalPath(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 + }) +} diff --git a/apps/buddy/service/src/permissions/permissionContract.ts b/apps/buddy/service/src/permissions/permissionContract.ts index 235a43f26..7c74bfb13 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 a6e35f7cd..fb4effc65 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/v32AutomationApproval.ts b/apps/buddy/service/src/storage/migrations/v32AutomationApproval.ts new file mode 100644 index 000000000..02a839d95 --- /dev/null +++ b/apps/buddy/service/src/storage/migrations/v32AutomationApproval.ts @@ -0,0 +1,6 @@ +export const BUDDY_V32_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 089ebb9d3..a6a7011c0 100644 --- a/apps/buddy/service/src/storage/schema.ts +++ b/apps/buddy/service/src/storage/schema.ts @@ -28,8 +28,8 @@ 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_MUTATION_RECOVERY_SCHEMA_SQL } from './migrations/v31MutationRecovery' +import { BUDDY_V32_AUTOMATION_APPROVAL_SCHEMA_SQL } from './migrations/v32AutomationApproval' export interface BuddySchemaMigration { foreignKeys?: 'off' @@ -37,7 +37,7 @@ export interface BuddySchemaMigration { version: number } -export const BUDDY_SCHEMA_VERSION = 31 as const +export const BUDDY_SCHEMA_VERSION = 32 as const export const BUDDY_SCHEMA_MIGRATIONS: readonly BuddySchemaMigration[] = [ { sql: BUDDY_V1_INITIAL_SCHEMA_SQL, version: 1 }, @@ -71,4 +71,5 @@ export const BUDDY_SCHEMA_MIGRATIONS: readonly BuddySchemaMigration[] = [ { sql: BUDDY_V29_SESSION_REFERENCES_SCHEMA_SQL, version: 29 }, { sql: BUDDY_V30_TASK_MODELS_SCHEMA_SQL, version: 30 }, { sql: BUDDY_V31_MUTATION_RECOVERY_SCHEMA_SQL, version: 31 }, + { sql: BUDDY_V32_AUTOMATION_APPROVAL_SCHEMA_SQL, version: 32 }, ] 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 e8d2934af..7de9cf689 100644 --- a/apps/buddy/src/i18n/locales/en-US/chat.ts +++ b/apps/buddy/src/i18n/locales/en-US/chat.ts @@ -238,9 +238,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 f0bcf1aa3..fce407419 100644 --- a/apps/buddy/src/i18n/locales/zh-CN/chat.ts +++ b/apps/buddy/src/i18n/locales/zh-CN/chat.ts @@ -236,9 +236,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 @@ + + + +