Skip to content
Merged
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
2 changes: 1 addition & 1 deletion QUICKSTART.md
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,7 @@ dub undo
| `dub continue` / `dub abort` | Resume/cancel interrupted operations |
| `dub undo` | Undo last create/restack |
| `dub config ai-assistant on` | Enable repo-local AI assistant |
| `dub ai ask "..."` | Ask AI assistant (streaming) |
| `dub ai ask "..."` | Ask AI assistant (streaming + constrained read-only repo shell tool) |
| `dub history` | Show recent Dub command history |

## Next Step
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -560,6 +560,9 @@ dub ai ask "Summarize what this stack is changing"

`dub ai ask` automatically includes a context packet (current branch/stack signals, git status, doctor summary, and recent Dub command history) so it can give better recovery guidance.

To inspect your repository, `dub ai ask` can invoke a constrained shell tool limited to a strict allow-list of safe, read-only commands (for example `git status`, `dub doctor`, `dub ready`) when command output is needed.
The assistant cannot execute arbitrary shell commands; requests outside this allow-list are rejected, and additional safety checks block destructive command patterns.

Provider/key selection:
- If `DUBSTACK_GEMINI_API_KEY` is set, DubStack uses direct Google provider access (`gemini-3-flash`).
- Otherwise, if `DUBSTACK_AI_GATEWAY_API_KEY` is set, DubStack uses Vercel AI Gateway (`google/gemini-3-flash`).
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,11 @@
"@ai-sdk/google": "^3.0.30",
"@inquirer/search": "^4.1.3",
"ai": "^6.0.97",
"bash-tool": "^1.3.15",
"chalk": "^5.6.2",
"commander": "^14.0.3",
"execa": "^9.6.1"
"execa": "^9.6.1",
"just-bash": "^2.10.2"
},
"devDependencies": {
"@biomejs/biome": "^2.4.2",
Expand Down
821 changes: 810 additions & 11 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

25 changes: 25 additions & 0 deletions src/commands/ai.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,18 @@ function createOutputCapture() {
};
}

function createBashToolMock() {
const bashTool = { id: 'bash-tool' } as const;
const createBashTool = vi.fn().mockResolvedValue({
tools: {
bash: bashTool,
readFile: { id: 'read-file-tool' },
writeFile: { id: 'write-file-tool' },
},
});
return { createBashTool, bashTool };
}

