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
38 changes: 38 additions & 0 deletions sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,44 @@ async function main() {
main()
```

### Execution permissions

`client.run()` accepts `noShell`, `askShell`, `projectOnly`, and `readOnly` to
enforce tool restrictions in the SDK runtime. `noShell` disables shell commands;
`askShell` requires a host-provided approval callback and denies commands when
that callback is absent. `readOnly` denies writes, and `projectOnly` restricts
SDK-managed filesystem access to the project directory (see the guarantee
boundaries below).

The SDK does not yet apply an operating-system sandbox to shell commands.
Because an unrestricted shell can write outside the SDK filesystem adapter or
access paths outside the project, both `readOnly` and `projectOnly` also disable
shell commands. This means commands such as `git status`, `git diff`, `ls`, and
`cat` are unavailable in those modes. External MCP, custom, and host-provided
tools are denied while any restriction is active unless the host explicitly
sets `allowExternalTools: true`.

The CLI's own `ask_user` prompt is the one exception: it is an audited UI
capability that runs inside the host process and cannot reach the filesystem or
the shell, so it keeps working under every restriction. A custom tool or MCP
server that merely reuses the `ask_user` name is still treated as external.

#### What `projectOnly` does and does not guarantee

`projectOnly` restricts **SDK-managed** filesystem operations: reads and writes
that go through the SDK's filesystem adapter, and tool targets the dispatcher can
resolve. It is not an OS security boundary. In particular it does not prevent:

- external processes (or a shell the host runs outside the SDK) from mutating the
same tree, or
- a path being swapped for a symlink between the containment check and the file
operation (a time-of-check/time-of-use race).

Symlink resolution uses `canonicalize` (default `fs.promises.realpath`). It must
describe the same filesystem as the run's adapter: when a custom adapter is
supplied without a matching canonicalizer, `projectOnly` is refused rather than
silently checked against the host filesystem.

## API Reference

### Knowledge Files
Expand Down
76 changes: 76 additions & 0 deletions sdk/src/__tests__/run-file-filter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,82 @@ describe('CodebuffClientOptions fileFilter', () => {
expect(optionalFileResult.current).toBe('contents:.ENV')
})

it('requires external trust before either direct read_files override path', async () => {
spyOn(databaseModule, 'getUserInfoFromApiKey').mockResolvedValue({
id: 'user-123',
email: 'test@example.com',
discord_id: null,
stripe_customer_id: null,
banned: false,
created_at: new Date('2024-01-01T00:00:00Z'),
})
spyOn(databaseModule, 'fetchAgentFromDatabase').mockResolvedValue(null)
spyOn(databaseModule, 'startAgentRun').mockResolvedValue('run-1')
spyOn(databaseModule, 'finishAgentRun').mockResolvedValue(undefined)
spyOn(databaseModule, 'addAgentStep').mockResolvedValue('step-1')

const mockFs = createMockFs({
files: {
'/project/src/index.ts': { content: 'export const value = 1\n' },
},
})
const override = mock(async ({ filePaths }: { filePaths: string[] }) =>
Object.fromEntries(
filePaths.map((filePath) => [filePath, 'remote data']),
),
)
const failures: unknown[] = []

spyOn(mainPromptModule, 'callMainPrompt').mockImplementation(
async (params: Parameters<typeof mainPromptModule.callMainPrompt>[0]) => {
const { sendAction, promptId, requestFiles, requestOptionalFile } =
params
const sessionState = getInitialSessionState(getStubProjectFileContext())
for (const request of [
() => requestFiles({ filePaths: ['src/index.ts'] }),
() => requestOptionalFile({ filePath: 'src/index.ts' }),
]) {
try {
await request()
} catch (error) {
failures.push(error)
}
}

await sendAction({
action: {
type: 'prompt-response',
promptId,
sessionState,
output: { type: 'lastMessage', value: [] },
},
})

return {
sessionState,
output: { type: 'lastMessage' as const, value: [] },
}
},
)

const client = new CodebuffClient({
apiKey: 'test-key',
cwd: '/project',
fsSource: mockFs,
overrideTools: { read_files: override },
})
const result = await client.run({
agent: 'base2',
prompt: 'read files',
noShell: true,
})

expect(result.output.type).toBe('lastMessage')
expect(failures).toHaveLength(2)
expect(failures.map(String).join('\n')).toContain('external capability')
expect(override).not.toHaveBeenCalled()
})

it('should keep env templates subject to gitignore with allow-example', async () => {
spyOn(databaseModule, 'getUserInfoFromApiKey').mockResolvedValue({
id: 'user-123',
Expand Down
Loading
Loading