Skip to content
Draft
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
53 changes: 53 additions & 0 deletions cli/src/__tests__/cli-args.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,39 @@ describe('CLI Argument Parsing', () => {
})

describe('Freebuff CLI Argument Parsing', () => {
test('parses the execution permission flags', () => {
const result = parseArgs({
argv: [
'node',
'freebuff',
'--no-shell',
'--ask-shell',
'--project-only',
'--read-only',
],
isFreebuff: true,
version: '1.0.0',
})

expect(result.noShell).toBe(true)
expect(result.askShell).toBe(true)
expect(result.projectOnly).toBe(true)
expect(result.readOnly).toBe(true)
})

test('defaults execution permission flags to false', () => {
const result = parseArgs({
argv: ['node', 'freebuff'],
isFreebuff: true,
version: '1.0.0',
})

expect(result.noShell).toBe(false)
expect(result.askShell).toBe(false)
expect(result.projectOnly).toBe(false)
expect(result.readOnly).toBe(false)
})

test('accepts login as a command, not an unexpected argument', () => {
const result = parseArgs({
argv: ['node', 'freebuff', 'login'],
Expand Down Expand Up @@ -207,4 +240,24 @@ describe('Codebuff --trust-agents', () => {
expect(result.trustAgents).toBe(true)
expect(result.initialPrompt).toBe('fix it')
})

test('parses execution permission flags', () => {
const result = parseArgs({
argv: [
'node',
'codebuff',
'--no-shell',
'--ask-shell',
'--project-only',
'--read-only',
],
isFreebuff: false,
version: '1.0.0',
})

expect(result.noShell).toBe(true)
expect(result.askShell).toBe(true)
expect(result.projectOnly).toBe(true)
expect(result.readOnly).toBe(true)
})
})
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 each shell command',
)
.option(
'--project-only',
'Restrict filesystem access to this project; unrestricted shell is disabled',
)
.option(
'--read-only',
'Deny writes and unrestricted shell commands',
)
.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 each shell command',
)
.option(
'--project-only',
'Restrict filesystem access to this project; unrestricted shell is disabled',
)
.option(
'--read-only',
'Deny writes and unrestricted shell commands',
)
.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),
}
}
5 changes: 3 additions & 2 deletions cli/src/components/chat-input-bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -315,12 +315,12 @@ export const ChatInputBar = ({
}
})

submitAnswers(formattedAnswers)
submitAnswers(formattedAnswers, askUserState)
}

const handleFormSkip = () => {
if (!askUserState) return
skip()
skip(askUserState)
onInterruptStream()
}

Expand All @@ -341,6 +341,7 @@ export const ChatInputBar = ({
}}
>
<MultipleChoiceForm
key={askUserState.toolCallId}
questions={askUserState.questions}
onSubmit={handleFormSubmit}
onSkip={handleFormSkip}
Expand Down
137 changes: 127 additions & 10 deletions cli/src/hooks/__tests__/use-ask-user-bridge.test.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,30 @@
import { AskUserBridge } from '@codebuff/common/utils/ask-user-bridge'
import {
__resetShellApprovalBridge,
getActiveShellApproval,
requestShellApproval,
} from '@codebuff/common/utils/shell-approval-bridge'
import { describe, test, expect, beforeEach, afterEach, spyOn } from 'bun:test'


import {
buildShellApprovalQuestions,
escapeControlCharactersForDisplay,
skipForm,
submitAnswersForForm,
} from '../use-ask-user-bridge'

import type { AskUserFormAnswers } from '../use-ask-user-bridge'
import { useChatStore } from '../../state/chat-store'

