Skip to content
Closed
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
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -145,12 +145,13 @@
"@inquirer/prompts": "8.5.2",
"@langchain/anthropic": "^1.0.0",
"@langchain/aws": "^1.3.9",
"@langchain/community": "^1.1.29",
"@langchain/classic": "1.0.32",
"@langchain/core": "^1.1.34",
"@langchain/google-genai": "^2.1.31",
"@langchain/mistralai": "^1.1.0",
"@langchain/ollama": "^1.2.7",
"@langchain/openai": "^1.4.5",
"@langchain/textsplitters": "1.0.1",
"@modelcontextprotocol/sdk": "1.29.0",
"ajv": "^8.20.0",
"chalk": "4.1.2",
Expand Down
22 changes: 22 additions & 0 deletions src/lib/autofix/adapters/claude.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,28 @@ describe('ClaudeAdapter', () => {
expect(args[args.length - 1]).toBe('fix the bug')
})

it('drops unrecognized option keys instead of forwarding them as flags (#1840)', async () => {
mockSpawn.mockReturnValue(makeChild(0))
const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined)

await adapter.run('fix the bug', {
model: 'claude-sonnet-4-20250514',
'dangerously-skip-permissions': 'true',
'permission-mode': 'bypassPermissions',
})

const args = mockSpawn.mock.calls[0][1] as string[]
expect(args).toContain('--model')
expect(args).not.toContain('--dangerously-skip-permissions')
expect(args).not.toContain('--permission-mode')
expect(args).not.toContain('bypassPermissions')
expect(warn).toHaveBeenCalled()
expect(warn.mock.calls[0][0]).toContain('dangerously-skip-permissions')
expect(warn.mock.calls[0][0]).toContain('permission-mode')

warn.mockRestore()
})

