fix:disable-agent-sandbox-at-runtime
This commit is contained in:
1 parent
3be45999f7
commit
c7d6c558e6
24 files changed
+147
-500
No files matched your search
@@ -174,11 +174,14 @@ if (!process.versions.electron) {
|
||||
await rejected({ action: 'open', url: 'javascript:alert(1)' }, /HTTP/)
|
||||
await rejected({ action: 'open', url: 'http://user:password@localhost/' }, /HTTP/)
|
||||
check('file, script and credential URLs are refused', BrowserWindow.getAllWindows().length === 0)
|
||||
await rejected({ action: 'open', url: fixture.base + '/page' }, /批准/, ctx('denied', { permissionMode: 'default-sandbox', approve: async () => false }))
|
||||
check('default permission refusal creates no browser', BrowserWindow.getAllWindows().length === 0)
|
||||
const blocked = await host.executeTurnTool('browser', { action: 'open', url: fixture.base + '/page' },
|
||||
{ ...ctx('mode-denied'), agentMode: 'plan', emit() {} })
|
||||
check('plan mode cannot bypass the shared tool gate', !blocked.ok && BrowserWindow.getAllWindows().length === 0)
|
||||
const hostBrowserContext = ctx('host-browser', { permissionMode: 'default-sandbox', approve: async () => false })
|
||||
const hostBrowser = await ok({ action: 'open', url: fixture.base + '/page' }, hostBrowserContext)
|
||||
check('host browser opens without sandbox approval', BrowserWindow.getAllWindows().length === 1)
|
||||
await ok({ action: 'close', tabId: hostBrowser.data.tabId }, hostBrowserContext)
|
||||
check('host browser close releases the unapproved tab', BrowserWindow.getAllWindows().length === 0)
|
||||
const first = await ok({ action: 'open', url: fixture.base + '/page' })
|
||||
const id = first.data.tabId
|
||||
check('real hidden Chromium returns the rendered page and interactive refs', first.data.text.includes('BROWSER_FIXTURE') && first.data.elements.some(element => element.name === 'Apply'))
|
||||
@@ -220,8 +223,9 @@ if (!process.versions.electron) {
|
||||
await rejected({ action: 'click', tabId: id, ref: oldRef }, /ref/)
|
||||
check('refs from previous documents cannot trigger new-page controls', true)
|
||||
let approvals = 0
|
||||
await ok({ action: 'open', url: fixture.base + '/page' }, ctx('approved', { permissionMode: 'default-sandbox', approve: async () => { approvals++; return true } }))
|
||||
check('default permission supports explicit approval', approvals === 1)
|
||||
const noApproval = await ok({ action: 'open', url: fixture.base + '/page' }, ctx('approved', { permissionMode: 'default-sandbox', approve: async () => { approvals++; return true } }))
|
||||
check('legacy sandbox approval callback is never invoked', approvals === 0)
|
||||
await ok({ action: 'close', tabId: noApproval.data.tabId }, ctx('approved'))
|
||||
const cancellation = new AbortController(), started = Date.now()
|
||||
setTimeout(() => cancellation.abort(), 150)
|
||||
await rejected({ action: 'open', url: fixture.base + '/hang' }, /取消/, ctx('cancelled-open', { signal: cancellation.signal }))
|
||||
|
||||
@@ -116,10 +116,10 @@ assert.deepEqual(oversizedAttachmentNames([
|
||||
{ name: 'limit.bin', size: MAX_ATTACHMENT_BYTES },
|
||||
{ name: 'over.bin', size: MAX_ATTACHMENT_BYTES + 1 }
|
||||
]), ['over.bin'])
|
||||
assert.deepEqual(buildComposerSendPayload(' prompt ', [], ['C:/work/file.ts'], 'full-access'), {
|
||||
text: 'prompt', attachments: [], resources: ['C:/work/file.ts'], quotes: [], mode: 'full-access'
|
||||
assert.deepEqual(buildComposerSendPayload(' prompt ', [], ['C:/work/file.ts']), {
|
||||
text: 'prompt', attachments: [], resources: ['C:/work/file.ts'], quotes: []
|
||||
})
|
||||
const quotedPayload = buildComposerSendPayload('', [], [], 'full-access', [
|
||||
const quotedPayload = buildComposerSendPayload('', [], [], [
|
||||
{ id: 'm1-text-0:table:table-0', kind: 'table', sourceId: 'm1-text-0:table:table-0', messageId: 'm1', markdown: '| a |\n| - |\n| 1 |', preview: '| a |' }
|
||||
])
|
||||
assert.deepEqual(quotedPayload.quotes.map((quote) => quote.sourceId), ['m1-text-0:table:table-0'])
|
||||
@@ -164,16 +164,15 @@ assert.match(emptyEditorMarkup, /role="alert"/)
|
||||
assert.match(emptyEditorMarkup, /cr-user-message-editor__send" disabled/)
|
||||
|
||||
const composerMarkup = renderToStaticMarkup(
|
||||
<ComposerControls value="draft" onChange={() => undefined} onSend={() => undefined} onModeChange={() => undefined} modelSlot={<span>Model slot</span>} workspaceFooter={<span>Workspace slot</span>} sending />
|
||||
<ComposerControls value="draft" onChange={() => undefined} onSend={() => undefined} modelSlot={<span>Model slot</span>} workspaceFooter={<span>Workspace slot</span>} sending />
|
||||
)
|
||||
assert.match(composerMarkup, /语音转写服务不可用/, 'without an ASR callback, voice input reports unavailable')
|
||||
assert.match(composerMarkup, /默认权限/)
|
||||
assert.doesNotMatch(composerMarkup, /默认权限|允许完全访问/)
|
||||
assert.match(composerMarkup, /添加/)
|
||||
assert.match(composerMarkup, /Model slot/)
|
||||
assert.match(composerMarkup, /Workspace slot/)
|
||||
assert.equal(composerMarkup.indexOf('Model slot') < composerMarkup.indexOf('语音转写服务不可用'), true)
|
||||
assert.ok(composerMarkup.indexOf('cr-input-toolbar__left') < composerMarkup.indexOf('默认权限'))
|
||||
assert.ok(composerMarkup.indexOf('默认权限') < composerMarkup.indexOf('cr-input-toolbar__right'), 'permission control belongs in the left toolbar')
|
||||
assert.ok(composerMarkup.indexOf('cr-input-toolbar__left') < composerMarkup.indexOf('cr-input-toolbar__right'))
|
||||
assert.ok(composerMarkup.indexOf('cr-input-toolbar__right') < composerMarkup.indexOf('wb-composer__workspace-footer'), 'workspace footer remains outside the input toolbar')
|
||||
const activeRunMarkup = renderToStaticMarkup(<ComposerControls value="next draft" onChange={() => undefined} onSend={() => undefined} onStop={() => undefined} />)
|
||||
assert.match(activeRunMarkup, /aria-label="停止"/)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { mkdtemp, mkdir, readFile, writeFile, rm, symlink } from 'node:fs/promises'
|
||||
import { mkdtemp, mkdir, readFile, writeFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { createInteractionBroker, respondToInteraction } from '../src/main/interaction-broker'
|
||||
@@ -13,13 +13,11 @@ import type { ChatEvent, ChatMessage } from '../src/shared/types'
|
||||
import type { ChatInteraction } from '../src/shared/conversation'
|
||||
import type { OmpKernel } from '../src/main/omp'
|
||||
import { assistantWireHistory } from '../src/main/agent/history'
|
||||
import { configureSandbox } from '../src/main/sandbox/configuration'
|
||||
|
||||
async function main() {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'ycode-conversation-runtime-'))
|
||||
const workspace = join(directory, 'workspace')
|
||||
await mkdir(workspace)
|
||||
configureSandbox({ stateRoot: join(directory, 'sandbox-state'), resourceDirectory: join(directory, 'missing-sandbox-backend') })
|
||||
try {
|
||||
const events: ChatEvent[] = []
|
||||
const emitted = new EventEmitter()
|
||||
@@ -31,43 +29,30 @@ async function main() {
|
||||
const listener = (event: ChatEvent) => { if (event.type === 'interaction' && event.interaction.status === 'pending') { emitted.off('event', listener); resolve(event.interaction) } }
|
||||
emitted.on('event', listener)
|
||||
})
|
||||
const pendingApproval = nextInteraction()
|
||||
const write = executeConversationTool('write_file', { path: 'nested/new/file.txt', content: 'first' }, context)
|
||||
const approval = await pendingApproval
|
||||
assert.equal(approval.kind, 'approval')
|
||||
await assert.rejects(readFile(join(workspace, 'nested/new/file.txt')), { code: 'ENOENT' })
|
||||
assert.equal(respondToInteraction('another-run', approval.id, { kind: 'approval', optionId: 'allow-once' }, emit), false)
|
||||
assert.throws(() => respondToInteraction('run', approval.id, { kind: 'approval', optionId: 'invented' }, emit), /无效/)
|
||||
assert.equal(respondToInteraction('run', approval.id, { kind: 'approval', optionId: 'allow-once' }, emit), true)
|
||||
assert.equal(respondToInteraction('run', approval.id, { kind: 'approval', optionId: 'allow-once' }, emit), false)
|
||||
const written = await write
|
||||
assert.equal(written.ok, true)
|
||||
assert.equal(await readFile(join(workspace, 'nested/new/file.txt'), 'utf8'), 'first')
|
||||
assert.equal((written.data as { oldContent: unknown }).oldContent, null)
|
||||
assert.equal(events.some(event => event.type === 'interaction'), false, 'host file writes no longer create sandbox approvals')
|
||||
|
||||
const pendingEdit = nextInteraction()
|
||||
const edit = executeConversationTool('edit_file', { path: 'nested/new/file.txt', oldText: 'first', newText: 'second' }, context)
|
||||
const editApproval = await pendingEdit
|
||||
await writeFile(join(workspace, 'nested/new/file.txt'), 'external change')
|
||||
respondToInteraction('run', editApproval.id, { kind: 'approval', optionId: 'allow-once' }, emit)
|
||||
assert.equal((await edit).ok, false)
|
||||
assert.equal(await readFile(join(workspace, 'nested/new/file.txt'), 'utf8'), 'external change', 'stale approval cannot overwrite a newer file')
|
||||
assert.equal((await edit).ok, true)
|
||||
assert.equal(await readFile(join(workspace, 'nested/new/file.txt'), 'utf8'), 'second')
|
||||
const outside = join(directory, 'outside')
|
||||
await mkdir(outside)
|
||||
await writeFile(join(outside, 'secret.txt'), 'private')
|
||||
await symlink(outside, join(workspace, 'linked'), process.platform === 'win32' ? 'junction' : 'dir')
|
||||
assert.equal((await executeConversationTool('read_file', { path: 'linked/secret.txt' }, context)).ok, false)
|
||||
assert.equal((await executeConversationTool('write_file', { path: '../outside/deep/new.txt', content: 'x' }, context)).ok, false)
|
||||
const unavailableCommand = await executeConversationTool('execute_command', { command: 'echo SHOULD_NOT_RUN' }, context)
|
||||
assert.equal(unavailableCommand.ok, false, 'a missing native backend must fail closed')
|
||||
assert.match(unavailableCommand.content, /ENOENT|原生组件/)
|
||||
const hostCommand = await executeConversationTool('execute_command', { command: 'echo HOST_COMMAND_OK' }, context)
|
||||
assert.equal(hostCommand.ok, true)
|
||||
assert.match(hostCommand.content, /HOST_COMMAND_OK/)
|
||||
const fullController = new AbortController()
|
||||
const childScript = join(directory, 'child.cjs')
|
||||
const parentScript = join(directory, 'parent.cjs')
|
||||
const pidFile = join(directory, 'child-pid.txt')
|
||||
await writeFile(childScript, `require('node:fs').writeFileSync(${JSON.stringify(pidFile)},String(process.pid));setInterval(()=>{},1000)`)
|
||||
await writeFile(parentScript, `require('node:child_process').spawn(process.execPath,[${JSON.stringify(childScript)}],{windowsHide:true,stdio:'ignore'});process.stdout.write('child started');setInterval(()=>{},1000)`)
|
||||
const fullCommand = executeConversationTool('execute_command', { command: `"${process.execPath}" "${parentScript}"` }, { ...context, permissionMode: 'full-access', signal: fullController.signal })
|
||||
const fullCommand = executeConversationTool('execute_command', { command: `"${process.execPath}" "${parentScript}"` }, { ...context, signal: fullController.signal })
|
||||
let childPid = 0
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
childPid = Number(await readFile(pidFile, 'utf8').catch(() => '0'))
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { executeTool, toolSpecs } from '../src/main/agent/tools'
|
||||
|
||||
const workspace = await mkdtemp(join(tmpdir(), 'ycode-host-tools-'))
|
||||
let approvals = 0
|
||||
const context = {
|
||||
workspaceRoot: workspace,
|
||||
permissionMode: 'default-sandbox' as const,
|
||||
approve: async () => {
|
||||
approvals += 1
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const specs = toolSpecs()
|
||||
const names = specs.map(spec => spec.function.name)
|
||||
assert.equal(names.includes('sandbox_workspace'), false)
|
||||
assert.equal(names.includes('sandbox_git'), false)
|
||||
const command = specs.find(spec => spec.function.name === 'execute_command')
|
||||
const properties = command?.function.parameters.properties as Record<string, unknown> | undefined
|
||||
assert.deepEqual(Object.keys(properties ?? {}), ['command'])
|
||||
|
||||
const written = await executeTool('write_file', { path: 'nested/file.txt', content: 'first' }, context)
|
||||
assert.equal(written.ok, true)
|
||||
assert.equal(await readFile(join(workspace, 'nested/file.txt'), 'utf8'), 'first')
|
||||
assert.equal(approvals, 0)
|
||||
|
||||
const edited = await executeTool('edit_file', { path: 'nested/file.txt', oldText: 'first', newText: 'second' }, context)
|
||||
assert.equal(edited.ok, true)
|
||||
assert.equal(await readFile(join(workspace, 'nested/file.txt'), 'utf8'), 'second')
|
||||
|
||||
const commandResult = await executeTool('execute_command', {
|
||||
command: `${JSON.stringify(process.execPath)} -e "require('node:fs').writeFileSync('host-command.txt','host')"`
|
||||
}, context)
|
||||
assert.equal(commandResult.ok, true)
|
||||
assert.equal(await readFile(join(workspace, 'host-command.txt'), 'utf8'), 'host')
|
||||
assert.equal(JSON.stringify(commandResult.data).includes('windows-appcontainer'), false)
|
||||
assert.equal(JSON.stringify(commandResult.data).includes('sandbox'), false)
|
||||
assert.equal(approvals, 0)
|
||||
|
||||
const outside = await executeTool('read_file', { path: '../outside.txt' }, context)
|
||||
assert.equal(outside.ok, false)
|
||||
assert.match(outside.content, /工作区/)
|
||||
console.log('sandbox-disabled-regression: host tools are active and legacy sandbox modes are inert')
|
||||
} finally {
|
||||
await rm(workspace, { recursive: true, force: true })
|
||||
}
|
||||
+1
-1
@@ -182,7 +182,7 @@ async function main(): Promise<void> {
|
||||
console.log('\n[2] 工具安全边界:工作区逃逸必须被拒绝')
|
||||
const escape = await executeTool('read_file', { path: '../../../../etc/passwd' }, { workspaceRoot })
|
||||
check('越界读取被拒绝', escape.ok === false, `实际 ok=${escape.ok}`)
|
||||
check('拒绝理由可读', escape.content.includes('越界'))
|
||||
check('拒绝理由可读', escape.content.includes('工作区内'))
|
||||
|
||||
const unknown = await executeTool('not_a_tool', {}, { workspaceRoot })
|
||||
check('未知工具被拒绝', unknown.ok === false)
|
||||
|
||||
@@ -105,7 +105,7 @@ if (!process.versions.electron) {
|
||||
assert.ok(!names.includes('write_file'), 'read-only child never receives write tools')
|
||||
return sse(res, model, { name: 'read_file', args: { path: 'fixture.txt' } })
|
||||
}
|
||||
assert.ok(write ? model.includes('approval') ? serialized.includes('拒绝') || serialized.includes('未批准') : serialized.includes('已写入')
|
||||
assert.ok(write ? serialized.includes('已写入')
|
||||
: serialized.includes('PROJECT_FIXTURE_CONTENT'), 'actual child tool result returns to model')
|
||||
return sse(res, model, undefined, marker)
|
||||
}
|
||||
@@ -281,25 +281,12 @@ if (!process.versions.electron) {
|
||||
await until(() => read('!document.querySelector(".conversation-member-detail")', liveWindow), 'close member detail')
|
||||
check('UI: closing member detail leaves the completed parent conversation intact', await read('document.body.textContent.includes("CONTROL_PARENT_COMPLETE")', liveWindow))
|
||||
liveWindow.destroy()
|
||||
phase = 'default-permission-approval'
|
||||
phase = 'legacy-sandbox-mode-inert'
|
||||
const approval = await start('agent-approval', 'default-sandbox')
|
||||
const pending = await until(async () => {
|
||||
const message = (await invoke('session', 'messages', approval.session.id)).at(-1)
|
||||
return message.interactions?.find(item => item.kind === 'approval' && item.status === 'pending')
|
||||
}, 'child write approval')
|
||||
check('APP: child write approval appears under the parent run with the child name', pending.runId === approval.run.runId && pending.title.includes('文件分析 A'))
|
||||
await invoke('chat', 'respond', approval.run.runId, pending.id, { kind: 'approval', optionId: 'reject-once' })
|
||||
const approvedMessage = await finish(approval.session)
|
||||
check('APP: refusing child approval prevents the write and returns control', approvedMessage.status === 'done'
|
||||
&& !await fs.stat(path.join(workspaceRoot, 'approval.txt')).then(() => true, () => false))
|
||||
phase = 'cancel-pending-approval'
|
||||
const approvalCancel = await start('agent-approval-cancel', 'default-sandbox')
|
||||
await until(async () => (await invoke('session', 'messages', approvalCancel.session.id)).at(-1).interactions?.some(item => item.status === 'pending'), 'approval to cancel')
|
||||
await invoke('agents', 'update', { teamManagement: false })
|
||||
const approvalCancelledMessage = await finish(approvalCancel.session)
|
||||
check('APP: disabling during child approval cancels the prompt without hanging the parent', approvalCancelledMessage.status === 'done'
|
||||
&& approvalCancelledMessage.interactions.every(item => item.status !== 'pending')
|
||||
&& approvalCancelledMessage.parts.some(part => part.type === 'subagent' && part.agent.status === 'cancelled'))
|
||||
check('APP: legacy default-sandbox value is inert; child write lands on the host without an approval prompt',
|
||||
approvedMessage.status === 'done' && (approvedMessage.interactions ?? []).every(item => item.status !== 'pending')
|
||||
&& await fs.readFile(path.join(workspaceRoot, 'approval.txt'), 'utf8') === 'WRITTEN_BY_CHILD_A')
|
||||
phase = 'native-followup'
|
||||
await invoke('agents', 'update', { teamManagement: true })
|
||||
const followup = await start('agent-followup')
|
||||
|
||||
@@ -219,10 +219,6 @@ if (!process.versions.electron) {
|
||||
await fill('.automation-prompt-input', 'FORM_DAILY_TASK', 'HTMLTextAreaElement')
|
||||
await fs.writeFile(path.join(directory, 'automation-editor-dark.png'), (await win.webContents.capturePage()).toPNG())
|
||||
await read('document.querySelector(".automation-editor-footer .automation-button--primary").click()')
|
||||
await waitUi('!!document.querySelector(".automation-full-access-confirm")')
|
||||
assert.equal(await read('document.querySelector("[data-testid=automation-full-access-confirm-ok]").disabled'), true)
|
||||
await read('document.querySelector(".automation-full-access-confirm__checkbox input").click()')
|
||||
await read('document.querySelector("[data-testid=automation-full-access-confirm-ok]").click()')
|
||||
await waitUi(`!document.querySelector('.automation-editor-modal') && !!document.querySelector('button[aria-label="编辑 表单验收"]')`)
|
||||
const formCreated = (await invoke('automation', 'list')).find(item => item.name === '表单验收')
|
||||
assert.equal(formCreated.schedule.frequency, 'biweekly')
|
||||
@@ -290,14 +286,6 @@ if (!process.versions.electron) {
|
||||
win.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' })
|
||||
win.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'Escape' })
|
||||
await waitUi('!document.querySelector(".cr-model-selector__menu")')
|
||||
await read('document.querySelector(".project-detail-view__project-composer .cr-permission-setting").click()')
|
||||
await waitUi('!!document.querySelector(".cr-permission-panel input[role=switch]")')
|
||||
await read('document.querySelector(".cr-permission-panel input[role=switch]").click()')
|
||||
await waitUi('!!document.querySelector(".permission-confirm-dialog")')
|
||||
assert.equal(await read('document.querySelector(".permission-confirm-dialog .wb-composer__danger-button").disabled'), true)
|
||||
await read('document.querySelector(".permission-confirm-acknowledgement").click()')
|
||||
await read('document.querySelector(".permission-confirm-dialog .wb-composer__danger-button").click()')
|
||||
await waitUi('document.querySelector(".project-detail-view__project-composer .cr-permission-setting")?.textContent.includes("允许完全访问")')
|
||||
const projectSessionIdsBefore = new Set((await invoke('session', 'list')).map(item => item.id))
|
||||
await fill('textarea[aria-label=项目任务输入]', 'PROJECT_FULL_ACCESS_COMMAND', 'HTMLTextAreaElement')
|
||||
await read('document.querySelector(".project-detail-view__project-composer .cr-send-button").click()')
|
||||
|
||||
Reference in new issue
Block a user