describe('useAskUserBridge', () => {
const submitAnswers = (
answers: Array<{
questionIndex: number
selectedOption?: string
selectedOptions?: string[]
otherText?: string
}>
) => {
AskUserBridge.submit({ answers })
// Answers routed as a generic ask_user form (no shell-approval kind).
const submitAnswers = (answers: AskUserFormAnswers) => {
submitAnswersForForm(answers, { kind: 'ask-user', toolCallId: 'ask-user-1' })
}

const skip = () => {
AskUserBridge.submit({ skipped: true })
skipForm({ kind: 'ask-user', toolCallId: 'ask-user-1' })
}

let submitSpy: ReturnType<typeof spyOn>
Expand Down Expand Up @@ -173,4 +180,114 @@ describe('useAskUserBridge', () => {
expect(useChatStore.getState().cursorPosition).toBe(0)
})
})

// A shell approval is correlated by id. A late submission from an expired
// form must never resolve the command that is active now.
describe('shell approval routing', () => {
beforeEach(() => {
__resetShellApprovalBridge()
})

afterEach(() => {
__resetShellApprovalBridge()
})

test('a stale answer for a timed-out request cannot approve the next command', async () => {
// Command A is displayed and times out before the user answers it.
const aVerdict = await requestShellApproval(
{ id: 'A', command: 'echo first-command', cwd: '/project', processType: 'SYNC' },
{ timeoutMs: 1 },
)
expect(aVerdict).toBe('deny')

// Command B becomes the active request.
let bVerdict: string | undefined
const bSettled = requestShellApproval(
{ id: 'B', command: 'echo second-command', cwd: '/project', processType: 'SYNC' },
{ timeoutMs: 1000 },
).then((verdict) => {
bVerdict = verdict
})
expect(getActiveShellApproval()?.id).toBe('B')

// A delayed "Approve" from A's form still carries A's captured id.
submitAnswersForForm(
[{ questionIndex: 0, selectedOption: 'Approve' }],
{ kind: 'shell-approval', toolCallId: 'A' },
)

// B is untouched and the generic ask_user bridge was never used.
expect(getActiveShellApproval()?.id).toBe('B')
expect(bVerdict).toBeUndefined()
expect(submitSpy).not.toHaveBeenCalled()

// Answering B's own form resolves B.
submitAnswersForForm(
[{ questionIndex: 0, selectedOption: 'Approve' }],
{ kind: 'shell-approval', toolCallId: 'B' },
)
await bSettled
expect(bVerdict).toBe('approve')
})

test('skipping a shell approval denies it rather than approving', async () => {
const verdictPromise = requestShellApproval(
{ id: 'C', command: 'echo third-command', cwd: '/project', processType: 'SYNC' },
{ timeoutMs: 1000 },
)

skipForm({ kind: 'shell-approval', toolCallId: 'C' })

expect(await verdictPromise).toBe('deny')
})

test('any answer other than Approve denies the command', async () => {
const verdictPromise = requestShellApproval(
{ id: 'D', command: 'echo fourth-command', cwd: '/project', processType: 'SYNC' },
{ timeoutMs: 1000 },
)

submitAnswersForForm(
[{ questionIndex: 0, selectedOption: 'Deny' }],
{ kind: 'shell-approval', toolCallId: 'D' },
)

expect(await verdictPromise).toBe('deny')
})
})

describe('buildShellApprovalQuestions', () => {
test('shows the exact command, working directory, and process type', () => {
const questions = buildShellApprovalQuestions({
command: 'bun test',
cwd: '/work/project',
processType: 'BACKGROUND',
})

const text = questions[0]!.question
expect(text).toContain('background')
expect(text).toContain('bun test')
expect(text).toContain('/work/project')
})

test('escapes control characters without changing the authorized command', () => {
const command = 'echo hi\u0007\u001b[31mred'
const questions = buildShellApprovalQuestions({
command,
cwd: '/work',
processType: 'SYNC',
})

const text = questions[0]!.question
// Control characters are rendered as visible escapes...
expect(text).toContain('\\u0007')
expect(text).toContain('\\u001b')
expect(text).not.toContain('\u0007')
expect(text).not.toContain('\u001b')
// ...but the raw command is only transformed for display.
expect(escapeControlCharactersForDisplay(command)).not.toBe(command)
expect(command).toContain('\u0007')
expect(command).toContain('\u001b')
})
})
})
Loading
Loading