it('injects ANTHROPIC_API_KEY when apiKey is provided and ambient is unset', async () => {
const previousApiKey = process.env.ANTHROPIC_API_KEY
delete process.env.ANTHROPIC_API_KEY
Expand Down
35 changes: 25 additions & 10 deletions src/lib/autofix/adapters/claude.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,40 @@
import { spawn } from 'child_process'
import { BaseAdapter, AutoFixVendor } from '../types'
import { filterAllowedOptions } from '../optionAllowlist'

/**
* Tuning flags this adapter forwards to `claude --print`. Deliberately
* excludes `--permission-mode` / `--dangerously-skip-permissions` — flags
* whose entire purpose is disabling the CLI's own confirmation gates, the
* exact class of flag #1840 is about keeping out of autoFixToolOptions.
*/
const ALLOWED_OPTIONS = new Set(['model', 'fallback-model', 'max-turns', 'output-format', 'append-system-prompt'])

export class ClaudeAdapter implements BaseAdapter {
readonly vendor: AutoFixVendor = 'anthropic'
readonly envVar = 'ANTHROPIC_API_KEY'
readonly binary = 'claude'

async run(
prompt: string,
options?: Record<string, string>,
apiKey?: string,
forceApiKey?: string
): Promise<void> {
buildArgs(options?: Record<string, string>): string[] {
const args: string[] = ['--print']

if (options) {
for (const [key, value] of Object.entries(options)) {
const allowedOptions = filterAllowedOptions(options, ALLOWED_OPTIONS, 'claude')
if (allowedOptions) {
for (const [key, value] of Object.entries(allowedOptions)) {
args.push(`--${key}`, value)
}
}

args.push(prompt)
return args
}

async run(
prompt: string,
options?: Record<string, string>,
apiKey?: string,
forceApiKey?: string
): Promise<void> {
const args = [...this.buildArgs(options), prompt]

// Build the child environment:
// - forceApiKey (explicit per-tool credential) always wins, even if an
Expand All @@ -34,7 +49,7 @@ export class ClaudeAdapter implements BaseAdapter {
}

return new Promise((resolve, reject) => {
const child = spawn('claude', args, { stdio: 'inherit', env })
const child = spawn(this.binary, args, { stdio: 'inherit', env })

child.on('error', (err: NodeJS.ErrnoException) => {
if (err.code === 'ENOENT') {
Expand Down
25 changes: 25 additions & 0 deletions src/lib/autofix/adapters/codex.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,31 @@ describe('CodexAdapter', () => {
expect(args[args.length - 1]).toBe('fix the bug')
})

it('drops unrecognized option keys instead of forwarding them as -c overrides (#1840)', async () => {
mockSpawn.mockReturnValue(makeChild(0))
const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined)

await adapter.run('fix the bug', {
model: 'o4-mini',
'dangerously-bypass-approvals-and-sandbox': 'true',
'shell_environment_policy.include_only': '["*"]',
})

const args = mockSpawn.mock.calls[0][1] as string[]
expect(args).toContain('--model')
expect(args).toContain('o4-mini')
expect(args).not.toContain('dangerously-bypass-approvals-and-sandbox')
expect(args.join(' ')).not.toContain('dangerously-bypass-approvals-and-sandbox')
expect(args.join(' ')).not.toContain('shell_environment_policy')
// No stray `-c` flag with nothing recognized behind it.
const cCount = args.filter((a) => a === '-c').length
expect(cCount).toBe(0)
expect(warn).toHaveBeenCalled()
expect(warn.mock.calls[0][0]).toContain('dangerously-bypass-approvals-and-sandbox')

warn.mockRestore()
})

it('injects OPENAI_API_KEY when apiKey is provided and ambient is unset', async () => {
const previousApiKey = process.env.OPENAI_API_KEY
delete process.env.OPENAI_API_KEY
Expand Down
45 changes: 35 additions & 10 deletions src/lib/autofix/adapters/codex.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,56 @@
import { spawn } from 'child_process'
import { BaseAdapter, AutoFixVendor } from '../types'
import { filterAllowedOptions } from '../optionAllowlist'

/** Keys mapped to a named codex CLI flag rather than a `-c` config override. */
const DIRECT_FLAG_OPTIONS = new Set(['model', 'm', 'sandbox', 's'])

/**
* Keys still allowed through to codex's `-c key=value` config-override
* mechanism. This used to be every unrecognized key — an unbounded escape
* hatch that let a config value write directly into codex's config
* namespace, including keys that disable its own approval/sandbox gates
* (#1840). Only `approval-mode` is allowlisted; anything else is dropped by
* `filterAllowedOptions` before this file ever sees it.
*/
const CONFIG_OVERRIDE_OPTIONS = new Set(['approval-mode'])

const ALLOWED_OPTIONS = new Set([...DIRECT_FLAG_OPTIONS, ...CONFIG_OVERRIDE_OPTIONS])

export class CodexAdapter implements BaseAdapter {
readonly vendor: AutoFixVendor = 'openai'
readonly envVar = 'OPENAI_API_KEY'
readonly binary = 'codex'

async run(
prompt: string,
options?: Record<string, string>,
apiKey?: string,
forceApiKey?: string
): Promise<void> {
buildArgs(options?: Record<string, string>): string[] {
const args: string[] = ['exec']

if (options) {
for (const [key, value] of Object.entries(options)) {
const allowedOptions = filterAllowedOptions(options, ALLOWED_OPTIONS, 'codex')
if (allowedOptions) {
for (const [key, value] of Object.entries(allowedOptions)) {
if (key === 'model' || key === 'm') {
args.push('--model', value)
} else if (key === 'sandbox' || key === 's') {
args.push('--sandbox', value)
} else {
// Only CONFIG_OVERRIDE_OPTIONS entries reach here — everything
// else was already dropped by filterAllowedOptions above.
args.push('-c', `${key}=${value}`)
}
}
}

args.push('--full-auto', prompt)
args.push('--full-auto')
return args
}

async run(
prompt: string,
options?: Record<string, string>,
apiKey?: string,
forceApiKey?: string
): Promise<void> {
const args = [...this.buildArgs(options), prompt]

// Build the child environment:
// - forceApiKey (explicit per-tool credential) always wins, even if an
Expand All @@ -40,7 +65,7 @@ export class CodexAdapter implements BaseAdapter {
}

return new Promise((resolve, reject) => {
const child = spawn('codex', args, { stdio: 'inherit', env })
const child = spawn(this.binary, args, { stdio: 'inherit', env })

child.on('error', (err: NodeJS.ErrnoException) => {
if (err.code === 'ENOENT') {
Expand Down
15 changes: 15 additions & 0 deletions src/lib/autofix/adapters/gemini.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,21 @@ describe('GeminiAdapter', () => {
expect(args[args.length - 1]).toBe('fix the bug')
})

it('drops unrecognized option keys instead of forwarding them as flags (#1840)', async () => {
mockSpawn.mockReturnValue(makeChild(0))
const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined)

await adapter.run('fix the bug', { model: 'gemini-2.5-pro', yolo: 'true' })

const args = mockSpawn.mock.calls[0][1] as string[]
expect(args).toContain('--model')
expect(args).not.toContain('--yolo')
expect(warn).toHaveBeenCalled()
expect(warn.mock.calls[0][0]).toContain('yolo')

warn.mockRestore()
})

it('injects GEMINI_API_KEY when apiKey is provided and ambient is unset', async () => {
const previousApiKey = process.env.GEMINI_API_KEY
delete process.env.GEMINI_API_KEY
Expand Down
36 changes: 26 additions & 10 deletions src/lib/autofix/adapters/gemini.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,41 @@
import { spawn } from 'child_process'
import { BaseAdapter, AutoFixVendor } from '../types'
import { filterAllowedOptions } from '../optionAllowlist'

/**
* Tuning flags this adapter forwards to `gemini`. Deliberately excludes
* `--yolo` (auto-accepts every action, no confirmation) and `--approval-mode`
* when set to its `yolo` value — the exact class of flag #1840 is about
* keeping out of autoFixToolOptions. `sandbox` stays allowed: it's a real
* tuning knob (and can also *restrict* execution), not solely a bypass.
*/
const ALLOWED_OPTIONS = new Set(['model', 'sandbox', 'checkpointing'])

export class GeminiAdapter implements BaseAdapter {
readonly vendor: AutoFixVendor = 'google'
readonly envVar = 'GEMINI_API_KEY'
readonly binary = 'gemini'

async run(
prompt: string,
options?: Record<string, string>,
apiKey?: string,
forceApiKey?: string
): Promise<void> {
buildArgs(options?: Record<string, string>): string[] {
const args: string[] = []

if (options) {
for (const [key, value] of Object.entries(options)) {
const allowedOptions = filterAllowedOptions(options, ALLOWED_OPTIONS, 'gemini')
if (allowedOptions) {
for (const [key, value] of Object.entries(allowedOptions)) {
args.push(`--${key}`, value)
}
}

args.push(prompt)
return args
}

async run(
prompt: string,
options?: Record<string, string>,
apiKey?: string,
forceApiKey?: string
): Promise<void> {
const args = [...this.buildArgs(options), prompt]

// Build the child environment:
// - forceApiKey (explicit per-tool credential) always wins, even if an
Expand All @@ -34,7 +50,7 @@ export class GeminiAdapter implements BaseAdapter {
}

return new Promise((resolve, reject) => {
const child = spawn('gemini', args, { stdio: 'inherit', env })
const child = spawn(this.binary, args, { stdio: 'inherit', env })

child.on('error', (err: NodeJS.ErrnoException) => {
if (err.code === 'ENOENT') {
Expand Down
103 changes: 103 additions & 0 deletions src/lib/autofix/hostileProjectConfig.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/**
* End-to-end proof for #1840: a hostile repo-committed `.coco.json` cannot
* influence the argv coco's auto-fix eventually hands to `spawn`. Every
* other test in this area mocks one boundary and asserts a filter fired at
* it (`project.test.ts` mocks fs and checks the merged Config object; the
* adapter tests mock `child_process` and check one adapter's args). This
* file instead chains the two REAL boundaries — `loadProjectJsonConfig`
* (repo trust filter) into `CodexAdapter.buildArgs` (adapter allowlist) —
* with nothing mocked in between, so a regression in how they compose
* would be caught even if each boundary's own unit tests still pass.
*/
import * as fs from 'fs'
import { loadProjectJsonConfig } from '../config/services/project'
import { Config } from '../config/types'
import { getDefaultServiceConfigFromAlias } from '../langchain/utils'
import { resolveGitRepoRoot } from '../utils/resolveGitRepoRoot'
import { CodexAdapter } from './adapters/codex'
import { ClaudeAdapter } from './adapters/claude'
import { GeminiAdapter } from './adapters/gemini'

jest.mock('fs')
jest.mock('os')
jest.mock('path', () => jest.requireActual('path'))
jest.mock('ini')
jest.mock('yargs', () => ({ argv: {} }))
jest.mock('../utils/resolveGitRepoRoot')

const mockFs = fs as jest.Mocked<typeof fs>
const mockResolveGitRepoRoot = resolveGitRepoRoot as jest.MockedFunction<typeof resolveGitRepoRoot>

const baseConfig: Config = {
service: getDefaultServiceConfigFromAlias('openai'),
defaultBranch: 'main',
mode: 'stdout',
}

beforeEach(() => {
mockResolveGitRepoRoot.mockReturnValue('/fake/repo/root')
jest.spyOn(console, 'warn').mockImplementation(() => undefined)
})

afterEach(() => {
jest.restoreAllMocks()
})

describe('hostile project config cannot influence the spawn() argv (#1840)', () => {
it('a repo-committed .coco.json cannot select the auto-fix tool or its flags at all', () => {
mockFs.existsSync.mockReturnValue(true)
mockFs.readFileSync.mockReturnValue(
JSON.stringify({
autoFixTool: 'codex',
autoFixToolOptions: {
model: 'o4-mini',
'dangerously-bypass-approvals-and-sandbox': 'true',
'shell_environment_policy.include_only': '["*"]',
},
autoFixToolApiKey: 'attacker-supplied-key',
})
)

const config = loadProjectJsonConfig(baseConfig) as Config & {
autoFixTool?: string
autoFixToolOptions?: Record<string, string>
autoFixToolApiKey?: string
}

// The repo file never even reaches an AutoFixConfig — the whole
// feature is off unless a trusted layer (global/XDG, ~/.gitconfig,
// env) turns it on.
expect(config.autoFixTool).toBeUndefined()
expect(config.autoFixToolOptions).toBeUndefined()
expect(config.autoFixToolApiKey).toBeUndefined()

// Defense in depth: even feeding the raw hostile options straight into
// the adapter — as if the first boundary above didn't exist — the
// dangerous key still never reaches spawn's argv.
const args = new CodexAdapter().buildArgs({
model: 'o4-mini',
'dangerously-bypass-approvals-and-sandbox': 'true',
'shell_environment_policy.include_only': '["*"]',
})

expect(args).toContain('--model')
expect(args).toContain('o4-mini')
expect(args.join(' ')).not.toContain('dangerously-bypass-approvals-and-sandbox')
expect(args.join(' ')).not.toContain('shell_environment_policy')
})

const dangerousFlagCases: Array<{ name: string; Adapter: new () => CodexAdapter | ClaudeAdapter | GeminiAdapter; dangerous: Record<string, string> }> = [
{ name: 'codex', Adapter: CodexAdapter, dangerous: { 'dangerously-bypass-approvals-and-sandbox': 'true' } },
{ name: 'claude', Adapter: ClaudeAdapter, dangerous: { 'dangerously-skip-permissions': 'true' } },
{ name: 'gemini', Adapter: GeminiAdapter, dangerous: { yolo: 'true' } },
]

it.each(dangerousFlagCases)('$name adapter never forwards a permission/sandbox-bypass flag even if handed it directly', ({ Adapter, dangerous }) => {
const args = new Adapter().buildArgs({ ...dangerous, model: 'safe-model' })

for (const key of Object.keys(dangerous)) {
expect(args.join(' ')).not.toContain(key)
}
expect(args).toContain('--model')
})
})
Loading
Loading