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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/buddy/service/src/BuddyService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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'
Expand Down Expand Up @@ -62,6 +64,7 @@ export interface BuddySessionExtensionServices {

export interface CreateBuddySessionExtensionsOptions {
actorKind?: 'actor' | 'npc'
approvalAllowlist?: readonly AutomationApprovalAllowlistEntry[]
approvalPolicy: BuddyApprovalPolicy
canonicalRoot: string
conversationId: string
Expand Down Expand Up @@ -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),
Expand All @@ -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() },
Expand Down
3 changes: 3 additions & 0 deletions apps/buddy/service/src/agent/sessions/BuddySessionFactory.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ describe('approvalService', () => {
reuseScopes: [],
arguments: { operation: 'upsert' },
automation: {
approvalAllowlist: [],
approvalPolicy: 'policy',
executionProfile: 'workspace_write',
modelMode: 'default',
name: 'Daily review',
Expand Down Expand Up @@ -319,6 +321,8 @@ describe('approvalService', () => {
reuseScopes: [],
arguments: { operation: 'upsert' },
automation: {
approvalAllowlist: [],
approvalPolicy: 'policy',
executionProfile: 'workspace_write',
modelMode: 'default',
name: 'Daily review',
Expand Down
4 changes: 4 additions & 0 deletions apps/buddy/service/src/automations/AutomationService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -623,6 +625,8 @@ function encodePage<T>(page: AutomationPageRecord<T>): AutomationPage<T> {

function toDefinitionDraft(automation: Automation): AutomationDefinitionDraft {
return {
approvalAllowlist: automation.approvalAllowlist,
approvalPolicy: automation.approvalPolicy,
executionProfile: automation.executionProfile,
model: automation.model,
name: automation.name,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,8 @@ function dailyDraft(
overrides: Partial<AutomationDefinitionDraft> = {},
): AutomationDefinitionDraft {
return {
approvalAllowlist: [],
approvalPolicy: 'policy',
executionProfile: 'workspace_write',
model: { mode: 'default' },
name: 'Automation',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,8 @@ function createFixture() {
}
const automation = service.create({
draft: {
approvalAllowlist: [],
approvalPolicy: 'policy',
executionProfile: input.executionProfile ?? 'workspace_write',
model: input.model,
name: 'Automation',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ describe('automationOccurrenceLifecycleService', () => {
})
const automation = automations.create({
draft: {
approvalAllowlist: [],
approvalPolicy: 'policy',
executionProfile: 'workspace_write',
model: { mode: 'default' },
name: 'Automation',
Expand Down Expand Up @@ -148,6 +150,8 @@ describe('automationOccurrenceLifecycleService', () => {
})
const automation = automations.create({
draft: {
approvalAllowlist: [],
approvalPolicy: 'policy',
executionProfile: 'workspace_write',
model: { mode: 'default' },
name: 'Automation',
Expand Down Expand Up @@ -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 })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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',
Expand All @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
24 changes: 24 additions & 0 deletions apps/buddy/service/src/automations/createAutomationTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down Expand Up @@ -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.',
Expand Down Expand Up @@ -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'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
9 changes: 6 additions & 3 deletions apps/buddy/service/src/permissions/PermissionEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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(
Expand Down
12 changes: 11 additions & 1 deletion apps/buddy/service/src/permissions/ToolAuthorizationService.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -15,6 +16,7 @@ import { PathClassificationError } from './classifyPath'
import { PermissionEngine } from './PermissionEngine'

export interface ToolAuthorizationOptions {
approvalAllowlist?: readonly AutomationApprovalAllowlistEntry[]
approvalService: Pick<ApprovalService, 'request'>
approvalAvailable: boolean
approvalPolicy: BuddyApprovalPolicy
Expand All @@ -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 {
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
Loading