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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 40 additions & 1 deletion .playwright/scripts/__tests__/network-permissions.e2e.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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' }))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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 () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: () => [],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,18 @@ 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'
import { createAssistantMessageEventStream, getCurrentSystemPrompt, getCurrentTools, InMemoryCredentialStore } from '@earendil-works/pi-ai'
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'
Expand Down Expand Up @@ -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] }))
Expand All @@ -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)
Expand Down Expand Up @@ -241,15 +276,24 @@ 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) => {
approved.push(input.toolName)
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') } },
Expand All @@ -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<ToolCall, 'name' | 'arguments'> | undefined
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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')
}
Expand Down
25 changes: 15 additions & 10 deletions apps/buddy/service/src/permissions/PermissionEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -178,6 +180,11 @@ export class PermissionEngine {
}

async #classifyShell(request: PermissionRequest): Promise<ToolDecision | undefined> {
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' }
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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()
Expand All @@ -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' },
Expand Down Expand Up @@ -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'),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -28,6 +29,7 @@ export class ToolExecutionPermissions {
cwd: string
grants: readonly DirectoryGrant[]
paths: readonly PermissionPath[]
profile: BuddyExecutionProfile
reviewedPaths?: readonly { path: string }[]
}): Promise<void> {
if (this.#disposed)
Expand All @@ -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}` })
Expand Down
Loading