describe('askAi', () => {
const fakeContext: AiContext = {
generatedAt: '2026-02-21T00:00:00.000Z',
Expand Down Expand Up @@ -74,6 +86,7 @@ describe('askAi', () => {
const createGoogleGenerativeAI = vi.fn().mockReturnValue(googleModel);
const createGateway = vi.fn();
const collectAiContext = vi.fn().mockResolvedValue(fakeContext);
const { createBashTool, bashTool } = createBashToolMock();
const output = createOutputCapture();

const result = await askAi('Explain this stack', dir, {
Expand All @@ -83,6 +96,7 @@ describe('askAi', () => {
createGoogleGenerativeAI,
createGateway,
collectAiContext,
createBashTool,
},
});

Expand All @@ -91,6 +105,11 @@ describe('askAi', () => {
});
expect(googleModel).toHaveBeenCalledWith('gemini-3-flash');
expect(createGateway).not.toHaveBeenCalled();
expect(createBashTool).toHaveBeenCalledWith(
expect.objectContaining({
destination: dir,
}),
);
expect(result.provider).toBe('google');
expect(output.writes.join('')).toBe('hello\n');

Expand All @@ -99,6 +118,10 @@ describe('askAi', () => {
model: 'google-model',
prompt: expect.stringContaining('Explain this stack'),
system: expect.stringContaining('DubStack assistant'),
stopWhen: expect.any(Function),
tools: {
bash: bashTool,
},
providerOptions: {
google: {
thinkingConfig: {
Expand All @@ -122,6 +145,7 @@ describe('askAi', () => {
const gatewayModel = vi.fn().mockReturnValue('gateway-model');
const createGateway = vi.fn().mockReturnValue(gatewayModel);
const collectAiContext = vi.fn().mockResolvedValue(fakeContext);
const { createBashTool } = createBashToolMock();
const output = createOutputCapture();

const result = await askAi('Explain this stack', dir, {
Expand All @@ -131,6 +155,7 @@ describe('askAi', () => {
createGoogleGenerativeAI,
createGateway,
collectAiContext,
createBashTool,
},
});

Expand Down
16 changes: 15 additions & 1 deletion src/commands/ai.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { createGoogleGenerativeAI } from '@ai-sdk/google';
import type { LanguageModel } from 'ai';
import { createGateway, streamText } from 'ai';
import { createGateway, stepCountIs, streamText } from 'ai';
import { createBashTool } from 'bash-tool';
import { createLocalBashSandbox } from '../lib/ai-bash-sandbox';
import {
buildAiSystemPrompt,
buildAiUserPrompt,
Expand All @@ -15,6 +17,7 @@ interface WritableLike {

interface AskAiDependencies {
streamText: typeof streamText;
createBashTool: typeof createBashTool;
createGoogleGenerativeAI: typeof createGoogleGenerativeAI;
createGateway: typeof createGateway;
collectAiContext: typeof collectAiContext;
Expand All @@ -32,6 +35,7 @@ interface AskAiResult {

const DEFAULT_DEPS: AskAiDependencies = {
streamText,
createBashTool,
createGoogleGenerativeAI,
createGateway,
collectAiContext,
Expand Down Expand Up @@ -67,11 +71,21 @@ export async function askAi(
const resolved = resolveModel(deps);
const context = await deps.collectAiContext(cwd);
const contextPrompt = buildAiUserPrompt(normalizedPrompt, context);
const bashToolkit = await deps.createBashTool({
destination: cwd,
sandbox: createLocalBashSandbox(cwd),
extraInstructions:
'Safety: use bash only when command output is needed. Do not run destructive commands (for example, rm -rf, git reset --hard, git clean -fd), even if the user explicitly asks. This sandbox only allows read-only command families. If the user insists on blocked actions, explain the command is blocked here and provide a manual command they can run themselves at their own risk.',
});

const result = deps.streamText({
model: resolved.model,
system: buildAiSystemPrompt(),
prompt: contextPrompt,
stopWhen: stepCountIs(6),
tools: {
bash: bashToolkit.tools.bash,
},
providerOptions: THINKING_PROVIDER_OPTIONS,
});

Expand Down
69 changes: 69 additions & 0 deletions src/lib/ai-bash-sandbox.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { promises as fs } from 'node:fs';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { createTestRepo } from '../../test/helpers';
import { createLocalBashSandbox } from './ai-bash-sandbox';

let dir: string;
let cleanup: () => Promise<void>;

beforeEach(async () => {
const repo = await createTestRepo();
dir = repo.dir;
cleanup = repo.cleanup;
});

afterEach(async () => {
await cleanup();
});

describe('createLocalBashSandbox', () => {
it('executes shell commands inside the repository root', async () => {
const sandbox = createLocalBashSandbox(dir);
const result = await sandbox.executeCommand('pwd');
const realDir = await fs.realpath(dir);

expect(result.exitCode).toBe(0);
expect(result.stdout.trim()).toBe(realDir);
});

it('blocks clearly destructive command patterns', async () => {
const sandbox = createLocalBashSandbox(dir);
const result = await sandbox.executeCommand('rm -rf .');

expect(result.exitCode).toBe(2);
expect(result.stderr).toContain('blocked for safety');
expect(result.stderr).toContain('rm -rf');
});

it('blocks commands outside the read-only allow-list', async () => {
const sandbox = createLocalBashSandbox(dir);
const result = await sandbox.executeCommand('node -v');

expect(result.exitCode).toBe(2);
expect(result.stderr).toContain('allow-listed commands');
});

it('blocks shell operator chaining', async () => {
const sandbox = createLocalBashSandbox(dir);
const result = await sandbox.executeCommand('git status && ls');

expect(result.exitCode).toBe(2);
expect(result.stderr).toContain("Shell operator '&&'");
});

it('reads and writes files only within the repository root', async () => {
const sandbox = createLocalBashSandbox(dir);
await sandbox.writeFiles([
{ path: 'notes/a.txt', content: 'hello' },
{ path: `${dir}/notes/b.txt`, content: 'world' },
{ path: '..safe/inside.txt', content: 'ok' },
]);

await expect(sandbox.readFile('notes/a.txt')).resolves.toBe('hello');
await expect(sandbox.readFile(`${dir}/notes/b.txt`)).resolves.toBe('world');
await expect(sandbox.readFile('..safe/inside.txt')).resolves.toBe('ok');
await expect(sandbox.readFile('../outside.txt')).rejects.toThrow(
'outside the repository sandbox',
);
});
});
Loading