Skip to content
Open
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
45 changes: 45 additions & 0 deletions cli/src/cli-args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ export type ParsedArgs = {
initialMode?: AgentMode
/** Load repository `.agents` content without the trust prompt (CI opt-in). */
trustAgents: boolean
/** Disable shell command execution completely. */
noShell: boolean
/** Ask for approval before every shell command. */
askShell: boolean
/** Restrict agent to current project directory. */
projectOnly: boolean
/** Read-only mode - deny write operations. */
readOnly: boolean
}

export function loadPackageVersion(): string {
Expand Down Expand Up @@ -67,6 +75,22 @@ export function parseArgs({
'--trust-agents',
"Load this repository's .agents files and mcp.json without asking (for CI)",
)
.option(
'--no-shell',
'Disable shell command execution (shell is enabled by default)',
)
.option(
'--ask-shell',
'Ask for approval before every shell command',
)
.option(
'--project-only',
'Restrict agent to current project directory',
)
.option(
'--read-only',
'Read-only mode - deny write operations',
)
.addArgument(
new Argument('[command]', 'Command to run').choices(['login']),
)
Expand Down Expand Up @@ -97,6 +121,22 @@ export function parseArgs({
'--trust-agents',
"Load this repository's .agents files and mcp.json without asking (for CI)",
)
.option(
'--no-shell',
'Disable shell command execution (shell is enabled by default)',
)
.option(
'--ask-shell',
'Ask for approval before every shell command',
)
.option(
'--project-only',
'Restrict agent to current project directory',
)
.option(
'--read-only',
'Read-only mode - deny write operations',
)
.option('--lite', 'Start in LITE mode')
.option('--free', 'Start in LITE mode (deprecated alias)')
.option('--max', 'Start in MAX mode')
Expand Down Expand Up @@ -141,5 +181,10 @@ export function parseArgs({
cwd: options.cwd,
initialMode,
trustAgents: Boolean(options.trustAgents),
// commander --shell/--no-shell sets options.shell (default true when undefined)
noShell: options.shell === false,
askShell: Boolean(options.askShell),
projectOnly: Boolean(options.projectOnly),
readOnly: Boolean(options.readOnly),
}
}
56 changes: 54 additions & 2 deletions cli/src/hooks/use-ask-user-bridge.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { AskUserBridge } from '@codebuff/common/utils/ask-user-bridge'
import {
subscribeShellApprovals,
submitShellApproval,
getActiveShellApproval,
} from '@codebuff/common/utils/shell-approval-bridge'
import { useEffect } from 'react'

import { useChatStore } from '../state/chat-store'
Expand Down Expand Up @@ -49,11 +54,26 @@ function filterRedundantOptions(questions: AskUserQuestion[]): AskUserQuestion[]
})
}

/** The single question the shell-approval prompt renders. */
const SHELL_APPROVAL_QUESTIONS: AskUserQuestion[] = [
{
question: 'Allow this shell command to run?',
header: 'Shell access',
options: [
{ label: 'Approve', description: 'Run the command once' },
{ label: 'Deny', description: 'Do not run the command' },
],
multiSelect: false,
},
]

