feat: run agent tools with host permissions

This commit is contained in:
mesalogo
2026-08-15 18:06:03 +08:00
parent 79e2511f6f
commit 34bbfab2af
38 changed files with 224 additions and 1395 deletions
+1 -17
View File
@@ -32,7 +32,6 @@ export type ContinueRuntimeOptions = {
binaryPath: string
bundledBinaryPath?: string
configPath: string
runtimeSandboxMode?: RuntimeSettings['runtimeSandboxMode']
defaultWorkspace: string
hostCacheRoot: string
skillInstructions?: string
@@ -186,16 +185,6 @@ export class ContinueAgentRuntime implements AgentRuntime {
}
async getStatus(): Promise<AgentRuntimeStatus> {
if (this.options.runtimeSandboxMode === 'strict') {
return {
id: 'continue',
label: 'Continue CLI',
available: false,
supportsToolExecution: this.supportsToolExecution,
detail:
'Continue 宿主暂不支持严格 OS 沙箱,请改用自动模式或嵌入式 OpenCode'
}
}
if (
!hasContinueModelConfiguration(
this.options.configPath,
@@ -236,7 +225,7 @@ export class ContinueAgentRuntime implements AgentRuntime {
available: detection.available,
supportsToolExecution: this.supportsToolExecution,
detail: detection.available
? `${detection.detail};Ask 可搜索已启用知识库,Execute 工具调用自动放行并保留审计;未启用 OS 进程沙箱`
? `${detection.detail};Ask 可搜索已启用知识库,Execute 工具调用自动放行并保留审计;工具以当前用户权限运行`
: detection.detail
}
}
@@ -246,11 +235,6 @@ export class ContinueAgentRuntime implements AgentRuntime {
signal: AbortSignal
): AsyncGenerator<RuntimeEvent, void, void> {
signal.throwIfAborted()
if (this.options.runtimeSandboxMode === 'strict') {
throw new Error(
'Continue 宿主暂不支持严格 OS 沙箱,请改用自动模式或嵌入式 OpenCode'
)
}
if (
request.images?.length &&
this.options.modelProfile &&
+2 -5
View File
@@ -55,7 +55,6 @@ function settings(
continueBinaryPath: '',
continueConfigPath: '',
continueMode: 'chat',
runtimeSandboxMode: 'off',
subagentSmartRoutingEnabled: false,
knowledgeEmbeddingEnabled: false,
knowledgeEmbeddingBaseUrl:
@@ -93,8 +92,7 @@ describe('createAgentRuntime model compatibility', () => {
modelProtocol: defaultProfile.protocol,
modelAuthentication: defaultProfile.authentication,
apiKey: defaultProfile.apiKey,
modelProfiles: [defaultProfile],
runtimeSandboxMode: 'auto'
modelProfiles: [defaultProfile]
}),
{ deepseekHarnessLauncher: vi.fn() }
)
@@ -120,8 +118,7 @@ describe('createAgentRuntime model compatibility', () => {
provider: 'deepseek-harness',
modelProfiles: [profile],
defaultModelProfileId: profile.id,
deepseekHarnessModelProfile: profile,
runtimeSandboxMode: 'auto'
deepseekHarnessModelProfile: profile
}),
{ deepseekHarnessLauncher: vi.fn() }
)
-11
View File
@@ -22,7 +22,6 @@ import type {
} from '../capabilities/capability-service'
import type { BundledRuntimePaths } from './bundled-runtimes'
import type { ContinueHostLauncher } from './continue-host-adapter'
import { resolveRuntimeSandbox } from './runtime-sandbox'
import type { BrowserToolService } from '../browser/browser-model-tools'
import type { ModelToolProviderLike } from './model-tool-provider'
import type { KnowledgeMcpGateway } from './knowledge-mcp-gateway'
@@ -105,9 +104,6 @@ export function createAgentRuntime(
const embedded = !baseUrl
const workspace = settings?.workspacePath || defaultWorkspace
const provider = settings?.provider ?? defaultRuntimeSettings.provider
const sandboxMode =
settings?.runtimeSandboxMode ??
defaultRuntimeSettings.runtimeSandboxMode
if (provider === 'deepseek-harness') {
const profile = settings?.deepseekHarnessModelProfile
@@ -122,9 +118,6 @@ export function createAgentRuntime(
if (!capabilities.deepseekHarnessLauncher) {
throw new Error('DeepSeek Harness 受控 Host 启动器不可用')
}
if (sandboxMode === 'off') {
throw new Error('DeepSeek Harness Execute 需要启用 Runtime 沙箱')
}
return new DeepSeekHarnessRuntime({
defaultWorkspace: workspace,
baseUrl: profile.baseUrl,
@@ -133,8 +126,6 @@ export function createAgentRuntime(
credentialRefs: {
GOODBUDDY_HARNESS_MODEL_API_KEY: profile.apiKey
},
requiredSandboxEnforcement:
sandboxMode === 'strict' ? 'full' : 'partial',
skillPackages: capabilities.skillPackages,
toolProvider: new ModelToolProvider(
workspace,
@@ -168,7 +159,6 @@ export function createAgentRuntime(
settings?.continueConfigPath ??
process.env.GOODBUDDY_CONTINUE_CONFIG?.trim() ??
'',
runtimeSandboxMode: sandboxMode,
modelProfile: settings?.continueModelProfile,
skillInstructions: capabilities.skillInstructions,
skillPackages: capabilities.skillPackages,
@@ -208,7 +198,6 @@ export function createAgentRuntime(
modelProfile: settings?.opencodeModelProfile,
skillInstructions: capabilities.skillInstructions,
skillPackages: capabilities.skillPackages,
sandbox: resolveRuntimeSandbox(sandboxMode),
defaultWorkspace: workspace,
knowledgeGateway: capabilities.knowledgeGateway
})
@@ -32,14 +32,6 @@ const MCP_CALL_ID = 'e2e-mcp-call'
const ASK_MCP_CALL_ID = 'e2e-ask-mcp-call'
const MICRO_DELTA_COUNT = 30_000
function expectedSandbox() {
return process.platform === 'win32'
? { provider: 'windows-acl', enforcement: 'partial' as const }
: process.platform === 'darwin'
? { provider: 'seatbelt', enforcement: 'full' as const }
: { provider: 'local-linux', enforcement: 'full' as const }
}
function deferred<T>() {
let resolvePromise!: (value: T) => void
const promise = new Promise<T>((resolve) => {
@@ -321,7 +313,6 @@ function createInProcessLaunch(
provider: 'goodbuddy',
model: options.model,
harnessVersion: '0.1.0-rc.6',
sandbox: expectedSandbox(),
credentialRefs: options.credentialRefs,
skillPackages: options.skillPackages,
stream: createBoundedNdJsonStream(
@@ -135,16 +135,12 @@ function setup(
supports: {
cancellation: true,
sessionRelease: true,
oneShotApproval: true,
reasoningEvents: true,
toolEvents: true,
usageEvents: true,
credentialResolution: true
},
sandbox: {
provider: 'test',
enforcement: 'full'
}
execution: { mode: 'host' }
}
}
if (method === 'goodbuddy/session/prepare') {
@@ -404,7 +400,6 @@ describe('DeepSeekHarnessRuntime', () => {
baseUrl: 'https://api.deepseek.com',
model: 'deepseek-test',
credentialRefs: [],
requiredSandboxEnforcement: undefined,
skillPackages: []
})
expect(harness.requests).toContainEqual({
+4 -22
View File
@@ -136,7 +136,6 @@ export type DeepSeekHarnessLaunchOptions = {
baseUrl: string
model: string
credentialRefs: readonly string[]
requiredSandboxEnforcement?: 'full' | 'partial'
skillPackages: readonly RuntimeSkillPackage[]
}
@@ -154,7 +153,6 @@ export type DeepSeekHarnessRuntimeOptions = {
maxEventCharacters?: number
maxRequestOutputCharacters?: number
credentialRefs?: Readonly<Record<string, string>>
requiredSandboxEnforcement?: 'full' | 'partial'
skillPackages?: RuntimeSkillPackage[]
toolProvider?: ModelToolProviderLike
loadAcpSdk?: () => Promise<DeepSeekHarnessAcpSdk>
@@ -184,15 +182,13 @@ type GoodBuddyHarnessCapabilities = {
supports: {
cancellation: boolean
sessionRelease: boolean
oneShotApproval: boolean
reasoningEvents: boolean
toolEvents: boolean
usageEvents: boolean
credentialResolution: boolean
}
sandbox: {
provider: string
enforcement: 'full' | 'partial'
execution: {
mode: 'host'
}
}
@@ -603,25 +599,13 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
typeof capabilities.harnessVersion !== 'string' ||
!supports?.cancellation ||
!supports.sessionRelease ||
!supports.oneShotApproval ||
!supports.credentialResolution ||
!capabilities.sandbox ||
!['full', 'partial'].includes(
capabilities.sandbox.enforcement
)
capabilities.execution?.mode !== 'host'
) {
throw new Error(
'DeepSeek Harness 内部控制面必需能力握手失败'
)
}
if (
this.options.requiredSandboxEnforcement === 'full' &&
capabilities.sandbox.enforcement !== 'full'
) {
throw new Error(
'DeepSeek Harness 沙箱仅部分强制,严格模式拒绝启动'
)
}
return capabilities
}
@@ -710,8 +694,6 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
credentialRefs: Object.keys(
this.options.credentialRefs ?? {}
),
requiredSandboxEnforcement:
this.options.requiredSandboxEnforcement,
skillPackages: this.options.skillPackages ?? []
}),
this.initializationTimeoutMs,
@@ -963,7 +945,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
label: 'DeepSeek Harness',
available: true,
supportsToolExecution: true,
detail: `DeepSeek Harness ${this.state?.capabilities.harnessVersion ?? ''} · ${this.state?.capabilities.sandbox.provider ?? 'sandbox'} ${this.state?.capabilities.sandbox.enforcement ?? 'unknown'}`
detail: `DeepSeek Harness ${this.state?.capabilities.harnessVersion ?? ''} · 当前用户权限`
}
} catch (error) {
return {
@@ -17,13 +17,6 @@ export const DEEPSEEK_HARNESS_HOST_VERSION = '0.1.0-rc.6'
export const DEEPSEEK_HARNESS_CREDENTIAL_REF =
'GOODBUDDY_HARNESS_MODEL_API_KEY'
const sandboxSchema = z
.object({
provider: z.string().min(1).max(64),
enforcement: z.enum(['full', 'partial'])
})
.strict()
const skillPackageSchema = z
.object({
id: z
@@ -47,7 +40,6 @@ export const controlledHarnessHostConfigSchema = z
provider: z.literal('goodbuddy'),
model: z.string().min(1).max(128),
harnessVersion: z.literal(DEEPSEEK_HARNESS_HOST_VERSION),
sandbox: sandboxSchema,
credentialRefs: z
.tuple([z.literal(DEEPSEEK_HARNESS_CREDENTIAL_REF)])
.readonly(),
@@ -146,14 +138,6 @@ export type DeepSeekHarnessUtilityLauncherOptions = {
startupTimeoutMs?: number
}
function expectedSandbox(): ControlledHarnessBootstrapConfig['sandbox'] {
return process.platform === 'win32'
? { provider: 'windows-acl', enforcement: 'partial' }
: process.platform === 'darwin'
? { provider: 'seatbelt', enforcement: 'full' }
: { provider: 'local-linux', enforcement: 'full' }
}
function hasControlCharacter(value: string): boolean {
for (const character of value) {
const codePoint = character.codePointAt(0)
@@ -224,15 +208,6 @@ export function createDeepSeekHarnessUtilityLauncher(
'DeepSeek Harness Host、工作区或隔离目录类型无效'
)
}
const sandbox = expectedSandbox()
if (
options.requiredSandboxEnforcement === 'full' &&
sandbox.enforcement !== 'full'
) {
throw new Error(
'DeepSeek Harness 当前平台只能提供部分沙箱强制'
)
}
if (!isDeepSeekHarnessCompatibleBaseUrl(options.baseUrl)) {
throw new Error(
'DeepSeek Harness 模型地址必须使用 HTTPS 或本机回环 HTTP,且不得包含凭据、查询参数或片段'
@@ -334,7 +309,6 @@ export function createDeepSeekHarnessUtilityLauncher(
provider: 'goodbuddy',
model: options.model,
harnessVersion: DEEPSEEK_HARNESS_HOST_VERSION,
sandbox,
credentialRefs: [DEEPSEEK_HARNESS_CREDENTIAL_REF],
skillPackages: canonicalSkillPackages,
maxFrameBytes: 1024 * 1024
@@ -7,42 +7,16 @@ import {
GOODBUDDY_PREPARE,
GoodBuddyCredentialProvider,
GoodBuddyHarnessControlPlane,
GoodBuddySandboxRetryLedger,
createBoundedAcpStream
} from './goodbuddy-harness-control-plane'
function execution(
callId: string,
name: string,
args: Record<string, unknown>
) {
return {
callId,
rootCallId: callId,
name,
arguments: args,
signal: new AbortController().signal,
token: Symbol('execution')
} as never
}
const sandboxDenied = {
isError: false,
value: {
sandbox: {
denied: true
}
},
content: []
} as const
function controlPlane() {
return new GoodBuddyHarnessControlPlane({} as Context, {
provider: 'goodbuddy',
model: 'deepseek-test',
workspace: resolve('workspace'),
harnessVersion: '0.1.0-rc.6',
sandbox: { provider: 'test', enforcement: 'full' },
execution: { mode: 'host' },
credentialRefs: ['GOODBUDDY_API_KEY'],
skills: []
})
@@ -80,7 +54,7 @@ function stubAgentContext() {
model: 'deepseek-test',
workspace: resolve('workspace'),
harnessVersion: '0.1.0-rc.6',
sandbox: { provider: 'test', enforcement: 'full' },
execution: { mode: 'host' },
credentialRefs: ['GOODBUDDY_API_KEY'],
skills: [],
maxEventCharacters: 10_000,
@@ -97,6 +71,7 @@ function stubAgentContext() {
inflight: {
requestId: string
messageId: string
mode: 'ask' | 'execute'
resolve: (reason: string) => void
reject: (error: unknown) => void
emittedCharacters: number
@@ -113,6 +88,7 @@ function stubAgentContext() {
inflight: {
requestId: 'request-output',
messageId: 'message-output',
mode: 'ask',
resolve: vi.fn(),
reject: vi.fn(),
emittedCharacters: 0,
@@ -150,10 +126,9 @@ describe('GoodBuddy Harness internal control plane', () => {
supports: {
cancellation: true,
sessionRelease: true,
oneShotApproval: true,
credentialResolution: true
},
sandbox: { enforcement: 'full' }
execution: { mode: 'host' }
})
})
@@ -261,73 +236,26 @@ describe('GoodBuddy Harness internal control plane', () => {
).toBeGreaterThan(180)
})
it('requires a matching real denial and consumes it once', () => {
const ledger = new GoodBuddySandboxRetryLedger()
const deniedArguments = {
command: 'type C:\\outside\\file.txt',
description: 'Read an outside file'
}
const retry = {
...deniedArguments,
sandbox_permissions: 'danger-full-access',
justification: 'The requested file is outside the workspace.'
}
expect(ledger.consumeRetry('pwsh', retry)).toBe(false)
ledger.record(
execution('denial-1', 'pwsh', deniedArguments),
sandboxDenied as never
)
expect(
ledger.consumeRetry('pwsh', {
...retry,
command: 'type C:\\different\\file.txt'
})
).toBe(false)
expect(ledger.consumeRetry('bash', retry)).toBe(false)
expect(ledger.consumeRetry('pwsh', retry)).toBe(true)
expect(ledger.consumeRetry('pwsh', retry)).toBe(false)
})
it('rejects non-denials, narrow escalation, and reordered ambiguity', () => {
const ledger = new GoodBuddySandboxRetryLedger()
const deniedArguments = {
description: 'Read an outside file',
command: 'cat /outside/file'
}
ledger.record(execution('success', 'bash', deniedArguments), {
it('blocks mutating and shell tools in Ask while allowing reads', async () => {
const { listeners, handle } = stubAgentContext()
const executeTool = listeners.get('tools/execute')!
const next = vi.fn(async () => ({
isError: false,
value: {},
content: []
} as never)
expect(
ledger.consumeRetry('bash', {
command: 'cat /outside/file',
description: 'Read an outside file',
sandbox_permissions: 'danger-full-access',
justification: 'The requested file is outside the workspace.'
})
).toBe(false)
}))
const request = (name: string) => ({
name,
agent: handle.agent
})
ledger.record(
execution('denial-2', 'bash', deniedArguments),
sandboxDenied as never
)
expect(
ledger.consumeRetry('bash', {
command: 'cat /outside/file',
description: 'Read an outside file',
sandbox_permissions: 'workspace-write',
justification: 'Retry in workspace-write.'
})
).toBe(false)
expect(
ledger.consumeRetry('bash', {
command: 'cat /outside/file',
description: 'Read an outside file',
sandbox_permissions: 'danger-full-access',
justification: 'The requested file is outside the workspace.'
})
).toBe(true)
for (const name of ['write', 'edit', 'bash', 'pwsh']) {
await expect(
Promise.resolve(executeTool(request(name), next))
).rejects.toThrow('Ask 模式不允许')
}
await expect(
Promise.resolve(executeTool(request('read'), next))
).resolves.toMatchObject({ isError: false })
})
})
+35 -267
View File
@@ -1,4 +1,4 @@
import { createHash, randomUUID } from 'node:crypto'
import { randomUUID } from 'node:crypto'
import { isAbsolute } from 'node:path'
import {
AgentSideConnection,
@@ -25,13 +25,7 @@ import {
SessionId,
type SessionEvent
} from '@deepseek-ai/dsh-session'
import { setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
import { setApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
import type {
ToolDefinition,
ToolExecution,
ToolExecutionResult
} from '@deepseek-ai/dsh-tools'
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
import * as ToolSkill from '@deepseek-ai/dsh-tool-skill'
export const GOODBUDDY_CONTROL_PROTOCOL_VERSION = 1
@@ -50,8 +44,13 @@ export const GOODBUDDY_HARNESS_MAX_STEP_TOKENS = 16 * 1024
const DELTA_BATCH_CHARACTERS = 4 * 1024
const DELTA_BATCH_INTERVAL_MS = 100
const MAX_SUMMARY_CHARACTERS = 4_000
const MAX_FINGERPRINT_BYTES = 4 * 1024 * 1024
const MAX_MCP_PROXY_RESULT_BYTES = 256 * 1024
const ASK_BLOCKED_TOOL_NAMES = new Set([
'bash',
'pwsh',
'write',
'edit'
])
const GOODBUDDY_EXECUTION_GUIDANCE = [
'GoodBuddy controlled execution rules:',
'- In Execute mode, act through the available tools instead of writing a long implementation plan.',
@@ -70,15 +69,13 @@ export type GoodBuddyHarnessCapabilities = {
supports: {
cancellation: true
sessionRelease: true
oneShotApproval: true
reasoningEvents: boolean
toolEvents: boolean
usageEvents: boolean
credentialResolution: true
}
sandbox: {
provider: string
enforcement: 'full' | 'partial'
execution: {
mode: 'host'
}
}
@@ -87,7 +84,7 @@ export type GoodBuddyHarnessControlConfig = {
model: string
workspace: string
harnessVersion: string
sandbox: GoodBuddyHarnessCapabilities['sandbox']
execution: GoodBuddyHarnessCapabilities['execution']
credentialRefs: readonly string[]
skills: readonly {
name: string
@@ -109,10 +106,10 @@ type OwnedSession = {
handle: AgentHandle
preparation?: Preparation
proxyToolDisposers: Map<string, () => void>
sandboxRetries: GoodBuddySandboxRetryLedger
inflight?: {
requestId: string
messageId: string
mode: GoodBuddyWorkMode
turn?: number
endReason?: string
turnError?: unknown
@@ -209,156 +206,10 @@ function parseProxyToolCatalog(
})
}
type DeniedToolCall = {
toolName: string
operationFingerprint: string
}
type CredentialResolver = (
ref: string
) => Promise<string | undefined>
function argumentsFingerprint(value: unknown): string | undefined {
try {
const serialized = JSON.stringify(value, (_key, nested) => {
if (
nested &&
typeof nested === 'object' &&
!Array.isArray(nested)
) {
return Object.fromEntries(
Object.entries(nested as Record<string, unknown>).sort(
([left], [right]) => left.localeCompare(right)
)
)
}
return nested
})
if (
serialized === undefined ||
Buffer.byteLength(serialized, 'utf8') >
MAX_FINGERPRINT_BYTES
) {
return undefined
}
return createHash('sha256').update(serialized).digest('hex')
} catch {
return undefined
}
}
function isSandboxDenial(
result: Readonly<ToolExecutionResult>
): boolean {
const sandboxValue =
!result.isError &&
result.value &&
typeof result.value === 'object' &&
!Array.isArray(result.value)
? (result.value as Record<string, unknown>).sandbox
: undefined
return (
(result.isError &&
result.error.info?.code === 'FS_SANDBOX_DENIED') ||
result.content.some(
(content) =>
content.type === 'text' &&
content.text.includes('[sandbox: file access denied under ')
) ||
(!!sandboxValue &&
typeof sandboxValue === 'object' &&
!Array.isArray(sandboxValue) &&
(sandboxValue as Record<string, unknown>).denied === true)
)
}
function requestedEscalation(
value: unknown
): {
mode: 'workspace-write' | 'danger-full-access'
operationFingerprint: string
} | undefined {
if (
!value ||
typeof value !== 'object' ||
Array.isArray(value)
) {
return undefined
}
const argumentsRecord = value as Record<string, unknown>
const mode = argumentsRecord.sandbox_permissions
if (
(mode !== 'workspace-write' &&
mode !== 'danger-full-access') ||
typeof argumentsRecord.justification !== 'string' ||
!argumentsRecord.justification.trim()
) {
return undefined
}
const operationArguments = { ...argumentsRecord }
delete operationArguments.sandbox_permissions
delete operationArguments.justification
const operationFingerprint = argumentsFingerprint(
operationArguments
)
return operationFingerprint
? {
mode,
operationFingerprint
}
: undefined
}
export class GoodBuddySandboxRetryLedger {
private readonly deniedToolCalls = new Map<
string,
DeniedToolCall
>()
clear(): void {
this.deniedToolCalls.clear()
}
record(
execution: Readonly<ToolExecution>,
result: Readonly<ToolExecutionResult>
): void {
if (!isSandboxDenial(result)) {
return
}
const operationFingerprint = argumentsFingerprint(
execution.arguments
)
if (!operationFingerprint) {
return
}
this.deniedToolCalls.set(execution.callId, {
toolName: execution.name,
operationFingerprint
})
}
consumeRetry(toolName: string, argumentsValue: unknown): boolean {
const escalation = requestedEscalation(argumentsValue)
if (escalation?.mode !== 'danger-full-access') {
return false
}
const denied = [...this.deniedToolCalls.entries()]
.reverse()
.find(
([, candidate]) =>
candidate.toolName === toolName &&
candidate.operationFingerprint ===
escalation.operationFingerprint
)
if (!denied) {
return false
}
this.deniedToolCalls.delete(denied[0])
return true
}
}
/**
* Memory-only credential provider. It deliberately has no writable operation
* and can resolve only references registered by the trusted host.
@@ -615,13 +466,12 @@ export class GoodBuddyHarnessControlPlane {
supports: {
cancellation: true,
sessionRelease: true,
oneShotApproval: true,
reasoningEvents: true,
toolEvents: true,
usageEvents: true,
credentialResolution: true
},
sandbox: this.config.sandbox
execution: this.config.execution
}
}
@@ -682,9 +532,9 @@ export class GoodBuddyHarnessControlPlane {
this.sendEvent(sessionId, event)
)
inflight.eventTail = queued.catch((error: unknown) => {
inflight.eventError ??= error
record.handle.agent.cancel({ kind: 'user' })
})
inflight.eventError ??= error
record.handle.agent.cancel({ kind: 'user' })
})
}
private flushPendingDelta(sessionId: string): void {
@@ -797,6 +647,23 @@ export class GoodBuddyHarnessControlPlane {
return
}
this.observing = true
this.ctx.on('tools/execute', async (exec, next) => {
const sessionId = exec.agent?.session.id
const record = sessionId
? this.sessions.get(sessionId)
: undefined
if (
record &&
record.handle.agent === exec.agent &&
record.inflight?.mode === 'ask' &&
ASK_BLOCKED_TOOL_NAMES.has(exec.name)
) {
throw new Error(
`Ask 模式不允许执行修改或命令工具:${exec.name}`
)
}
return next()
})
this.ctx.on(
'session/event',
(session, event: SessionEvent) => {
@@ -878,92 +745,6 @@ export class GoodBuddyHarnessControlPlane {
}
}
)
this.ctx.on(
'tools/result',
(
exec: Readonly<ToolExecution>,
result: Readonly<ToolExecutionResult>
) => {
const sessionId = exec.agent?.session.id
if (!sessionId) {
return
}
const record = this.sessions.get(sessionId)
if (
record?.handle.agent !== exec.agent ||
!record.inflight
) {
return
}
record.sandboxRetries.record(exec, result)
}
)
this.ctx.on('approval/request', async (request, next) => {
const record = this.sessions.get(request.agent.session.id)
if (
!record ||
record.handle.agent !== request.agent ||
!record.inflight ||
!this.connection
) {
return next()
}
const matchingRetry = request.callId
? record.handle.agent.session.events
.filter(
(
event
): event is Extract<
SessionEvent,
{ type: 'tool/call' }
> =>
event.type === 'tool/call' &&
event.data.callId === request.callId
)
.at(-1)
: undefined
let retryArguments: unknown
if (matchingRetry) {
try {
retryArguments = JSON.parse(matchingRetry.data.arguments)
} catch {
return 'rejected'
}
}
if (
!matchingRetry ||
!record.sandboxRetries.consumeRetry(
request.toolName,
retryArguments
)
) {
return 'rejected'
}
const response = await this.connection.requestPermission({
sessionId: request.agent.session.id,
toolCall: {
toolCallId:
request.callId ?? `approval-${randomUUID()}`,
title: request.reason ?? request.toolName
},
options: [
{
optionId: 'allow-once',
name: 'Allow once',
kind: 'allow_once'
},
{
optionId: 'reject-once',
name: 'Reject',
kind: 'reject_once'
}
]
})
return response.outcome.outcome === 'selected' &&
response.outcome.optionId === 'allow-once'
? 'allowed-once'
: 'rejected'
})
}
private queueUsage(sessionId: string, usage: TokenUsage): void {
@@ -1150,12 +931,9 @@ export class GoodBuddyHarnessControlPlane {
await Promise.all([skillTool, skillRegistrations])
}
})
setSandboxMode(handle.agent.session, 'read-only')
setApprovalPolicy(handle.agent.session, 'never')
this.sessions.set(sessionId, {
handle,
proxyToolDisposers: new Map(),
sandboxRetries: new GoodBuddySandboxRetryLedger()
proxyToolDisposers: new Map()
})
return {
sessionId,
@@ -1187,16 +965,6 @@ export class GoodBuddyHarnessControlPlane {
'a single-use goodbuddy/session/prepare is required'
)
}
setSandboxMode(
record.handle.agent.session,
preparation.mode === 'ask'
? 'read-only'
: 'workspace-write'
)
setApprovalPolicy(
record.handle.agent.session,
preparation.mode === 'ask' ? 'never' : 'ask'
)
if (preparation.mode === 'execute') {
await this.refreshProxyTools(params.sessionId, record)
} else {
@@ -1205,7 +973,6 @@ export class GoodBuddyHarnessControlPlane {
}
record.proxyToolDisposers.clear()
}
record.sandboxRetries.clear()
const text = promptText(params.prompt)
if (!text.trim()) {
throw RequestError.invalidParams(
@@ -1222,6 +989,7 @@ export class GoodBuddyHarnessControlPlane {
record.inflight = {
requestId: preparation.requestId,
messageId: message.id,
mode: preparation.mode,
resolve,
reject,
emittedCharacters: 0,
+2 -1
View File
@@ -331,7 +331,8 @@ describe('OpenCodeRuntime embedded launcher', () => {
await expect(runtime.getStatus()).resolves.toMatchObject({
available: true,
detail: '由 GoodBuddy 管理本机 OpenCode 进程'
detail:
'由 GoodBuddy 以当前用户权限管理本机 OpenCode 进程'
})
expect(detectBinary).toHaveBeenCalledWith(
'opencode',
+3 -31
View File
@@ -38,10 +38,6 @@ import {
buildRuntimeEnvironment,
runtimePrivacyEnvironment
} from './process-environment'
import {
buildBubblewrapLaunch,
type RuntimeSandboxResolution
} from './runtime-sandbox'
import {
boundedToolDetail,
safeToolErrorDetail
@@ -388,7 +384,6 @@ export type OpenCodeRuntimeOptions = {
modelProfile?: ResolvedModelProfile
skillInstructions?: string
skillPackages?: RuntimeSkillPackage[]
sandbox?: RuntimeSandboxResolution
knowledgeGateway?: KnowledgeMcpGateway
}
@@ -741,13 +736,6 @@ export class OpenCodeRuntime implements AgentRuntime {
) {
throw new Error('OpenCode 独立模型连接尚未配置 API Key')
}
const sandbox = this.options.sandbox
if (
sandbox?.status.mode === 'strict' &&
!sandbox.status.available
) {
throw new Error(sandbox.status.detail)
}
const skillIds = this.getNativeSkillIds()
const registration = await this.createSkillRegistration()
try {
@@ -813,25 +801,11 @@ export class OpenCodeRuntime implements AgentRuntime {
'--hostname=127.0.0.1',
`--port=${port}`
]
const launch =
sandbox?.status.available && sandbox.binaryPath
? buildBubblewrapLaunch({
binaryPath: sandbox.binaryPath,
command: binaryPath,
args: serverArgs,
workspace: this.options.defaultWorkspace,
readOnlyPaths: this.options.configPath.trim()
? [resolve(this.options.configPath)]
: [],
writablePaths: [registration.root],
platform: this.dependencies.platform
})
: { command: binaryPath, args: serverArgs }
return await new Promise<OpenCodeServer>((resolveServer, reject) => {
const child = this.dependencies.spawn(
launch.command,
launch.args,
binaryPath,
serverArgs,
{
cwd: this.options.defaultWorkspace,
env,
@@ -1019,9 +993,7 @@ export class OpenCodeRuntime implements AgentRuntime {
available: true,
supportsToolExecution: this.supportsToolExecution,
detail: this.server
? this.options.sandbox
? `由 GoodBuddy 管理本机 OpenCode 进程;${this.options.sandbox.status.detail}`
: '由 GoodBuddy 管理本机 OpenCode 进程'
? '由 GoodBuddy 以当前用户权限管理本机 OpenCode 进程'
: `已连接 ${this.options.baseUrl}`
}
} catch (error) {
-108
View File
@@ -1,108 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import {
buildBubblewrapLaunch,
resolveRuntimeSandbox
} from './runtime-sandbox'
describe('resolveRuntimeSandbox', () => {
it('reports bubblewrap enforcement only after a successful Linux probe', () => {
const probe = vi.fn(() => true)
expect(resolveRuntimeSandbox('auto', 'linux', probe)).toEqual({
binaryPath: 'bwrap',
status: {
mode: 'auto',
enforcement: 'bubblewrap',
available: true,
detail:
'Linux bubblewrap 文件系统沙箱已启用,网络仍按模型连接配置开放'
}
})
expect(probe).toHaveBeenCalledWith('bwrap')
})
it('fails closed when strict mode is unavailable', () => {
expect(
resolveRuntimeSandbox('strict', 'linux', () => false)
).toMatchObject({
status: {
mode: 'strict',
enforcement: 'unavailable',
available: false
}
})
expect(
resolveRuntimeSandbox('strict', 'win32', () => true).status.detail
).toContain('仅支持')
})
it('does not probe when sandboxing is disabled', () => {
const probe = vi.fn(() => true)
expect(resolveRuntimeSandbox('off', 'linux', probe).status).toMatchObject({
enforcement: 'disabled',
available: false
})
expect(probe).not.toHaveBeenCalled()
})
})
describe('buildBubblewrapLaunch', () => {
it('mounts only system roots, explicit runtime paths, and writable workspace paths', () => {
const launch = buildBubblewrapLaunch({
binaryPath: 'bwrap',
command: '/opt/goodbuddy/node',
args: ['/data/runtime/index.js', 'serve'],
workspace: '/work/project',
readOnlyPaths: ['/data/runtime/index.js'],
writablePaths: ['/data/runtime/cache'],
platform: 'linux'
})
expect(launch.command).toBe('bwrap')
expect(launch.args).toContain('--unshare-all')
expect(launch.args).toContain('--share-net')
expect(launch.args).toContain('/opt/goodbuddy/node')
expect(launch.args).toContain('/data/runtime/index.js')
expect(launch.args).toContain('/data/runtime/cache')
expect(launch.args).toContain('/work/project')
expect(launch.args.slice(-3)).toEqual([
'/opt/goodbuddy/node',
'/data/runtime/index.js',
'serve'
])
})
it('rejects relative mounts and non-Linux use', () => {
expect(() =>
buildBubblewrapLaunch({
binaryPath: 'bwrap',
command: 'node',
args: [],
workspace: 'relative',
platform: 'linux'
})
).toThrow('绝对路径')
expect(() =>
buildBubblewrapLaunch({
binaryPath: 'bwrap',
command: 'node',
args: [],
workspace: 'C:\\work',
platform: 'win32'
})
).toThrow('仅支持 Linux')
})
it('rejects writable system mounts', () => {
expect(() =>
buildBubblewrapLaunch({
binaryPath: 'bwrap',
command: '/usr/bin/opencode',
args: [],
workspace: '/etc',
platform: 'linux'
})
).toThrow('系统路径')
})
})
-240
View File
@@ -1,240 +0,0 @@
import { spawnSync } from 'node:child_process'
import { posix } from 'node:path'
export type RuntimeSandboxMode = 'off' | 'auto' | 'strict'
export type RuntimeSandboxStatus = {
mode: RuntimeSandboxMode
enforcement: 'disabled' | 'unavailable' | 'bubblewrap'
available: boolean
detail: string
}
export type RuntimeSandboxResolution = {
status: RuntimeSandboxStatus
binaryPath?: string
}
export type BubblewrapLaunch = {
command: string
args: string[]
}
type SandboxProbe = (command: string) => boolean
type BubblewrapLaunchInput = {
binaryPath: string
command: string
args: readonly string[]
workspace: string
readOnlyPaths?: readonly string[]
writablePaths?: readonly string[]
platform?: NodeJS.Platform
}
const SYSTEM_PATHS = ['/usr', '/bin', '/sbin', '/lib', '/lib64', '/etc']
function defaultProbe(command: string): boolean {
const result = spawnSync(
command,
[
'--die-with-parent',
'--unshare-all',
'--share-net',
'--ro-bind',
'/',
'/',
'--proc',
'/proc',
'--dev',
'/dev',
'--',
'/bin/true'
],
{
shell: false,
stdio: 'ignore',
timeout: 1_000,
windowsHide: true
}
)
return !result.error && result.status === 0
}
export function resolveRuntimeSandbox(
mode: RuntimeSandboxMode,
platform: NodeJS.Platform = process.platform,
probe: SandboxProbe = defaultProbe
): RuntimeSandboxResolution {
if (mode === 'off') {
return {
status: {
mode,
enforcement: 'disabled',
available: false,
detail: 'Runtime OS 沙箱已关闭'
}
}
}
if (platform !== 'linux') {
return {
status: {
mode,
enforcement: 'unavailable',
available: false,
detail:
mode === 'strict'
? '严格 OS 沙箱当前仅支持安装 bubblewrap 的 Linux'
: '当前平台尚无可用的 Runtime OS 沙箱'
}
}
}
if (!probe('bwrap')) {
return {
status: {
mode,
enforcement: 'unavailable',
available: false,
detail:
mode === 'strict'
? '严格 OS 沙箱需要安装 bubblewrapbwrap'
: '未检测到 bubblewrapRuntime 将保持审批隔离但不启用 OS 沙箱'
}
}
}
return {
binaryPath: 'bwrap',
status: {
mode,
enforcement: 'bubblewrap',
available: true,
detail: 'Linux bubblewrap 文件系统沙箱已启用,网络仍按模型连接配置开放'
}
}
}
function normalizePath(value: string): string {
if (
!posix.isAbsolute(value) ||
[...value].some((character) => {
const code = character.charCodeAt(0)
return code <= 31 || code === 127
})
) {
throw new Error('OS 沙箱路径必须是无控制字符的绝对路径')
}
return posix.normalize(value)
}
function isWithinPath(candidate: string, parent: string): boolean {
return candidate === parent || candidate.startsWith(`${parent}/`)
}
function uniquePaths(paths: readonly string[]): string[] {
return [
...new Set(paths.map(normalizePath))
].sort((left, right) => left.length - right.length)
}
function addDestinationDirectories(
args: string[],
paths: readonly string[]
): void {
const directories = new Set<string>()
for (const target of paths) {
let current = posix.parse(target).dir
while (current && current !== posix.parse(current).root) {
if (SYSTEM_PATHS.some((systemPath) => isWithinPath(current, systemPath))) {
break
}
directories.add(current)
current = posix.parse(current).dir
}
}
for (const directory of [...directories].sort(
(left, right) => left.length - right.length
)) {
args.push('--dir', directory)
}
}
export function buildBubblewrapLaunch(
input: BubblewrapLaunchInput
): BubblewrapLaunch {
if ((input.platform ?? process.platform) !== 'linux') {
throw new Error('bubblewrap 仅支持 Linux 路径')
}
const workspace = normalizePath(input.workspace)
const command =
posix.isAbsolute(input.command)
? normalizePath(input.command)
: input.command
const writablePaths = uniquePaths([
workspace,
...(input.writablePaths ?? [])
])
if (
writablePaths.some(
(path) =>
path === '/' ||
SYSTEM_PATHS.some((systemPath) =>
isWithinPath(path, systemPath)
)
)
) {
throw new Error('OS 沙箱不允许将系统路径挂载为可写')
}
const readOnlyPaths = uniquePaths([
...(input.readOnlyPaths ?? []),
...(posix.isAbsolute(command) &&
!SYSTEM_PATHS.some((systemPath) => isWithinPath(command, systemPath))
? [command]
: [])
]).filter(
(path) =>
!writablePaths.some((writablePath) => isWithinPath(path, writablePath))
)
const mountedPaths = [...readOnlyPaths, ...writablePaths]
const args = [
'--die-with-parent',
'--new-session',
'--unshare-all',
'--share-net',
'--proc',
'/proc',
'--dev',
'/dev',
'--tmpfs',
'/tmp',
'--dir',
'/run',
'--dir',
'/home',
'--dir',
'/tmp/goodbuddy-home',
'--setenv',
'HOME',
'/tmp/goodbuddy-home',
'--setenv',
'XDG_CONFIG_HOME',
'/tmp/goodbuddy-home/.config',
'--setenv',
'XDG_CACHE_HOME',
'/tmp/goodbuddy-home/.cache'
]
for (const systemPath of SYSTEM_PATHS) {
args.push('--ro-bind-try', systemPath, systemPath)
}
addDestinationDirectories(args, mountedPaths)
for (const path of readOnlyPaths) {
args.push('--ro-bind', path, path)
}
for (const path of writablePaths) {
args.push('--bind', path, path)
}
args.push('--chdir', workspace, '--', command, ...input.args)
return {
command: input.binaryPath,
args
}
}
-1
View File
@@ -83,7 +83,6 @@ function settings(
continueBinaryPath: '',
continueConfigPath: '',
continueMode: 'chat',
runtimeSandboxMode: 'auto',
subagentSmartRoutingEnabled: false,
knowledgeEmbeddingEnabled: false,
knowledgeEmbeddingBaseUrl: