diff --git a/.playwright/scripts/__tests__/network-permissions.e2e.mjs b/.playwright/scripts/__tests__/network-permissions.e2e.mjs index 786dc5467..7a55d6986 100644 --- a/.playwright/scripts/__tests__/network-permissions.e2e.mjs +++ b/.playwright/scripts/__tests__/network-permissions.e2e.mjs @@ -71,7 +71,7 @@ test('real network requests enforce saved access, file download, redirects and m } await providers.setDefaultModel({ providerId: 'permission-fixture', modelId: 'permission-fixture', reasoning: null }) }, `http://127.0.0.1:${server.address().port}/v1`) - await desktop.page.evaluate(server => window.lexoraDesktop.settings.update({ proxy: { mode: 'custom', server } }), `http://127.0.0.1:${server.address().port}`) + await desktop.page.evaluate(server => window.lexoraDesktop.settings.update({ proxy: { mode: 'custom', server }, runtime: { codemode: false } }), `http://127.0.0.1:${server.address().port}`) await desktop.page.reload() const send = async (text) => { await desktop.page.locator('.desktop-chat-composer__prosemirror:visible').fill(text) @@ -144,6 +144,45 @@ test('real network requests enforce saved access, file download, redirects and m browser.networkPermissions.rules[0].access = 'deny' await window.lexoraDesktop.settings.update({ browser: { networkPermissions: browser.networkPermissions } }) }) + const database = new DatabaseSync(path.join(instance.home, 'buddy/buddy.sqlite3'), { readOnly: true }) + let conversationId + try { + conversationId = database.prepare('SELECT conversation_id FROM runs ORDER BY rowid DESC LIMIT 1').get().conversation_id + } + finally { database.close() } + await desktop.page.evaluate(async (conversationId) => { + await window.lexoraDesktop.localChat.conversations.setPermissionSettings(conversationId, { approvalPolicy: 'policy', executionProfile: 'full_access' }) + const { browser } = await window.lexoraDesktop.settings.get() + browser.networkPermissions.rules[0].access = 'ask' + await window.lexoraDesktop.settings.update({ browser: { networkPermissions: browser.networkPermissions } }) + }, conversationId) + const fullAccessSettings = await settings() + await send('Fetch a file with full access.') + await expect.poll(completed).toBe(6) + await expect(card).toBeHidden() + expect(hits.at(-1)).toBe('/file') + const fullAccessMessages = requests.at(-1).messages + const fullAccessResult = JSON.stringify(fullAccessMessages.slice(fullAccessMessages.findLastIndex(message => message.role === 'user') + 1)) + expect(fullAccessResult).not.toMatch(/NETWORK_PERMISSION_DENIED|APPROVAL_UNAVAILABLE_IN_BACKGROUND/) + expect(await settings()).toEqual(fullAccessSettings) + const beforeRedirect = hits.filter(hit => hit === '/landing').length + await send('Open the browser to a redirect with full access.') + await expect.poll(completed).toBe(7) + await expect(card).toBeHidden() + expect(hits.filter(hit => hit === '/landing')).toHaveLength(beforeRedirect + 1) + expect(hits).not.toContain('/pixel') + await desktop.page.evaluate(async () => { + const { browser } = await window.lexoraDesktop.settings.get() + browser.networkPermissions.rules[0].access = 'deny' + await window.lexoraDesktop.settings.update({ browser: { networkPermissions: browser.networkPermissions } }) + }) + const beforeFullAccessDenial = hits.length + await send('Fetch a denied page with full access.') + await expect.poll(completed).toBe(8) + expect(hits).toHaveLength(beforeFullAccessDenial) + await expect(card).toBeHidden() + expect(JSON.stringify(requests.at(-1).messages)).toContain('NETWORK_PERMISSION_DENIED') + const address = desktop.page.getByTestId('browser-address') await address.fill('http://permission.example.test/manual') await address.press('Enter') diff --git a/apps/buddy/electron/main/runtime/__tests__/buddyServiceEnvironmentPaths.spec.ts b/apps/buddy/electron/main/runtime/__tests__/buddyServiceEnvironmentPaths.spec.ts index 1065cc413..b8147b0a9 100644 --- a/apps/buddy/electron/main/runtime/__tests__/buddyServiceEnvironmentPaths.spec.ts +++ b/apps/buddy/electron/main/runtime/__tests__/buddyServiceEnvironmentPaths.spec.ts @@ -31,9 +31,10 @@ describe('service environment and sensitive path policy', () => { cwd: root, grants: [], owner: { id: 'fixture', kind: 'conversation' as const }, - profile: 'full_access' as const, + profile: 'workspace_write' as const, toolName: 'read', } + await expect(engine.decide({ ...request, profile: 'full_access' })).resolves.toEqual({ type: 'allow' }) expect(environment).not.toHaveProperty('OPENAI_API_KEY') await expect(engine.decide(request)).resolves.toMatchObject({ type: 'ask' }) await expect(engine.decide({ ...request, toolName: 'write' })) diff --git a/apps/buddy/service/src/agent/extensions/__tests__/toolPolicyExtension.spec.ts b/apps/buddy/service/src/agent/extensions/__tests__/toolPolicyExtension.spec.ts index 33b04bb81..179a180c4 100644 --- a/apps/buddy/service/src/agent/extensions/__tests__/toolPolicyExtension.spec.ts +++ b/apps/buddy/service/src/agent/extensions/__tests__/toolPolicyExtension.spec.ts @@ -54,7 +54,7 @@ describe('toolPolicyExtension always-confirm', () => { })) }) - it('awaits capability preparation and keeps forced approval in full access', async () => { + it('awaits capability preparation and automatically approves full-access operations', async () => { const request = vi.fn().mockResolvedValue({ approvalId: 'approval-1', decision: 'approved_once', @@ -102,9 +102,7 @@ describe('toolPolicyExtension always-confirm', () => { await expect(invoke(event)).resolves.toBeUndefined() expect(classifyTool).toHaveBeenCalledWith(event, run) - expect(request).toHaveBeenCalledWith(expect.objectContaining({ - kind: 'shell', - })) + expect(request).not.toHaveBeenCalled() }) it('does not let full access bypass a capability-owned classification failure', async () => { diff --git a/apps/buddy/service/src/agent/extensions/discovery/__tests__/toolDiscoverySession.spec.ts b/apps/buddy/service/src/agent/extensions/discovery/__tests__/toolDiscoverySession.spec.ts index 19b651cc5..90b65eeee 100644 --- a/apps/buddy/service/src/agent/extensions/discovery/__tests__/toolDiscoverySession.spec.ts +++ b/apps/buddy/service/src/agent/extensions/discovery/__tests__/toolDiscoverySession.spec.ts @@ -185,7 +185,7 @@ describe('real Pi tool discovery loop', () => { authorization: new ToolAuthorizationService({ approvalAvailable: true, approvalPolicy: 'policy', - executionProfile: 'full_access', + executionProfile: 'workspace_write', cwd: root, owner: { id: 'conversation-1', kind: 'conversation' }, getGrants: () => [], diff --git a/apps/buddy/service/src/agent/extensions/discovery/__tests__/toolScopeSession.spec.ts b/apps/buddy/service/src/agent/extensions/discovery/__tests__/toolScopeSession.spec.ts index 46c6fd2d5..8a319462c 100644 --- a/apps/buddy/service/src/agent/extensions/discovery/__tests__/toolScopeSession.spec.ts +++ b/apps/buddy/service/src/agent/extensions/discovery/__tests__/toolScopeSession.spec.ts @@ -3,7 +3,7 @@ import type { BuddySettings } from '../../../../../../shared/buddies/buddySettin import type { BuddyExecutionProfile } from '../../../../../../shared/permissions/executionProfile' import type { BuddyCapability } from '../../BuddyCapability' import type { BuddySessionExtensionServices } from '../../createBuddySessionExtensions' -import { mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import process from 'node:process' @@ -11,7 +11,10 @@ import { createAssistantMessageEventStream, getCurrentSystemPrompt, getCurrentTo import { defineTool, ModelRuntime } from '@earendil-works/pi-coding-agent' import { Type } from 'typebox' import { afterEach, describe, expect, it, vi } from 'vitest' +import * as userHomePaths from '../../../../../../platform/filesystem/userHomePaths' import { MutationCoordinator } from '../../../../changes/MutationCoordinator' +import { FileResourceCoordinator } from '../../../../files/FileResourceCoordinator' +import { NativeFileEngine } from '../../../../files/NativeFileEngine' import { ShellSelection } from '../../../../sandbox/ShellSelection' import { createIsolatedBuddySession } from '../../../sessions/__tests__/isolatedBuddySession' import { createBuddySessionExtensions } from '../../createBuddySessionExtensions' @@ -102,6 +105,38 @@ describe('role tool scope in real Pi sessions', () => { expect(resumed.approved).toEqual(['lexora_host_shell']) }) + it.each([false, true])('writes and reads outside grants and sensitive locations across full-access recovery, codemode=%s', async (codemode) => { + const outside = await realpath(await mkdtemp(join(tmpdir(), 'buddy-full-access-'))) + cleanups.push(() => rm(outside, { recursive: true, force: true })) + const home = vi.spyOn(userHomePaths, 'resolveUserHomePaths').mockReturnValue([outside]) + cleanups.push(async () => { + home.mockRestore() + }) + await mkdir(join(outside, '.ssh')) + const f = await fixture({ executionProfile: 'full_access', codemode }) + const invoke = async (session: typeof f, name: string, arguments_: ToolCall['arguments']) => { + await session.turn(codemode ? scriptCall(name, arguments_) : { name, arguments: arguments_ }) + } + for (const path of [join(outside, 'document.txt'), join(outside, '.ssh', 'fixture.txt')]) { + await invoke(f, 'write', { path, content: 'synthetic full-access content' }) + expect(f.lastResult()).toContain('Successfully wrote') + expect(await readFile(path, 'utf8')).toBe('synthetic full-access content') + await invoke(f, 'read', { path }) + expect(f.lastResult()).toContain('synthetic full-access content') + } + expect(f.approved).toEqual([]) + await f.shutdown() + const resumed = await fixture({ root: f.root, piSessionFile: f.sessionFile, executionProfile: 'full_access', codemode }) + const sensitivePath = join(outside, '.ssh', 'fixture.txt') + await invoke(resumed, 'write', { path: sensitivePath, content: 'after restart' }) + expect(await readFile(sensitivePath, 'utf8')).toBe('after restart') + expect(resumed.approved).toEqual([]) + resumed.extensions.updatePermissionSettings({ approvalPolicy: 'policy', executionProfile: 'workspace_write' }) + await invoke(resumed, 'write', { path: sensitivePath, content: 'must stay blocked' }) + expect(resumed.lastResult()).toContain('SENSITIVE_PATH') + expect(await readFile(sensitivePath, 'utf8')).toBe('after restart') + }) + it('keeps none empty across declarations, search and native CodeMode catalogs', async () => { const f = await fixture({ tools: { mode: 'none' } }) await f.turn(scriptCall('lexora_tool_search', { toolNames: [deniedMcp] })) @@ -126,7 +161,7 @@ describe('role tool scope in real Pi sessions', () => { expect(f.lastResult()).not.toContain(marker) expect(f.lastResult()).not.toContain(deniedMcp) expect(f.lastResult()).toContain('"external":false') - expect(f.approved).toEqual([allowedMcp]) + expect(f.approved).toEqual([]) await f.turn(scriptCall('lexora_tool_search', { toolNames: [deniedMcp, allowedMcp] })) expect(f.lastResult()).toContain(`"notFound":["${deniedMcp}"]`) expect(f.requests.map(request => request.tools).join('\n')).not.toContain(marker) @@ -241,6 +276,15 @@ async function fixture(options: { tools?: BuddySettings['tools'], fileAccessLimi } }, } const descriptors = [{ extensionId: 'fixture.shells', revision: '1', configurationRevision: '1', preferred: [], candidates: [] }] + const engine = new NativeFileEngine(join(root, 'file-operations')) + await engine.ready() + const fileResources = new FileResourceCoordinator(engine) + const mutations = new MutationCoordinator({ resources: fileResources }) + const disposeResources = async () => { + await mutations.dispose() + await engine.dispose() + } + cleanups.push(disposeResources) const services: BuddySessionExtensionServices = { shells: options.shells ? { snapshot: async () => new ShellSelection(descriptors, async () => descriptors) } : undefined, approvalService: { request: async (input) => { @@ -248,8 +292,8 @@ async function fixture(options: { tools?: BuddySettings['tools'], fileAccessLimi return { approvalId: `approval-${approved.length}`, decision: 'approved_once' } } }, attachmentService: {} as BuddySessionExtensionServices['attachmentService'], - fileResources: {} as BuddySessionExtensionServices['fileResources'], - mutations: new MutationCoordinator(), + fileResources, + mutations, changeCaptureService: { beginInvocation: async input => ({ invocationId: input.invocationId, runId: input.runId }), finishInvocation: async () => ({ complete: true }), markPartial: async () => {} }, createCapabilities: async () => [custom], directoryGrants: { assertCurrent() {}, grant: async () => { throw new Error('Unexpected directory grant') } }, @@ -273,7 +317,10 @@ async function fixture(options: { tools?: BuddySettings['tools'], fileAccessLimi resources: { skillReadRoots: [], skillReferences: [], approvedSkills: [], context: { agentsFiles: [], diagnostics: [] }, directoryContext: '', revision: 'empty' }, inProcessExtensions: extensions.inProcessExtensions, }) - const shutdown = () => created.shutdown('quit') + const shutdown = async () => { + await created.shutdown('quit') + await disposeResources() + } cleanups.push(shutdown) const requests: { names: string[], tools: string, prompt: string }[] = [] let nextCall: Pick | undefined diff --git a/apps/buddy/service/src/agent/resources/createBuddyResourceLoader.ts b/apps/buddy/service/src/agent/resources/createBuddyResourceLoader.ts index dc1d23976..99ef2d2b2 100644 --- a/apps/buddy/service/src/agent/resources/createBuddyResourceLoader.ts +++ b/apps/buddy/service/src/agent/resources/createBuddyResourceLoader.ts @@ -111,7 +111,7 @@ export async function createBuddyResourceLoader( } export function createPermissionPrompt(settings: BuddyPermissionSettings, platform?: NodeJS.Platform, activeToolNames?: readonly string[]): string { - return [createBuddyExecutionPrompt(settings.executionProfile, platform, activeToolNames), ...settings.approvalPolicy === 'manual' ? [BUDDY_MANUAL_APPROVAL_PROMPT] : []].join('\n\n') + return [createBuddyExecutionPrompt(settings.executionProfile, platform, activeToolNames), ...settings.executionProfile !== 'full_access' && settings.approvalPolicy === 'manual' ? [BUDDY_MANUAL_APPROVAL_PROMPT] : []].join('\n\n') } export class BuddyResourceLoadError extends Error { diff --git a/apps/buddy/service/src/agent/resources/createBuddySystemPrompt.ts b/apps/buddy/service/src/agent/resources/createBuddySystemPrompt.ts index 03abf51a6..3b9a78b13 100644 --- a/apps/buddy/service/src/agent/resources/createBuddySystemPrompt.ts +++ b/apps/buddy/service/src/agent/resources/createBuddySystemPrompt.ts @@ -38,7 +38,7 @@ export function createBuddySystemPrompt(options: CreateBuddySystemPromptOptions) LEXORA_BUDDY_BASE_SYSTEM_PROMPT, ...options.includePermissions === false ? [] : [createBuddyExecutionPrompt(options.executionProfile, options.platform)], ] - if (options.includePermissions !== false && options.approvalPolicy === 'manual') + if (options.includePermissions !== false && options.executionProfile !== 'full_access' && options.approvalPolicy === 'manual') sections.push(BUDDY_MANUAL_APPROVAL_PROMPT) const directoryContext = options.directoryContext?.trim() if (directoryContext) @@ -71,8 +71,8 @@ function createExecutionProfilePrompt( if (executionProfile === 'full_access') { return [ 'The user explicitly enabled full access for this conversation.', - 'Host tools run with the Lexora Buddy service user\'s operating-system permissions. Ordinary operations are auto-approved, while sensitive reads, system mutations, MCP tool calls, automation changes, and unknown capabilities still require user authorization.', - 'Full access does not grant root privileges or bypass operating-system authorization.', + 'Available tools are automatically approved, including file and credential access, host commands, network requests, system mutations, MCP calls, and automation changes. Commands run on the host without shell sandbox isolation; saved directory grants do not constrain full-access tool calls.', + 'Explicit tool-scope limits and denials still apply. Managed network tools enforce explicit domain denials; host commands have no network isolation. Full access does not grant root privileges, bypass execution-target validation or operating-system authorization, or expand the user task.', inspection, ].filter(Boolean).join('\n') } diff --git a/apps/buddy/service/src/permissions/PermissionEngine.ts b/apps/buddy/service/src/permissions/PermissionEngine.ts index 5d5907537..93fc8038b 100644 --- a/apps/buddy/service/src/permissions/PermissionEngine.ts +++ b/apps/buddy/service/src/permissions/PermissionEngine.ts @@ -152,10 +152,12 @@ export class PermissionEngine { if (input.access === 'write' && request.profile === 'read_only') return deny('READ_ONLY_PROFILE', 'profile') const path = await classifyPath({ cwd: request.cwd, grants: request.grants, mode: 'existing', path: input.path, sensitive: this.#sensitive }) - if (path.zone === 'sensitive') + if (path.zone === 'sensitive' && request.profile !== 'full_access') return deny('SENSITIVE_PATH', 'sensitive') if (!path.isDirectory) return deny('INVALID_PATH', 'invalid') + if (request.profile === 'full_access') + return allow() const metadata = await stat(path.canonicalPath, { bigint: true }) const directory = { access: input.access, path: path.canonicalPath, device: String(metadata.dev), inode: String(metadata.ino) } if (!request.forceAsk) { @@ -178,6 +180,11 @@ export class PermissionEngine { } async #classifyShell(request: PermissionRequest): Promise { + if (request.profile === 'full_access') { + if (request.shellDialect || ['bash', 'powershell', 'lexora_shell', 'lexora_host_shell'].includes(request.toolName)) + readStringProperty(request.arguments, 'command') + return undefined + } if (request.shellDialect && request.shellDialect !== 'bash' && request.shellDialect !== 'powershell') { readStringProperty(request.arguments, 'command') return { type: 'ask', forceAsk: true, kind: 'shell', reason: 'unsupported-syntax', summary: 'Review a host command in this Shell dialect' } @@ -228,7 +235,7 @@ export class PermissionEngine { request: PermissionRequest, classifications: readonly PathClassification[], ): PermissionDecision { - if (classifications.some(entry => entry.zone === 'sensitive')) + if (request.profile !== 'full_access' && classifications.some(entry => entry.zone === 'sensitive')) return deny('SENSITIVE_PATH', 'sensitive') return ask({ kind: request.approval?.kind ?? 'system', @@ -335,12 +342,10 @@ export class PermissionEngine { if (!decision) { if (outcome === 'delegate_allowlist') return deny('READ_ONLY_PROFILE', 'profile') - return request.profile === 'full_access' && !request.forceAsk - ? allow() - : ask({ - kind: request.approval?.kind ?? 'system', - summary: request.approval?.summary ?? summaryFor('execute'), - }) + return ask({ + kind: request.approval?.kind ?? 'system', + summary: request.approval?.summary ?? summaryFor('execute'), + }) } if (decision.type === 'allow') return request.forceAsk ? this.#forcedShellAsk(request) : allow() @@ -350,8 +355,6 @@ export class PermissionEngine { return this.#forcedShellAsk(request, decision.reason) if (outcome === 'delegate_allowlist') return deny('READ_ONLY_PROFILE', 'profile') - if (request.profile === 'full_access' && !request.forceAsk) - return allow() return ask({ kind: 'shell', shell: { cwd: request.cwd, reason: request.forceAsk ? 'forced-confirmation' : decision.reason ?? 'unknown-command' }, @@ -478,6 +481,8 @@ function finalizeDecision( request: PermissionRequest, decision: PermissionDecision, ): PermissionDecision { + if (request.profile === 'full_access' && decision.type !== 'deny') + return allow() const requiredDecision = request.requireApproval && decision.type === 'allow' ? ask({ kind: request.approval?.kind ?? approvalKindFor(request.access ?? 'interaction'), diff --git a/apps/buddy/service/src/permissions/ToolAuthorizationService.ts b/apps/buddy/service/src/permissions/ToolAuthorizationService.ts index ae26b66b9..5a930441b 100644 --- a/apps/buddy/service/src/permissions/ToolAuthorizationService.ts +++ b/apps/buddy/service/src/permissions/ToolAuthorizationService.ts @@ -251,6 +251,7 @@ export class ToolAuthorizationService { cwd: options.cwd, grants: options.getGrants(), paths: executionPaths, + profile: options.fileAccessLimit === 'read_only' ? 'read_only' : settings.executionProfile, reviewedPaths: decision.type === 'ask' ? decision.paths : undefined, }) } diff --git a/apps/buddy/service/src/permissions/ToolExecutionPermissions.ts b/apps/buddy/service/src/permissions/ToolExecutionPermissions.ts index 5171ca289..26e6d8ff2 100644 --- a/apps/buddy/service/src/permissions/ToolExecutionPermissions.ts +++ b/apps/buddy/service/src/permissions/ToolExecutionPermissions.ts @@ -1,3 +1,4 @@ +import type { BuddyExecutionProfile } from '../../../shared/permissions/executionProfile' import type { BuddyExtensionRunContext } from '../agent/extensions/BuddyExtensionRunContext' import type { DirectoryGrant } from '../directories/resolveGrantedPath' import type { PermissionPath } from './permissionContract' @@ -28,6 +29,7 @@ export class ToolExecutionPermissions { cwd: string grants: readonly DirectoryGrant[] paths: readonly PermissionPath[] + profile: BuddyExecutionProfile reviewedPaths?: readonly { path: string }[] }): Promise { if (this.#disposed) @@ -39,7 +41,7 @@ export class ToolExecutionPermissions { const target = await classifyPath({ ...input, ...path, sensitive: this.#sensitive }) if (input.reviewedPaths && target.canonicalPath !== input.reviewedPaths[index]?.path) throw new PathClassificationError('INVALID_PATH') - if (target.zone !== 'outside') + if (target.zone !== 'outside' && !(target.zone === 'sensitive' && input.profile === 'full_access')) continue const root = target.isDirectory ? target.canonicalPath : dirname(target.canonicalPath) grants.push({ canonicalRoot: root, root, kind: 'granted', grantId: `run:${run.runId}:${toolCallId}:${grants.length}` }) diff --git a/apps/buddy/service/src/permissions/__tests__/PermissionEngine.spec.ts b/apps/buddy/service/src/permissions/__tests__/PermissionEngine.spec.ts index 585d30755..159c12fc3 100644 --- a/apps/buddy/service/src/permissions/__tests__/PermissionEngine.spec.ts +++ b/apps/buddy/service/src/permissions/__tests__/PermissionEngine.spec.ts @@ -17,7 +17,7 @@ afterEach(async () => { }) describe('permissionEngine', () => { - it('uses trusted selected dialect metadata and never applies Bash safety rules to CMD or Zsh host execution', async () => { + it('allows all selected shell dialects in full access, including unattended runs', async () => { const fixture = await createFixture() const engine = createEngine(fixture.home) for (const shellDialect of ['cmd', 'zsh', 'sh'] as const) { @@ -27,9 +27,9 @@ describe('permissionEngine', () => { profile: 'full_access', shellDialect, }) - await expect(engine.decide(input)).resolves.toMatchObject({ type: 'ask', shell: { reason: 'unsupported-syntax' } }) + await expect(engine.decide(input)).resolves.toEqual({ type: 'allow' }) await expect(engine.decide({ ...input, profile: 'workspace_write', shellBoundary: 'sandbox' })).resolves.toEqual({ type: 'allow' }) - await expect(engine.decide({ ...input, approvalAvailable: false })).resolves.toMatchObject({ type: 'deny', code: 'APPROVAL_UNAVAILABLE_IN_BACKGROUND' }) + await expect(engine.decide({ ...input, approvalAvailable: false })).resolves.toEqual({ type: 'allow' }) } await expect(engine.decide(request(fixture, { toolName: 'lexora_shell', @@ -37,7 +37,7 @@ describe('permissionEngine', () => { profile: 'full_access', shellDialect: 'powershell', approvalPolicy: 'manual', - }))).resolves.toMatchObject({ type: 'ask', shell: { reason: 'manual-policy', boundary: 'host' } }) + }))).resolves.toEqual({ type: 'allow' }) }) it('allows verified Git queries across profiles and backgrounds, while preserving manual approval', async () => { @@ -111,16 +111,18 @@ describe('permissionEngine', () => { arguments: { command }, profile, toolName: 'bash', - })), command).resolves.toMatchObject({ - shell: { reason: 'sensitive-path' }, - type: 'ask', - }) + })), command).resolves.toMatchObject(profile === 'full_access' + ? { type: 'allow' } + : { + shell: { reason: 'sensitive-path' }, + type: 'ask', + }) } await expect(engine.decide(request(fixture, { arguments: { command }, profile: 'full_access', toolName: 'lexora_host_shell', - })), command).resolves.toMatchObject({ shell: { reason: 'sensitive-path' }, type: 'ask' }) + })), command).resolves.toEqual({ type: 'allow' }) } for (const command of ['git diff --cached', 'git diff --cached --check', 'git diff --cached -- .']) { await expect(engine.decide(request(fixture, { @@ -138,7 +140,7 @@ describe('permissionEngine', () => { arguments: { command: 'git diff -- .env' }, profile: 'full_access', toolName, - }))).resolves.toMatchObject({ type: 'ask', shell: { reason: 'sensitive-path' } }) + }))).resolves.toEqual({ type: 'allow' }) } }) @@ -235,7 +237,7 @@ describe('permissionEngine', () => { }) }) - it('asks before reading a sensitive file and denies sensitive writes', async () => { + it('protects sensitive paths in controlled modes and allows them in full access', async () => { const fixture = await createFixture() const engine = createEngine(fixture.home) const ssh = join(fixture.home, '.ssh') @@ -249,13 +251,58 @@ describe('permissionEngine', () => { }))).resolves.toMatchObject({ kind: 'read', type: 'ask' }) await expect(engine.decide(request(fixture, { arguments: { content: 'changed', path: secret }, - profile: 'full_access', toolName: 'write', }))).resolves.toEqual({ code: 'SENSITIVE_PATH', source: 'sensitive', type: 'deny', }) + for (const access of ['read', 'write', 'delete', 'render'] as const) { + await expect(engine.decide(request(fixture, { + access, + arguments: {}, + approvalAvailable: false, + forceAsk: true, + requireApproval: true, + paths: [{ path: secret, mode: 'existing' }], + profile: 'full_access', + toolName: 'sensitive_tool', + }))).resolves.toEqual({ type: 'allow' }) + } + }) + + it('validates full-access targets and commands before automatic approval', async () => { + const fixture = await createFixture() + const engine = createEngine(fixture.home) + for (const arguments_ of [{}, { command: 42 }, { command: ' ' }]) { + await expect(engine.decide(request(fixture, { + arguments: arguments_, + profile: 'full_access', + toolName: 'lexora_host_shell', + }))).resolves.toMatchObject({ type: 'deny', code: 'VALIDATION_FAILED' }) + } + await expect(engine.decide(request(fixture, { + arguments: { path: join(fixture.outside, 'missing') }, + profile: 'full_access', + toolName: 'read', + }))).resolves.toMatchObject({ type: 'deny', code: 'PATH_NOT_FOUND' }) + await expect(engine.decide(request(fixture, { + arguments: {}, + profile: 'full_access', + toolName: 'write', + }))).resolves.toMatchObject({ type: 'deny', code: 'VALIDATION_FAILED' }) + await expect(engine.decide(request(fixture, { + arguments: { path: fixture.workspace, access: 'write', reason: 'Fixture' }, + profile: 'full_access', + toolName: 'lexora_authorize_directory', + shellBoundary: 'sandbox', + }))).resolves.toEqual({ type: 'allow' }) + await expect(engine.decide(request(fixture, { + arguments: { path: fixture.workspace, access: 'invalid' }, + profile: 'full_access', + toolName: 'lexora_authorize_directory', + shellBoundary: 'sandbox', + }))).resolves.toMatchObject({ type: 'deny', code: 'VALIDATION_FAILED' }) }) it('keeps safe shell inspection available in read-only mode', async () => { @@ -278,7 +325,7 @@ describe('permissionEngine', () => { }) }) - it('forces confirmation for unknown capabilities and recognizable system mutations', async () => { + it('automatically approves unknown capabilities and system mutations only in full access', async () => { const fixture = await createFixture() const engine = createEngine(fixture.home) @@ -286,17 +333,14 @@ describe('permissionEngine', () => { arguments: {}, profile: 'full_access', toolName: 'unknown_tool', - }))).resolves.toMatchObject({ - kind: 'system', - type: 'ask', - }) + }))).resolves.toEqual({ type: 'allow' }) for (const toolName of ['bash', 'lexora_host_shell']) { for (const command of ['sudo /usr/bin/systemctl --user restart example.service', 'bash -c \'shutdown now\'']) { await expect(engine.decide(request(fixture, { arguments: { command }, profile: 'full_access', toolName, - }))).resolves.toMatchObject({ type: 'ask', shell: { reason: 'system-mutation' } }) + }))).resolves.toEqual({ type: 'allow' }) } } await expect(engine.decide(request(fixture, { diff --git a/apps/buddy/service/src/permissions/__tests__/ToolAuthorizationService.spec.ts b/apps/buddy/service/src/permissions/__tests__/ToolAuthorizationService.spec.ts index d41b92aa1..ba70335cc 100644 --- a/apps/buddy/service/src/permissions/__tests__/ToolAuthorizationService.spec.ts +++ b/apps/buddy/service/src/permissions/__tests__/ToolAuthorizationService.spec.ts @@ -5,6 +5,7 @@ import { mkdir, mkdtemp, rm, symlink, unlink, writeFile } from 'node:fs/promises import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import * as userHomePaths from '../../../../platform/filesystem/userHomePaths' import { DEFAULT_NETWORK_PERMISSIONS, upsertNetworkPermission } from '../../../../shared/permissions/networkPermissions' import { SessionToolScope } from '../../agent/extensions/discovery/SessionToolScope' import { ApprovalService } from '../../approvals/ApprovalService' @@ -95,7 +96,7 @@ describe('harness tool authorization', () => { it.each([ ['ask', 'policy', 'workspace_write', 'APPROVAL_UNAVAILABLE_IN_BACKGROUND'], - ['ask', 'policy', 'full_access', 'APPROVAL_UNAVAILABLE_IN_BACKGROUND'], + ['ask', 'policy', 'full_access', null], ['allow', 'policy', 'workspace_write', null], ['allow', 'policy', 'read_only', null], ['allow', 'manual', 'workspace_write', 'APPROVAL_UNAVAILABLE_IN_BACKGROUND'], @@ -340,11 +341,20 @@ describe('harness tool authorization', () => { await f.approvals.dispose() }) - it('applies turn authorization to every approvable tool category while preserving hard denials', async () => { + it.each(['turn', 'full_access', 'full_access_background'] as const)('applies %s to every tool category while preserving denials and validation', async (mode) => { const f = fixture() - const initial = f.authorization.authorize(f.call('lexora_web_fetch', { url: 'https://example.com' }), f.run, { access: 'network' }) - await f.approve('approved_for_turn') - await initial + const authorization = mode === 'turn' + ? f.authorization + : new ToolAuthorizationService({ + ...f.options, + executionProfile: 'full_access', + approvalAvailable: mode !== 'full_access_background', + }) + if (mode === 'turn') { + const initial = authorization.authorize(f.call('lexora_web_fetch', { url: 'https://example.com' }), f.run, { access: 'network' }) + await f.approve('approved_for_turn') + await initial + } const browser = { action: 'click' as const, @@ -378,17 +388,17 @@ describe('harness tool authorization', () => { ['future_plugin_tool', { action: 'fixture' }, {}], ] for (const [name, input, declared] of cases) - await expect(f.authorization.authorize(f.call(name, input), f.run, declared), name).resolves.toBeNull() + await expect(authorization.authorize(f.call(name, input), f.run, declared), name).resolves.toBeNull() - expect(f.directories.get(f.run)).toMatchObject([{ path: outside, access: 'write' }]) - expect(f.records.size).toBe(1) + expect(f.directories.get(f.run)).toMatchObject(mode === 'turn' ? [{ path: outside, access: 'write' }] : []) + expect(f.records.size).toBe(mode === 'turn' ? 1 : 0) const readOnly = new ToolAuthorizationService({ ...f.options, executionProfile: 'read_only' }) await expect(readOnly.authorize(f.call('write', { path: join(workspace, 'blocked.txt'), content: 'no' }), f.run)).resolves.toBe('READ_ONLY_PROFILE') const background = new ToolAuthorizationService({ ...f.options, approvalAvailable: false }) await expect(background.authorize(f.call('lexora_host_shell', { command: 'node fixture.mjs' }), f.run)).resolves.toBe('APPROVAL_UNAVAILABLE_IN_BACKGROUND') - const stale = f.authorization.authorize(f.call('lexora_browser_act', {}), f.run, { forceAsk: true, approval: { kind: 'browser', summary: 'Stale', browser }, validateBeforeExecution: async () => ({ blocked: true, reason: 'BROWSER_TARGET_STALE' }) }) + const stale = authorization.authorize(f.call('lexora_browser_act', {}), f.run, { forceAsk: true, approval: { kind: 'browser', summary: 'Stale', browser }, validateBeforeExecution: async () => ({ blocked: true, reason: 'BROWSER_TARGET_STALE' }) }) await expect(stale).resolves.toBe('BROWSER_TARGET_STALE') - expect(f.records.size).toBe(1) + expect(f.records.size).toBe(mode === 'turn' ? 1 : 0) }) it('rejects a path replaced while waiting without granting its new target', async () => { @@ -420,6 +430,31 @@ describe('harness tool authorization', () => { expect(f.repository.listPending()).toEqual([]) }) + it('grants full-access sensitive targets only to the current execution and revokes them on release', async () => { + const homes = vi.spyOn(userHomePaths, 'resolveUserHomePaths').mockReturnValue([root]) + try { + const sensitive = join(root, '.ssh') + await mkdir(sensitive) + const output = join(sensitive, 'fixture.txt') + const f = fixture() + const authorization = new ToolAuthorizationService({ ...f.options, executionProfile: 'full_access', approvalAvailable: false }) + const event = f.call('lexora_image_generate', { outputPath: output }) + await expect(authorization.authorize(event, f.run, { access: 'write', paths: [{ path: output, mode: 'create' }] })).resolves.toBeNull() + const executionGrants = [...f.grants, ...f.executionPermissions.get(f.run, event.toolCallId)] + await expect(resolveGrantedPath(executionGrants, output, 'create')).resolves.toMatchObject({ canonicalPath: output }) + await expect(resolveGrantedPath(f.grants, output, 'create')).rejects.toMatchObject({ code: 'PATH_OUTSIDE_GRANTED_DIRECTORY' }) + expect(f.executionPermissions.get(f.run, 'another-call')).toEqual([]) + expect(f.repository.list()).toEqual([]) + authorization.releaseExecutionPermit(event, f.run) + expect(f.executionPermissions.get(f.run, event.toolCallId)).toEqual([]) + expect(authorization.getExecutionPermit(event, f.run)).toBeNull() + authorization.updatePermissionSettings({ approvalPolicy: 'policy', executionProfile: 'workspace_write' }) + await expect(authorization.authorize(event, f.run, { access: 'write', paths: [{ path: output, mode: 'create' }] })).resolves.toBe('SENSITIVE_PATH') + expect(f.executionPermissions.get(f.run, event.toolCallId)).toEqual([]) + } + finally { homes.mockRestore() } + }) + it('passes temporary grants to the approved execution only without changing saved grants', async () => { const f = fixture() const events: unknown[] = [] diff --git a/apps/buddy/service/src/permissions/__tests__/decisionTable.spec.ts b/apps/buddy/service/src/permissions/__tests__/decisionTable.spec.ts index d9f1d86bb..ab3e99fce 100644 --- a/apps/buddy/service/src/permissions/__tests__/decisionTable.spec.ts +++ b/apps/buddy/service/src/permissions/__tests__/decisionTable.spec.ts @@ -31,14 +31,14 @@ const expected: Record< write: ['allow', 'allow', 'ask_grant', 'deny_sensitive'], }, full_access: { - delete: ['allow', 'allow', 'allow', 'deny_sensitive'], - execute: ['allow', 'allow', 'allow', 'deny_sensitive'], + delete: ['allow', 'allow', 'allow', 'allow'], + execute: ['allow', 'allow', 'allow', 'allow'], interaction: ['allow', 'allow', 'allow', 'allow'], network: ['allow', 'allow', 'allow', 'allow'], - read: ['allow', 'allow', 'allow', 'ask'], - render: ['allow', 'allow', 'allow', 'deny_sensitive'], + read: ['allow', 'allow', 'allow', 'allow'], + render: ['allow', 'allow', 'allow', 'allow'], visual: ['allow', 'allow', 'allow', 'allow'], - write: ['allow', 'allow', 'allow', 'deny_sensitive'], + write: ['allow', 'allow', 'allow', 'allow'], }, } diff --git a/apps/buddy/service/src/permissions/__tests__/shellFileDeletion.spec.ts b/apps/buddy/service/src/permissions/__tests__/shellFileDeletion.spec.ts index 60be7db6c..5966dd747 100644 --- a/apps/buddy/service/src/permissions/__tests__/shellFileDeletion.spec.ts +++ b/apps/buddy/service/src/permissions/__tests__/shellFileDeletion.spec.ts @@ -101,25 +101,23 @@ describe.skipIf(process.platform === 'win32')('shell file deletion permissions', }) }) - it('denies explicitly protected paths and their canonical targets even in full access', async () => { + it('protects sensitive deletion targets only in controlled profiles', async () => { const fixture = await createFixture(true) await writeFile(join(fixture.workspace, '.env'), 'SYNTHETIC=fixture') await symlink(join(fixture.workspace, '.env'), join(fixture.workspace, 'public.vue')) for (const profile of ['read_only', 'workspace_write', 'full_access'] as const) { for (const command of ['rm .env', 'rm public.vue', 'rm obsolete.vue .env']) { - await expect(fixture.engine.decide(request(fixture, command, { profile }))).resolves.toEqual({ - code: 'SENSITIVE_PATH', - source: 'sensitive', - type: 'deny', - }) + await expect(fixture.engine.decide(request(fixture, command, { profile }))).resolves.toEqual(profile === 'full_access' + ? { type: 'allow' } + : { + code: 'SENSITIVE_PATH', + source: 'sensitive', + type: 'deny', + }) } } for (const command of ['rm .env', 'rm public.vue', 'rm obsolete.vue .env']) { - await expect(fixture.engine.decide(request(fixture, command, { profile: 'full_access', toolName: 'lexora_host_shell' }))).resolves.toEqual({ - code: 'SENSITIVE_PATH', - source: 'sensitive', - type: 'deny', - }) + await expect(fixture.engine.decide(request(fixture, command, { profile: 'full_access', toolName: 'lexora_host_shell' }))).resolves.toEqual({ type: 'allow' }) } }) diff --git a/apps/buddy/service/src/permissions/decisionTable.ts b/apps/buddy/service/src/permissions/decisionTable.ts index 7ea12872b..594cdac65 100644 --- a/apps/buddy/service/src/permissions/decisionTable.ts +++ b/apps/buddy/service/src/permissions/decisionTable.ts @@ -30,14 +30,14 @@ const TABLE: Record< Record > = { full_access: { - delete: zones('allow', 'allow', 'allow', 'deny_sensitive'), - execute: zones('allow', 'allow', 'allow', 'deny_sensitive'), + delete: UNIFORM_ALLOW, + execute: UNIFORM_ALLOW, interaction: UNIFORM_ALLOW, network: UNIFORM_ALLOW, - read: READ_ANYWHERE, - render: zones('allow', 'allow', 'allow', 'deny_sensitive'), + read: UNIFORM_ALLOW, + render: UNIFORM_ALLOW, visual: UNIFORM_ALLOW, - write: zones('allow', 'allow', 'allow', 'deny_sensitive'), + write: UNIFORM_ALLOW, }, read_only: { delete: zones('deny_profile', 'deny_profile', 'deny_profile', 'deny_sensitive'), diff --git a/apps/buddy/service/src/sandbox/__tests__/sandboxPermissions.spec.ts b/apps/buddy/service/src/sandbox/__tests__/sandboxPermissions.spec.ts index 94a3c1653..8bcd7ee77 100644 --- a/apps/buddy/service/src/sandbox/__tests__/sandboxPermissions.spec.ts +++ b/apps/buddy/service/src/sandbox/__tests__/sandboxPermissions.spec.ts @@ -66,11 +66,11 @@ describe('isolated shell permissions', () => { const host = { ...request, toolName: name, profile: 'full_access' as const, arguments: { command } } await expect(engine.decide(host)).resolves.toEqual({ type: 'allow' }) await expect(engine.decide({ ...host, approvalAvailable: false })).resolves.toEqual({ type: 'allow' }) - await expect(engine.decide({ ...host, approvalPolicy: 'manual' })).resolves.toMatchObject({ type: 'ask', shell: { reason: 'manual-policy' } }) - await expect(engine.decide({ ...host, forceAsk: true })).resolves.toMatchObject({ type: 'ask', kind: 'shell', shell: { cwd: request.cwd } }) + await expect(engine.decide({ ...host, approvalPolicy: 'manual' })).resolves.toEqual({ type: 'allow' }) + await expect(engine.decide({ ...host, forceAsk: true })).resolves.toEqual({ type: 'allow' }) await expect(engine.decide({ ...host, arguments: { command: 42 } })).resolves.toMatchObject({ type: 'deny', code: 'VALIDATION_FAILED' }) - await expect(engine.decide({ ...host, arguments: { command: mutation } })).resolves.toMatchObject({ type: 'ask', shell: { reason: 'system-mutation' } }) - await expect(engine.decide({ ...host, arguments: { command: mutation }, approvalAvailable: false })).resolves.toMatchObject({ type: 'deny', code: 'APPROVAL_UNAVAILABLE_IN_BACKGROUND' }) + await expect(engine.decide({ ...host, arguments: { command: mutation } })).resolves.toEqual({ type: 'allow' }) + await expect(engine.decide({ ...host, arguments: { command: mutation }, approvalAvailable: false })).resolves.toEqual({ type: 'allow' }) } }) diff --git a/apps/buddy/src/i18n/locales/en-US/chat.ts b/apps/buddy/src/i18n/locales/en-US/chat.ts index 844f5da42..273737d8e 100644 --- a/apps/buddy/src/i18n/locales/en-US/chat.ts +++ b/apps/buddy/src/i18n/locales/en-US/chat.ts @@ -239,7 +239,7 @@ export default { 'desktop.chat.toolDeniedReadOnly': 'The read-only profile blocked this operation', 'desktop.chat.executionProfileFull': 'Full access', 'desktop.chat.executionProfileFullConfirmAcknowledgement': 'I understand the risks and want to continue', - 'desktop.chat.executionProfileFullConfirmDescription': 'Full access lets commands use your local permissions for files, credentials, and network. Some tools still require confirmation, but arbitrary commands cannot be constrained. Enable it only for a trusted task if you accept these risks.', + 'desktop.chat.executionProfileFullConfirmDescription': 'Automatically approve tools with access to local files, credentials, and network. Use only for trusted tasks.', 'desktop.chat.executionProfileFullConfirmTitle': 'Allow full access?', 'desktop.chat.executionProfileOpen': 'View execution permissions', 'desktop.chat.permissionModeFullDescription': 'Run commands with local permissions.', diff --git a/apps/buddy/src/i18n/locales/en-US/settings.ts b/apps/buddy/src/i18n/locales/en-US/settings.ts index e47b2c912..101b0f9fa 100644 --- a/apps/buddy/src/i18n/locales/en-US/settings.ts +++ b/apps/buddy/src/i18n/locales/en-US/settings.ts @@ -45,7 +45,7 @@ export default { 'desktop.settings.runtime.defaultPermissionMode': 'New task permissions', 'desktop.settings.runtime.defaultPermissionModeDescription': 'New tasks start with the selected permissions. Existing tasks, including unsent drafts, keep their current settings.', 'desktop.settings.runtime.fullAccessDefaultTitle': 'Use full access by default for new tasks?', - 'desktop.settings.runtime.fullAccessDefaultDescription': 'New tasks will start with local access to your files, credentials, and network. The sandbox cannot restrict what arbitrary commands do; some sensitive operations will still ask for confirmation.', + 'desktop.settings.runtime.fullAccessDefaultDescription': 'New tasks use full access, automatically approving tools with access to local files, credentials, and network.', 'desktop.settings.runtime.fullAccessDefaultAcknowledgement': 'I understand that full access will apply to every new task by default', 'desktop.settings.runtime.fullAccessDefaultConfirm': 'Use as default', 'desktop.settings.runtime.context': 'Context', diff --git a/apps/buddy/src/i18n/locales/zh-CN/chat.ts b/apps/buddy/src/i18n/locales/zh-CN/chat.ts index 1cb14b9ce..43bc498ae 100644 --- a/apps/buddy/src/i18n/locales/zh-CN/chat.ts +++ b/apps/buddy/src/i18n/locales/zh-CN/chat.ts @@ -237,7 +237,7 @@ export default { 'desktop.chat.toolDeniedReadOnly': '只读权限已阻止此操作', 'desktop.chat.executionProfileFull': '完全访问', 'desktop.chat.executionProfileFullConfirmAcknowledgement': '我已了解风险,并愿意继续', - 'desktop.chat.executionProfileFullConfirmDescription': '完全访问会让命令使用你的本机权限访问文件、凭据和网络。部分工具仍需确认,但无法限制任意命令的实际行为。仅在信任当前任务并接受风险时开启。', + 'desktop.chat.executionProfileFullConfirmDescription': '工具操作免审批,可访问本机文件、凭据和网络。仅对可信任务开启。', 'desktop.chat.executionProfileFullConfirmTitle': '确认允许完全访问?', 'desktop.chat.executionProfileOpen': '查看执行权限', 'desktop.chat.permissionModeFullDescription': '命令使用本机权限执行。', diff --git a/apps/buddy/src/i18n/locales/zh-CN/settings.ts b/apps/buddy/src/i18n/locales/zh-CN/settings.ts index 4c08ec996..4c868d39a 100644 --- a/apps/buddy/src/i18n/locales/zh-CN/settings.ts +++ b/apps/buddy/src/i18n/locales/zh-CN/settings.ts @@ -43,7 +43,7 @@ export default { 'desktop.settings.runtime.defaultPermissionMode': '新任务权限', 'desktop.settings.runtime.defaultPermissionModeDescription': '新建任务时使用所选权限;已有任务(包括未发送的草稿)保持当前设置。', 'desktop.settings.runtime.fullAccessDefaultTitle': '将完全访问设为新任务默认权限?', - 'desktop.settings.runtime.fullAccessDefaultDescription': '之后新建的任务将默认以你的本机权限访问文件、凭据和网络。任意命令的实际行为无法由沙盒限制;部分敏感操作仍会请求确认。', + 'desktop.settings.runtime.fullAccessDefaultDescription': '新任务默认使用完全访问,工具操作免审批,可访问本机文件、凭据和网络。', 'desktop.settings.runtime.fullAccessDefaultAcknowledgement': '我已了解完全访问会默认应用于每个新任务', 'desktop.settings.runtime.fullAccessDefaultConfirm': '设为默认权限', 'desktop.settings.runtime.context': '上下文',