export function useAskUserBridge() {
const setAskUserState = useChatStore((state) => state.setAskUserState)

useEffect(() => {
const unsubscribe = AskUserBridge.subscribe((request) => {
const unsubAsk = AskUserBridge.subscribe((request) => {
// A shell approval owns the form while it is active; don't clobber it.
if (getActiveShellApproval()) return
if (request) {
// Filter out redundant "Custom"/"Other" options since UI provides its own
const filteredQuestions = filterRedundantOptions(request.questions)
Expand All @@ -68,7 +88,26 @@ export function useAskUserBridge() {
setAskUserState(null)
}
})
return unsubscribe

// Surface shell-approval requests through the same form. The correlated
// bridge serializes them, so exactly one is active at a time.
const unsubShell = subscribeShellApprovals((active) => {
if (active) {
setAskUserState({
toolCallId: active.id,
questions: SHELL_APPROVAL_QUESTIONS,
selectedAnswers: [-1],
otherTexts: [''],
})
} else if (!AskUserBridge.getPendingRequest()) {
setAskUserState(null)
}
})

return () => {
unsubAsk()
unsubShell()
}
}, [setAskUserState])

const submitAnswers = (
Expand All @@ -79,11 +118,24 @@ export function useAskUserBridge() {
otherText?: string
}>
) => {
// Route to the correlated shell-approval bridge when an approval is active.
const active = getActiveShellApproval()
if (active) {
const verdict = answers[0]?.selectedOption === 'Approve' ? 'approve' : 'deny'
submitShellApproval(active.id, verdict)
return
}
// Don't clear input value - preserve user's input from before the questionnaire
AskUserBridge.submit({ answers })
}

const skip = () => {
const active = getActiveShellApproval()
if (active) {
// Skipping an approval is a denial, never an implicit approval.
submitShellApproval(active.id, 'deny')
return
}
// Don't clear input value - preserve user's input from before the questionnaire
AskUserBridge.submit({ skipped: true })
}
Expand Down
22 changes: 22 additions & 0 deletions cli/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ import {
startClientEnvironmentProbe,
} from './utils/client-environment'
import { setOscDetectedTheme } from './utils/theme-system'
import { setParsedArgs } from './utils/parsed-args'

import type { FileTreeNode } from '@codebuff/common/util/file'

Expand Down Expand Up @@ -260,8 +261,29 @@ async function main(): Promise<void> {
cwd,
initialMode,
trustAgents,
noShell,
askShell,
projectOnly,
readOnly,
} = parseArgs()

// Store parsed args for use by getCodebuffClient
setParsedArgs({
initialPrompt,
command,
agent,
clearLogs,
continue: continueChat,
continueId,
cwd,
initialMode,
trustAgents,
noShell,
askShell,
projectOnly,
readOnly,
})

const isLoginCommand = command === 'login'
const isPublishCommand = command === 'publish'
const hasAgentOverride = Boolean(agent?.trim())
Expand Down
31 changes: 31 additions & 0 deletions cli/src/utils/codebuff-client.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { API_KEY_ENV_VAR } from '@codebuff/common/old-constants'
import { AskUserBridge } from '@codebuff/common/utils/ask-user-bridge'
import {
requestShellApproval as requestShellApprovalViaBridge,
cancelAllShellApprovals,
} from '@codebuff/common/utils/shell-approval-bridge'
import { CodebuffClient } from '@codebuff/sdk'
import type { ShellApprovalCallback } from '@codebuff/sdk'

import { getAuthTokenDetails } from './auth'
import { getCliEnv, getSystemProcessEnv } from './env'
Expand All @@ -11,6 +16,7 @@ import { createTraceWriter } from './trace-writer'
import { terminalCommandBroker } from './terminal-command-broker'
import { getRgPath } from '../native/ripgrep'
import { getProjectRoot } from '../project-files'
import { getPermissionOptions } from './parsed-args'

import type { ClientToolCall } from '@codebuff/common/tools/list'
import type { ResolvedByokConnection } from '@codebuff/sdk'
Expand Down Expand Up @@ -48,6 +54,8 @@ function removeUndefinedValues<T>(obj: T): T {
export function resetCodebuffClient(): void {
clientInstance = null
byokClientIdentity = null
// Deny any pending shell approvals so no caller hangs across a client reset.
cancelAllShellApprovals()
}

export async function getCodebuffClient(
Expand Down Expand Up @@ -81,6 +89,24 @@ export async function getCodebuffClient(
}
}

// Get permission options from parsed CLI args
const permissionOptions = getPermissionOptions()

// Shell approval callback bound to this run. Uses the correlated
// shell-approval bridge (serialized, timeout/abort-aware, id-matched) so a
// concurrent request can never be lost or misattributed.
const shellApproval: ShellApprovalCallback | undefined =
permissionOptions.askShell
? (request) =>
requestShellApprovalViaBridge({
id: request.id,
command: request.command,
cwd: request.cwd,
processType: request.processType,
...(request.signal ? { signal: request.signal } : {}),
})
: undefined

try {
const agentDefinitions = loadAgentDefinitions()
clientInstance = new CodebuffClient({
Expand All @@ -105,6 +131,11 @@ export async function getCodebuffClient(
logger,
traceWriter: createTraceWriter(),
terminalCommandBroker,
noShell: permissionOptions.noShell,
askShell: permissionOptions.askShell,
projectOnly: permissionOptions.projectOnly,
readOnly: permissionOptions.readOnly,
...(shellApproval ? { shellApproval } : {}),
overrideTools: {
ask_user: async (input: ClientToolCall<'ask_user'>['input']) => {
const askUserResponse = await AskUserBridge.request(
Expand Down
38 changes: 38 additions & 0 deletions cli/src/utils/parsed-args.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/**
* Module to store parsed CLI args for use across the application.
* This is populated once at startup in index.tsx before the app renders.
*/

import type { ParsedArgs } from '../cli-args'

let parsedArgs: ParsedArgs | null = null

export function setParsedArgs(args: ParsedArgs): void {
parsedArgs = args
}

export function getParsedArgs(): ParsedArgs | null {
return parsedArgs
}

export function getPermissionOptions(): {
noShell: boolean
askShell: boolean
projectOnly: boolean
readOnly: boolean
} {
if (!parsedArgs) {
return {
noShell: false,
askShell: false,
projectOnly: false,
readOnly: false,
}
}
return {
noShell: parsedArgs.noShell,
askShell: parsedArgs.askShell,
projectOnly: parsedArgs.projectOnly,
readOnly: parsedArgs.readOnly,
}
}
92 changes: 92 additions & 0 deletions common/src/utils/__tests__/shell-approval-bridge.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { describe, test, expect, beforeEach } from 'bun:test'

import {
requestShellApproval,
submitShellApproval,
cancelAllShellApprovals,
getActiveShellApproval,
__resetShellApprovalBridge,
} from '../shell-approval-bridge'

function req(id: string, extra: Partial<Parameters<typeof requestShellApproval>[0]> = {}) {
return { id, command: 'echo hi', cwd: '/tmp/p', processType: 'SYNC' as const, ...extra }
}

describe('shell-approval-bridge', () => {
beforeEach(() => __resetShellApprovalBridge())

test('approve resolves only the matching id', async () => {
const p = requestShellApproval(req('a'))
expect(getActiveShellApproval()?.id).toBe('a')
expect(submitShellApproval('wrong', 'approve')).toBe(false)
expect(submitShellApproval('a', 'approve')).toBe(true)
expect(await p).toBe('approve')
})

test('deny resolves deny', async () => {
const p = requestShellApproval(req('a'))
submitShellApproval('a', 'deny')
expect(await p).toBe('deny')
})

test('timeout resolves deny without any submit', async () => {
const p = requestShellApproval(req('a'), { timeoutMs: 10 })
expect(await p).toBe('deny')
})

test('abort resolves deny', async () => {
const controller = new AbortController()
const p = requestShellApproval(req('a', { signal: controller.signal }))
controller.abort()
expect(await p).toBe('deny')
})

test('already-aborted signal denies without queueing', async () => {
const controller = new AbortController()
controller.abort()
const p = requestShellApproval(req('a', { signal: controller.signal }))
expect(await p).toBe('deny')
expect(getActiveShellApproval()).toBeNull()
})

test('two concurrent requests: neither is lost; FIFO order', async () => {
const p1 = requestShellApproval(req('a'))
const p2 = requestShellApproval(req('b'))
// First is active, second queued.
expect(getActiveShellApproval()?.id).toBe('a')
submitShellApproval('a', 'approve')
expect(await p1).toBe('approve')
// Second becomes active after the first settles.
expect(getActiveShellApproval()?.id).toBe('b')
submitShellApproval('b', 'deny')
expect(await p2).toBe('deny')
})

test('a submit for a queued id is ignored (cannot approve a non-active request)', async () => {
const p1 = requestShellApproval(req('a'))
const p2 = requestShellApproval(req('b'))
expect(submitShellApproval('b', 'approve')).toBe(false)
submitShellApproval('a', 'approve')
await p1
// b is now active and still unresolved until explicitly decided.
expect(submitShellApproval('b', 'approve')).toBe(true)
expect(await p2).toBe('approve')
})

test('cancelAll denies every pending request (UI shutdown)', async () => {
const p1 = requestShellApproval(req('a'))
const p2 = requestShellApproval(req('b'))
cancelAllShellApprovals()
expect(await p1).toBe('deny')
expect(await p2).toBe('deny')
expect(getActiveShellApproval()).toBeNull()
})

test('stale approval after settle does not re-resolve', async () => {
const p = requestShellApproval(req('a'))
submitShellApproval('a', 'deny')
expect(await p).toBe('deny')
// A late UI action for the same id is ignored.
expect(submitShellApproval('a', 'approve')).toBe(false)
})
})
Loading
Loading