feat: add Shuru sandbox mode for agent shell execution (#215)

* feat: add Shuru sandbox mode for agent shell execution

Adds a user-selectable sandbox mode that runs all agent-driven bash
commands inside a Shuru microVM. Includes persistent user/project
settings, CLI flags (--sandbox / --no-sandbox), a TUI picker via
/sandbox, runtime toggling for live agents, delegation propagation,
model prompt updates, and Phase 1 guardrails that block shell commands
which would silently lose workspace mutations inside the guest overlay.

* fix: use word boundary in git guardrail to catch compound commands

The git mutation guard used ^ (start-of-string anchor), so compound
expressions like `echo foo && git commit` bypassed it. Switch to \b
to match the pattern used by the other guardrails.

* fix: remove branch from git allowlist and stabilize bash tests

- Remove `branch` from the read-only git allowlist since git branch
  can create/delete/rename branches, which are mutating operations.
- Export wrapCommandForShuru and getSandboxMutationBlockReason for
  direct unit testing without child_process mocking.
- Rewrite bash.test.ts to test pure functions directly, eliminating
  flaky vi.doMock/resetModules ESM interactions.
This commit is contained in:
Ismail Pelaseyed authored and GitHub committed 2026-03-25 14:02:53 +01:00
1 parent 76498778d5
commit 2058cdfd09
12 files changed
+721 -35

No files matched your search

+35 -8
View File
@@ -32,7 +32,7 @@ import type {
WorkspaceInfo,
} from "../types/index";
import { loadCustomInstructions } from "../utils/instructions";
import { type CustomSubagentConfig, loadMcpServers, loadValidSubAgents } from "../utils/settings";
import { type CustomSubagentConfig, loadMcpServers, loadValidSubAgents, type SandboxMode } from "../utils/settings";
import { discoverSkills, formatSkillsForPrompt } from "../utils/skills";
import {
type CompactionSettings,
@@ -55,6 +55,7 @@ const VISION_MODEL = "grok-4-1-fast-reasoning";
interface AgentOptions {
persistSession?: boolean;
session?: string;
sandboxMode?: SandboxMode;
}
type ProcessMessageFinishReason = "stop" | "length" | "content-filter" | "tool-calls" | "error" | "other";
@@ -243,6 +244,7 @@ function formatCustomSubagentsPromptSection(subagents: CustomSubagentConfig[]):
function buildSystemPrompt(
cwd: string,
mode: AgentMode,
sandboxMode: SandboxMode,
planContext?: string | null,
subagents?: CustomSubagentConfig[],
): string {
@@ -254,12 +256,13 @@ function buildSystemPrompt(
const skillsText = formatSkillsForPrompt(discoverSkills(cwd));
const skillsSection = skillsText ? `\n\n${skillsText}\n` : "";
const subagentsSection = formatCustomSubagentsPromptSection(subagents ?? loadValidSubAgents());
const sandboxSection = formatSandboxPromptSection(sandboxMode);
const planSection = planContext
? `\n\nAPPROVED PLAN:\nThe following plan has been approved by the user. Execute it now.\n${planContext}\n`
: "";
return `${MODE_PROMPTS[mode]}${customSection}${skillsSection}${subagentsSection}${planSection}
return `${MODE_PROMPTS[mode]}${sandboxSection}${customSection}${skillsSection}${subagentsSection}${planSection}
Current working directory: ${cwd}`;
}
@@ -268,6 +271,7 @@ function buildSubagentPrompt(
request: TaskRequest,
cwd: string,
custom: CustomSubagentConfig | null,
sandboxMode: SandboxMode,
subagents?: CustomSubagentConfig[],
): string {
const isExplore = request.agent === "explore";
@@ -308,7 +312,21 @@ function buildSubagentPrompt(
"",
`Delegated task: ${request.description}`,
"",
buildSystemPrompt(cwd, mode, undefined, subagents),
buildSystemPrompt(cwd, mode, sandboxMode, undefined, subagents),
].join("\n");
}
function formatSandboxPromptSection(sandboxMode: SandboxMode): string {
if (sandboxMode === "off") return "";
return [
"",
"SANDBOX MODE:",
"- Bash commands run inside a Shuru sandbox.",
"- Network is disabled by default unless the current workspace config allows it.",
"- The current workspace is mounted inside the sandbox at `/workspace`.",
"- Shell-side workspace file changes do not persist back to the host in this version.",
"- Use `read_file`, `edit_file`, and `write_file` for durable source edits.",
"- If a task needs a host-persistent shell mutation, explain that sandbox mode blocks that workflow and ask whether to disable sandbox mode.",
].join("\n");
}
@@ -360,7 +378,7 @@ export class Agent {
if (apiKey) {
this.setApiKey(apiKey, baseURL);
}
this.bash = new BashTool();
this.bash = new BashTool(process.cwd(), { sandboxMode: options.sandboxMode ?? "off" });
this.delegations = new DelegationManager(() => this.bash.getCwd());
this.modelId = normalizeModelId(model || DEFAULT_MODEL);
this.schedules = new ScheduleManager(
@@ -399,6 +417,14 @@ export class Agent {
return this.mode;
}
getSandboxMode(): SandboxMode {
return this.bash.getSandboxMode();
}
setSandboxMode(mode: SandboxMode): void {
this.bash.setSandboxMode(mode);
}
setMode(mode: AgentMode): void {
if (mode !== this.mode) {
this.mode = mode;
@@ -454,7 +480,7 @@ export class Agent {
ratioUsed: number;
ratioRemaining: number;
} {
const system = buildSystemPrompt(this.bash.getCwd(), this.mode, this.planContext);
const system = buildSystemPrompt(this.bash.getCwd(), this.mode, this.bash.getSandboxMode(), this.planContext);
const usedTokens = Math.min(contextWindow, estimateConversationTokens(system, this.messages, inFlightText));
const remainingTokens = Math.max(0, contextWindow - usedTokens);
@@ -611,7 +637,7 @@ export class Agent {
}
const childMode: AgentMode = isExplore ? "ask" : "agent";
const childBash = new BashTool(this.bash.getCwd());
const childBash = new BashTool(this.bash.getCwd(), { sandboxMode: this.bash.getSandboxMode() });
const childBaseTools = createTools(childBash, provider, childMode);
const initialDetail = isExplore ? "Scanning the codebase" : "Planning delegated work";
let assistantText = "";
@@ -625,7 +651,7 @@ export class Agent {
? { ...resolveModelRuntime(provider, childModelId), model: provider.responses(childModelId) }
: resolveModelRuntime(provider, childModelId);
const childSystem = applyModelConstraints(
buildSubagentPrompt(request, childBash.getCwd(), custom ?? null, subagents),
buildSubagentPrompt(request, childBash.getCwd(), custom ?? null, childBash.getSandboxMode(), subagents),
childRuntime.modelId,
);
@@ -743,6 +769,7 @@ export class Agent {
return await this.delegations.start(request, {
model: this.modelId,
sandboxMode: this.bash.getSandboxMode(),
maxToolRounds: this.maxToolRounds,
maxTokens: this.maxTokens,
});
@@ -871,7 +898,7 @@ export class Agent {
const provider = this.requireProvider();
const subagents = loadValidSubAgents();
const system = applyModelConstraints(
buildSystemPrompt(this.bash.getCwd(), this.mode, this.planContext, subagents),
buildSystemPrompt(this.bash.getCwd(), this.mode, this.bash.getSandboxMode(), this.planContext, subagents),
this.modelId,
);
const runtime = resolveModelRuntime(provider, this.modelId);
+94
View File
@@ -0,0 +1,94 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
const tempDirs: string[] = [];
function makeTempDir(prefix: string): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
tempDirs.push(dir);
return dir;
}
async function importDelegationsModule(options: { home?: string; spawnMock?: ReturnType<typeof vi.fn> } = {}) {
vi.resetModules();
vi.doUnmock("os");
vi.doUnmock("child_process");
if (options.home) {
vi.doMock("os", async () => {
const actual = await vi.importActual<typeof import("os")>("os");
return {
...actual,
homedir: () => options.home!,
};
});
}
if (options.spawnMock) {
vi.doMock("child_process", async () => {
const actual = await vi.importActual<typeof import("child_process")>("child_process");
return {
...actual,
spawn: options.spawnMock,
};
});
}
return import("./delegations");
}
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
vi.restoreAllMocks();
vi.resetModules();
vi.doUnmock("os");
vi.doUnmock("child_process");
});
describe("DelegationManager sandbox propagation", () => {
it("persists sandbox mode in background delegation records", async () => {
const home = makeTempDir("grok-delegation-home-");
const cwd = makeTempDir("grok-delegation-cwd-");
const spawnMock = vi.fn(() => ({
pid: 2468,
unref: vi.fn(),
}));
const mod = await importDelegationsModule({ home, spawnMock });
const manager = new mod.DelegationManager(() => cwd);
const result = await manager.start(
{
agent: "explore",
description: "Inspect the repo",
prompt: "Find the execution path.",
},
{
model: "grok-test-model",
sandboxMode: "shuru",
maxToolRounds: 25,
maxTokens: 2048,
},
);
expect(result.success).toBe(true);
expect(spawnMock).toHaveBeenCalledWith(
process.execPath,
expect.arrayContaining(["--directory", cwd]),
expect.objectContaining({ cwd }),
);
const cliArgs = (spawnMock.mock.calls[0] as unknown[])?.[1] as string[];
const jobPath = cliArgs[cliArgs.indexOf("--background-task-file") + 1] as string;
const record = JSON.parse(fs.readFileSync(jobPath, "utf8")) as {
sandboxMode: string;
pid: number;
};
expect(record.sandboxMode).toBe("shuru");
expect(record.pid).toBe(2468);
});
});
+4
View File
@@ -4,6 +4,7 @@ import { promises as fs } from "fs";
import os from "os";
import path from "path";
import type { DelegationRun, DelegationStatus, TaskRequest, ToolResult } from "../types/index";
import type { SandboxMode } from "../utils/settings";
const ID_ADJECTIVES = ["brisk", "calm", "clever", "eager", "gentle", "keen", "lively", "nimble", "quiet", "steady"];
@@ -18,6 +19,7 @@ export interface StoredDelegation {
prompt: string;
cwd: string;
model: string;
sandboxMode: SandboxMode;
maxToolRounds: number;
maxTokens: number;
status: DelegationStatus;
@@ -38,6 +40,7 @@ export interface DelegationNotification {
interface StartDelegationOptions {
model: string;
sandboxMode: SandboxMode;
maxToolRounds: number;
maxTokens: number;
}
@@ -74,6 +77,7 @@ export class DelegationManager {
prompt: request.prompt,
cwd,
model: options.model,
sandboxMode: options.sandboxMode,
maxToolRounds: options.maxToolRounds,
maxTokens: options.maxTokens,
status: "running",
+78
View File
@@ -0,0 +1,78 @@
import { afterEach, describe, expect, it, vi } from "vitest";
async function importAgentModule() {
vi.resetModules();
vi.doMock("../storage/index", () => ({
appendCompaction: vi.fn(),
appendMessages: vi.fn(() => []),
appendSystemMessage: vi.fn(() => 0),
buildChatEntries: vi.fn(() => []),
getNextMessageSequence: vi.fn(() => 0),
getSessionTotalTokens: vi.fn(() => 0),
loadTranscript: vi.fn(() => []),
loadTranscriptState: vi.fn(() => ({ messages: [], seqs: [] })),
recordUsageEvent: vi.fn(),
SessionStore: class {
getWorkspace() {
return null;
}
openSession() {
return null;
}
createSession() {
return null;
}
setModel() {}
getRequiredSession() {
return null;
}
setMode() {}
touchSession() {}
},
}));
return import("./agent");
}
afterEach(() => {
vi.restoreAllMocks();
vi.resetModules();
vi.doUnmock("../storage/index");
});
describe("Agent sandbox mode", () => {
it("can switch sandbox mode at runtime", async () => {
const { Agent } = await importAgentModule();
const agent = new Agent(undefined, undefined, undefined, undefined, {
persistSession: false,
sandboxMode: "off",
});
expect(agent.getSandboxMode()).toBe("off");
agent.setSandboxMode("shuru");
expect(agent.getSandboxMode()).toBe("shuru");
});
it("passes sandbox mode into background delegations", async () => {
const { Agent } = await importAgentModule();
const agent = new Agent(undefined, undefined, undefined, undefined, {
persistSession: false,
sandboxMode: "shuru",
});
const startMock = vi.fn(async () => ({ success: true, output: "ok" }));
(agent as unknown as { delegations: { start: typeof startMock } }).delegations.start = startMock;
await (agent as unknown as { runDelegation: (request: unknown) => Promise<unknown> }).runDelegation({
agent: "explore",
description: "Inspect",
prompt: "Look around",
});
expect(startMock).toHaveBeenCalledWith(
expect.objectContaining({ agent: "explore" }),
expect.objectContaining({ sandboxMode: "shuru" }),
);
});
});
+8
View File
@@ -36,6 +36,14 @@ function createScheduleToolSet(overrides?: {
}
describe("schedule daemon tools", () => {
it("describes bash sandbox constraints when shuru mode is enabled", () => {
const tools = createTools(new BashTool("/tmp", { sandboxMode: "shuru" }), {} as never, "agent");
const bashTool = tools.bash as { description?: string };
expect(bashTool.description).toContain("Shuru sandbox");
expect(bashTool.description).toContain("do not persist back to the host");
});
it("reports daemon status", async () => {
const { tools } = createScheduleToolSet({
getDaemonStatus: async () => ({ running: true, pid: 4321 }),
+1 -2
View File
@@ -67,8 +67,7 @@ export function createTools(
const base = {
bash: tool({
description:
"Execute a bash command. Use for searching (grep, rg, find), git, build tools, package managers, running tests, and any other shell command. Set background=true for long-running processes like dev servers, watchers, or anything that should keep running while you continue working. For file read/write/edit, prefer the dedicated file tools instead.",
description: bash.getToolDescription(),
inputSchema: z.object({
command: z.string().describe("The bash command to execute"),
timeout: z
+48 -17
View File
@@ -14,7 +14,14 @@ import {
} from "./headless/output";
import { runTelegramHeadlessBridge } from "./telegram/headless-bridge";
import { startScheduleDaemon } from "./tools/schedule";
import { getApiKey, getBaseURL, getCurrentModel, saveUserSettings } from "./utils/settings";
import {
getApiKey,
getBaseURL,
getCurrentModel,
getCurrentSandboxMode,
type SandboxMode,
saveUserSettings,
} from "./utils/settings";
dotenv.config();
@@ -39,10 +46,11 @@ async function startInteractive(
baseURL: string,
model: string,
maxToolRounds: number,
sandboxMode: SandboxMode,
session?: string,
initialMessage?: string,
) {
const agent = new Agent(apiKey, baseURL, model, maxToolRounds, { session });
const agent = new Agent(apiKey, baseURL, model, maxToolRounds, { session, sandboxMode });
const { createCliRenderer } = await import("@opentui/core");
const { createRoot } = await import("@opentui/react");
const { createElement } = await import("react");
@@ -70,6 +78,7 @@ async function startInteractive(
baseURL,
model,
maxToolRounds,
sandboxMode,
},
initialMessage,
onExit,
@@ -83,10 +92,11 @@ async function runHeadless(
baseURL: string,
model: string,
maxToolRounds: number,
sandboxMode: SandboxMode,
format: HeadlessOutputFormat,
session?: string,
) {
const agent = new Agent(apiKey, baseURL, model, maxToolRounds, { session });
const agent = new Agent(apiKey, baseURL, model, maxToolRounds, { session, sandboxMode });
const prelude = renderHeadlessPrelude(format, agent.getSessionId() || undefined);
if (prelude.stdout) process.stdout.write(prelude.stdout);
if (prelude.stderr) process.stderr.write(prelude.stderr);
@@ -125,21 +135,34 @@ function changeDirectoryOrExit(directory: string | undefined) {
}
}
async function runBackgroundDelegation(jobPath: string, options: Record<string, string | undefined>) {
type CliOptions = Record<string, string | boolean | undefined>;
function stringOption(value: string | boolean | undefined): string | undefined {
return typeof value === "string" ? value : undefined;
}
function resolveCliSandboxMode(value: string | boolean | undefined): SandboxMode | undefined {
if (value === true) return "shuru";
if (value === false) return "off";
return undefined;
}
async function runBackgroundDelegation(jobPath: string, options: CliOptions) {
let output = "";
try {
const delegation = await loadDelegation(jobPath);
const apiKey = options.apiKey || getApiKey();
const apiKey = stringOption(options.apiKey) || getApiKey();
if (!apiKey) {
throw new Error("API key required. Set GROK_API_KEY, use --api-key, or save it to ~/.grok/user-settings.json.");
}
const baseURL = options.baseUrl || getBaseURL();
const model = normalizeModelId(options.model || delegation.model || getCurrentModel());
const baseURL = stringOption(options.baseUrl) || getBaseURL();
const model = normalizeModelId(stringOption(options.model) || delegation.model || getCurrentModel());
const maxToolRounds =
parseInt(options.maxToolRounds || String(delegation.maxToolRounds), 10) || delegation.maxToolRounds;
const agent = new Agent(apiKey, baseURL, model, maxToolRounds, { persistSession: false });
parseInt(stringOption(options.maxToolRounds) || String(delegation.maxToolRounds), 10) || delegation.maxToolRounds;
const sandboxMode = resolveCliSandboxMode(options.sandbox) || delegation.sandboxMode || getCurrentSandboxMode();
const agent = new Agent(apiKey, baseURL, model, maxToolRounds, { persistSession: false, sandboxMode });
const result = await agent.runTaskRequest({
agent: delegation.agent,
description: delegation.description,
@@ -165,16 +188,17 @@ async function runBackgroundDelegation(jobPath: string, options: Record<string,
}
}
function resolveConfig(options: Record<string, string | undefined>) {
const apiKey = options.apiKey || getApiKey();
const baseURL = options.baseUrl || getBaseURL();
const model = normalizeModelId(options.model || getCurrentModel());
const maxToolRounds = parseInt(options.maxToolRounds || "400", 10) || 400;
function resolveConfig(options: CliOptions) {
const apiKey = stringOption(options.apiKey) || getApiKey();
const baseURL = stringOption(options.baseUrl) || getBaseURL();
const model = normalizeModelId(stringOption(options.model) || getCurrentModel());
const maxToolRounds = parseInt(stringOption(options.maxToolRounds) || "400", 10) || 400;
const sandboxMode = resolveCliSandboxMode(options.sandbox) || getCurrentSandboxMode();
if (options.apiKey) saveUserSettings({ apiKey: options.apiKey });
if (options.model) saveUserSettings({ defaultModel: normalizeModelId(options.model) });
if (typeof options.apiKey === "string") saveUserSettings({ apiKey: options.apiKey });
if (typeof options.model === "string") saveUserSettings({ defaultModel: normalizeModelId(options.model) });
return { apiKey, baseURL, model, maxToolRounds };
return { apiKey, baseURL, model, maxToolRounds, sandboxMode };
}
function requireApiKey(apiKey: string | undefined): string {
@@ -207,6 +231,8 @@ program
.option("-d, --directory <dir>", "Working directory", process.cwd())
.option("-p, --prompt <prompt>", "Run a single prompt headlessly")
.option("--format <format>", "Headless output format: text or json", parseHeadlessOutputFormat, "text")
.option("--sandbox", "Run agent shell commands inside a Shuru sandbox")
.option("--no-sandbox", "Run agent shell commands directly on the host")
.option("-s, --session <id>", "Continue a saved session by id, or use 'latest'")
.option("--background-task-file <path>", "Run a persisted background delegation")
.option("--max-tool-rounds <n>", "Max tool execution rounds", "400")
@@ -227,6 +253,7 @@ program
config.baseURL,
config.model,
config.maxToolRounds,
config.sandboxMode,
options.format,
options.session,
);
@@ -239,6 +266,7 @@ program
config.baseURL,
config.model,
config.maxToolRounds,
config.sandboxMode,
options.session,
initialMessage,
);
@@ -251,6 +279,8 @@ program
.option("-u, --base-url <url>", "API base URL")
.option("-m, --model <model>", "Model to use")
.option("-d, --directory <dir>", "Working directory", process.cwd())
.option("--sandbox", "Run agent shell commands inside a Shuru sandbox")
.option("--no-sandbox", "Run agent shell commands directly on the host")
.option("--max-tool-rounds <n>", "Max tool execution rounds", "400")
.option("--log-file <path>", "Bridge log file", "telegram-remote-bridge.log")
.option("--pair-code-file <path>", "Pairing code file", "telegram-pair-code.txt")
@@ -265,6 +295,7 @@ program
baseURL: config.baseURL,
model: config.model,
maxToolRounds: config.maxToolRounds,
sandboxMode: config.sandboxMode,
logFile: options.logFile,
pairCodeFile: options.pairCodeFile,
});
+6 -1
View File
@@ -6,8 +6,10 @@ import {
getApiKey,
getBaseURL,
getCurrentModel,
getCurrentSandboxMode,
getTelegramBotToken,
loadUserSettings,
type SandboxMode,
saveApprovedTelegramUserId,
saveUserSettings,
} from "../utils/settings";
@@ -24,6 +26,7 @@ export interface TelegramHeadlessBridgeOptions {
apiKey?: string;
baseURL?: string;
model?: string;
sandboxMode?: SandboxMode;
maxToolRounds?: number;
logFile?: string;
pairCodeFile?: string;
@@ -33,6 +36,7 @@ interface TelegramHeadlessStartupConfig {
apiKey: string;
baseURL: string;
model: string;
sandboxMode: SandboxMode;
maxToolRounds: number;
}
@@ -72,7 +76,7 @@ function buildTelegramAgentFactory(startupConfig: TelegramHeadlessStartupConfig)
startupConfig.baseURL,
startupConfig.model,
startupConfig.maxToolRounds,
{ session: sessionId },
{ session: sessionId, sandboxMode: startupConfig.sandboxMode },
);
const nextSessionId = agent.getSessionId();
@@ -121,6 +125,7 @@ export async function runTelegramHeadlessBridge(options: TelegramHeadlessBridgeO
apiKey,
baseURL: options.baseURL ?? getBaseURL(),
model: options.model ?? getCurrentModel(),
sandboxMode: options.sandboxMode ?? getCurrentSandboxMode(),
maxToolRounds: options.maxToolRounds ?? 400,
};
const pathOptions: TelegramHeadlessBridgePathOptions = {
+128
View File
@@ -0,0 +1,128 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { BashTool, getSandboxMutationBlockReason, wrapCommandForShuru } from "./bash";
const tempDirs: string[] = [];
function makeTempDir(prefix: string): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
tempDirs.push(dir);
return dir;
}
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
describe("wrapCommandForShuru", () => {
it("wraps a simple command with mount and workspace cd", () => {
const result = wrapCommandForShuru("/repo", "echo hi");
expect(result).toBe("shuru run --mount '/repo:/workspace' -- sh -lc 'cd /workspace && echo hi'");
});
it("handles paths with spaces", () => {
const result = wrapCommandForShuru("/my repo", "ls");
expect(result).toBe("shuru run --mount '/my repo:/workspace' -- sh -lc 'cd /workspace && ls'");
});
it("escapes single quotes in the command", () => {
const result = wrapCommandForShuru("/repo", "echo 'hello world'");
expect(result).toContain("'\\''hello world'\\''");
});
});
describe("getSandboxMutationBlockReason", () => {
it("returns null for read-only git commands", () => {
expect(getSandboxMutationBlockReason("git status")).toBeNull();
expect(getSandboxMutationBlockReason("git diff")).toBeNull();
expect(getSandboxMutationBlockReason("git log --oneline")).toBeNull();
expect(getSandboxMutationBlockReason("git show HEAD")).toBeNull();
expect(getSandboxMutationBlockReason("git rev-parse HEAD")).toBeNull();
expect(getSandboxMutationBlockReason("git grep foo")).toBeNull();
expect(getSandboxMutationBlockReason("git ls-files")).toBeNull();
});
it("blocks mutating git commands", () => {
expect(getSandboxMutationBlockReason("git add .")).toContain("Sandbox mode blocks git commands");
expect(getSandboxMutationBlockReason("git commit -m 'test'")).toContain("Sandbox mode blocks git commands");
expect(getSandboxMutationBlockReason("git push")).toContain("Sandbox mode blocks git commands");
expect(getSandboxMutationBlockReason("git checkout -b new")).toContain("Sandbox mode blocks git commands");
});
it("blocks git inside compound shell expressions", () => {
expect(getSandboxMutationBlockReason('echo foo && git commit -m "test"')).toContain(
"Sandbox mode blocks git commands",
);
expect(getSandboxMutationBlockReason('bash -c "git push"')).toContain("Sandbox mode blocks git commands");
});
it("blocks git branch since it can mutate", () => {
expect(getSandboxMutationBlockReason("git branch -D feature")).toContain("Sandbox mode blocks git commands");
expect(getSandboxMutationBlockReason("git branch new-name")).toContain("Sandbox mode blocks git commands");
});
it("blocks package-manager installs", () => {
expect(getSandboxMutationBlockReason("npm install express")).toContain("Package-manager installs");
expect(getSandboxMutationBlockReason("yarn add lodash")).toContain("Package-manager installs");
expect(getSandboxMutationBlockReason("pnpm install")).toContain("Package-manager installs");
expect(getSandboxMutationBlockReason("bun add zod")).toContain("Package-manager installs");
});
it("blocks package-manager installs in compound commands", () => {
expect(getSandboxMutationBlockReason("echo foo && npm install express")).toContain("Package-manager installs");
});
it("blocks formatters that rewrite files", () => {
expect(getSandboxMutationBlockReason("prettier --write src/")).toContain("formatters");
expect(getSandboxMutationBlockReason("biome check --write src/")).toContain("formatters");
});
it("allows safe non-mutating commands", () => {
expect(getSandboxMutationBlockReason("ls -la")).toBeNull();
expect(getSandboxMutationBlockReason("cat README.md")).toBeNull();
expect(getSandboxMutationBlockReason("grep -r foo src/")).toBeNull();
expect(getSandboxMutationBlockReason("uname -a")).toBeNull();
expect(getSandboxMutationBlockReason("npm run test")).toBeNull();
expect(getSandboxMutationBlockReason("node -e 'console.log(1)'")).toBeNull();
});
it("returns null for empty commands", () => {
expect(getSandboxMutationBlockReason("")).toBeNull();
expect(getSandboxMutationBlockReason(" ")).toBeNull();
});
});
describe("BashTool sandbox state", () => {
it("tracks cwd changes independently of sandbox mode", () => {
const root = makeTempDir("grok-bash-test-");
const nested = path.join(root, "nested");
fs.mkdirSync(nested);
const bash = new BashTool(root, { sandboxMode: "shuru" });
expect(bash.getCwd()).toBe(root);
expect(bash.getSandboxMode()).toBe("shuru");
});
it("can switch sandbox mode at runtime", () => {
const bash = new BashTool("/repo", { sandboxMode: "off" });
expect(bash.getSandboxMode()).toBe("off");
bash.setSandboxMode("shuru");
expect(bash.getSandboxMode()).toBe("shuru");
});
it("returns sandbox-aware tool description", () => {
const off = new BashTool("/repo", { sandboxMode: "off" });
const on = new BashTool("/repo", { sandboxMode: "shuru" });
expect(off.getToolDescription()).not.toContain("Shuru");
expect(on.getToolDescription()).toContain("Shuru sandbox");
expect(on.getToolDescription()).toContain("do not persist back to the host");
});
});
+119 -3
View File
@@ -4,6 +4,7 @@ import { mkdtemp, rm, stat, unlink } from "fs/promises";
import os from "os";
import path from "path";
import type { ToolResult } from "../types/index";
import type { SandboxMode } from "../utils/settings";
const MAX_TAIL_BYTES = 8_192;
const MAX_BACKGROUND_PROCESSES = 8;
@@ -20,15 +21,21 @@ export interface BackgroundProcess {
exitCode: number | null;
}
interface BashToolOptions {
sandboxMode?: SandboxMode;
}
let nextBgId = 1;
export class BashTool {
private cwd: string;
private bgProcesses = new Map<number, BackgroundProcess>();
private tmpDir: string | null = null;
private sandboxMode: SandboxMode;
constructor(initialCwd = process.cwd()) {
constructor(initialCwd = process.cwd(), options: BashToolOptions = {}) {
this.cwd = initialCwd;
this.sandboxMode = options.sandboxMode ?? "off";
}
private async ensureTmpDir(): Promise<string> {
@@ -63,6 +70,11 @@ export class BashTool {
return { success: false, error: "[Cancelled]" };
}
const prepared = this.prepareCommand(command);
if (!prepared.ok) {
return { success: false, error: prepared.error };
}
return await new Promise<ToolResult>((resolve) => {
let settled = false;
let aborted = false;
@@ -77,7 +89,7 @@ export class BashTool {
};
const child = exec(
command,
prepared.command,
{
cwd: this.cwd,
timeout,
@@ -92,6 +104,11 @@ export class BashTool {
const output = stdout + (stderr ? `\nSTDERR: ${stderr}` : "");
if (err) {
const sandboxError = this.formatSandboxRuntimeError(output, err.message);
if (sandboxError) {
finish({ success: false, error: sandboxError });
return;
}
if (output.trim()) {
finish({ success: false, error: output.trim() });
return;
@@ -150,12 +167,16 @@ export class BashTool {
}
try {
const prepared = this.prepareCommand(command);
if (!prepared.ok) {
return { success: false, output: prepared.error };
}
const tmpDir = await this.ensureTmpDir();
const id = nextBgId++;
const logPath = path.join(tmpDir, `bg-${id}.log`);
const logStream = createWriteStream(logPath, { flags: "a" });
const child = spawn("sh", ["-c", command], {
const child = spawn("sh", ["-c", prepared.command], {
cwd: this.cwd,
detached: false,
stdio: ["ignore", "pipe", "pipe"],
@@ -332,6 +353,49 @@ export class BashTool {
getCwd(): string {
return this.cwd;
}
getSandboxMode(): SandboxMode {
return this.sandboxMode;
}
setSandboxMode(mode: SandboxMode): void {
this.sandboxMode = mode;
}
getToolDescription(): string {
if (this.sandboxMode === "shuru") {
return "Execute a bash command inside a Shuru sandbox. Use for searching (grep, rg, find), git inspection, build tools, test runners, and other shell commands that should stay isolated. The current workspace is mounted inside the sandbox at /workspace, network is disabled by default unless configured, and shell-side workspace file changes do not persist back to the host in this version, so prefer the dedicated file tools for durable edits. Set background=true for long-running processes like dev servers or watchers.";
}
return "Execute a bash command. Use for searching (grep, rg, find), git, build tools, package managers, running tests, and any other shell command. Set background=true for long-running processes like dev servers, watchers, or anything that should keep running while you continue working. For file read/write/edit, prefer the dedicated file tools instead.";
}
private prepareCommand(command: string): { ok: true; command: string } | { ok: false; error: string } {
if (this.sandboxMode !== "shuru") {
return { ok: true, command };
}
const unsupportedReason = getSandboxUnsupportedReason();
if (unsupportedReason) {
return { ok: false, error: unsupportedReason };
}
const blockedReason = getSandboxMutationBlockReason(command);
if (blockedReason) {
return { ok: false, error: blockedReason };
}
return { ok: true, command: wrapCommandForShuru(this.cwd, command) };
}
private formatSandboxRuntimeError(output: string, fallbackMessage: string): string | null {
if (this.sandboxMode !== "shuru") {
return null;
}
if (output.includes("shuru: command not found") || output.includes("sh: shuru: not found")) {
return "Shuru sandbox mode is enabled, but the `shuru` CLI is not installed or not on PATH. Install Shuru or disable sandbox mode.";
}
if (output.includes("Apple Silicon") || fallbackMessage.includes("Apple Silicon")) {
return "Shuru sandbox mode requires macOS on Apple Silicon.";
}
return null;
}
}
function truncCmd(cmd: string, max: number): string {
@@ -347,3 +411,55 @@ function formatAge(start: Date): string {
const hr = Math.floor(min / 60);
return `${hr}h${min % 60}m`;
}
export function wrapCommandForShuru(cwd: string, command: string): string {
const mountArg = `${cwd}:/workspace`;
const innerCommand = `cd /workspace && ${command}`;
return `shuru run --mount ${shellQuote(mountArg)} -- sh -lc ${shellQuote(innerCommand)}`;
}
function shellQuote(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`;
}
function getSandboxUnsupportedReason(): string | null {
if (process.platform !== "darwin" || process.arch !== "arm64") {
return "Shuru sandbox mode currently requires macOS on Apple Silicon.";
}
return null;
}
export function getSandboxMutationBlockReason(command: string): string | null {
const trimmed = command.trim();
if (!trimmed) return null;
if (/\bgit\s+/.test(trimmed) && !/\bgit\s+(status|diff|log|show|rev-parse|grep|ls-files)\b/.test(trimmed)) {
return [
"Sandbox mode blocks git commands that mutate repository state because Shuru guest-side workspace changes do not persist back to the host.",
"Disable sandbox mode to run persistent git mutations on the real workspace.",
].join(" ");
}
const blockedPatterns: Array<{ pattern: RegExp; reason: string }> = [
{
pattern: /\b(?:npm|pnpm|yarn|bun)\s+(?:add|install|remove|unlink|update|upgrade)\b/,
reason:
"Package-manager installs are blocked in sandbox mode because workspace changes like lockfile updates would stay inside the Shuru guest overlay.",
},
{
pattern: /\b(?:pip|pip3)\s+install\b|\bpoetry\s+add\b|\buv\s+add\b|\bcargo\s+add\b/,
reason:
"Dependency install commands are blocked in sandbox mode because resulting workspace changes would not persist back to the host.",
},
{
pattern:
/\b(?:prettier\s+--write|eslint\b.*--fix|biome\s+check\b.*--write|ruff\s+check\b.*--fix|gofmt\s+-w|rustfmt\b|clang-format\b.*-i)\b/,
reason:
"Shell-driven formatters that rewrite files are blocked in sandbox mode because those file changes would not persist back to the host workspace.",
},
];
const matched = blockedPatterns.find(({ pattern }) => pattern.test(trimmed));
if (!matched) return null;
return `${matched.reason} Use read_file/edit_file/write_file for durable edits, or disable sandbox mode for host-persistent shell changes.`;
}
+183 -4
View File
@@ -44,6 +44,7 @@ import {
loadValidSubAgents,
type McpRemoteTransport,
type McpServerConfig,
type SandboxMode,
saveApprovedTelegramUserId,
saveMcpServers,
saveProjectSettings,
@@ -282,6 +283,7 @@ const SLASH_MENU_ITEMS: SlashMenuItem[] = [
{ id: "agents", label: "agents", description: "Manage custom sub-agents" },
{ id: "schedule", label: "schedule", description: "View scheduled runs" },
{ id: "mcp", label: "mcp", description: "Manage MCP servers" },
{ id: "sandbox", label: "sandbox", description: "Select shell sandbox mode" },
{ id: "models", label: "models", description: "Select a model" },
{ id: "new", label: "new session", description: "Start a new session" },
{ id: "commit-push", label: "commit & push", description: "Commit and push" },
@@ -330,6 +332,7 @@ const BUILTIN_TYPED_SLASH_COMMANDS = new Set([
"/clear",
"/model",
"/models",
"/sandbox",
"/remote-control",
"/mcp",
"/mcps",
@@ -345,6 +348,11 @@ const BUILTIN_TYPED_SLASH_COMMANDS = new Set([
"/commit-pr",
]);
const SANDBOX_OPTIONS: Array<{ id: SandboxMode; label: string; description: string }> = [
{ id: "off", label: "Off", description: "Run shell commands directly on the host" },
{ id: "shuru", label: "Shuru", description: "Run shell commands inside a Shuru sandbox" },
];
function parseCustomSubagentSlashCommand(
cmd: string,
subagents: CustomSubagentConfig[],
@@ -397,6 +405,7 @@ export interface AppStartupConfig {
apiKey: string | undefined;
baseURL: string;
model: string;
sandboxMode: SandboxMode;
maxToolRounds: number;
}
@@ -429,10 +438,13 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
const [isProcessing, setIsProcessing] = useState(false);
const [liveTurnSourceLabel, setLiveTurnSourceLabel] = useState<string | null>(null);
const [model, setModel] = useState(agent.getModel());
const [sandboxMode, setSandboxModeState] = useState<SandboxMode>(agent.getSandboxMode());
const [mode, setModeState] = useState<AgentMode>(agent.getMode());
const [showModelPicker, setShowModelPicker] = useState(false);
const [modelPickerIndex, setModelPickerIndex] = useState(0);
const [modelSearchQuery, setModelSearchQuery] = useState("");
const [showSandboxPicker, setShowSandboxPicker] = useState(false);
const [sandboxPickerIndex, setSandboxPickerIndex] = useState(0);
const [activeToolCalls, setActiveToolCalls] = useState<ToolCall[]>([]);
const [sessionTitle, setSessionTitle] = useState<string | null>(() => agent.getSessionTitle());
const [sessionId, setSessionId] = useState<string | null>(() => agent.getSessionId());
@@ -572,6 +584,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
)
: MODELS;
const filteredModelIds = filteredModels.map((m) => m.id);
const currentSandboxIndex = SANDBOX_OPTIONS.findIndex((option) => option.id === sandboxMode);
const filteredSlashItems = slashSearchQuery
? SLASH_MENU_ITEMS.filter(
(item) =>
@@ -595,6 +608,24 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
saveMcpServers(servers);
}, []);
const applySandboxMode = useCallback(
(next: SandboxMode) => {
agent.setSandboxMode(next);
for (const telegramAgent of telegramAgentsRef.current.values()) {
telegramAgent.setSandboxMode(next);
}
setSandboxModeState(next);
saveProjectSettings({ sandboxMode: next });
saveUserSettings({ sandboxMode: next });
},
[agent],
);
const openSandboxPicker = useCallback(() => {
setSandboxPickerIndex(Math.max(0, currentSandboxIndex));
setShowSandboxPicker(true);
}, [currentSandboxIndex]);
const setReasoningEfforts = useCallback((next: Record<string, ReasoningEffort>) => {
setReasoningEffortByModel(next);
saveUserSettings({ reasoningEffortByModel: next });
@@ -1275,6 +1306,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
const sid = u.telegram?.sessionsByUserId?.[String(userId)];
const a = new Agent(apiKey, startupConfig.baseURL, startupConfig.model, startupConfig.maxToolRounds, {
session: sid,
sandboxMode,
});
if (!sid && a.getSessionId()) {
saveUserSettings({
@@ -1291,7 +1323,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
map.set(userId, a);
return a;
},
[startupConfig, wireTelegramAgentUi],
[sandboxMode, startupConfig, wireTelegramAgentUi],
);
const appendTelegramUserMessage = useCallback(
@@ -1830,6 +1862,10 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
setModelSearchQuery("");
return true;
}
if (c === "/sandbox") {
openSandboxPicker();
return true;
}
if (c === "/remote-control") {
setConnectModalIndex(0);
setShowConnectModal(true);
@@ -1880,7 +1916,16 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
}
return false;
},
[handleExit, openAgentsModal, openMcpModal, openScheduleModal, processMessage, resetToNewSession, subAgents],
[
handleExit,
openAgentsModal,
openMcpModal,
openSandboxPicker,
openScheduleModal,
processMessage,
resetToNewSession,
subAgents,
],
);
const handleSlashMenuSelect = useCallback(
@@ -1896,6 +1941,9 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
setModelPickerIndex(0);
setModelSearchQuery("");
break;
case "sandbox":
openSandboxPicker();
break;
case "remote-control":
setConnectModalIndex(0);
setShowConnectModal(true);
@@ -1943,7 +1991,16 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
break;
}
},
[agent, handleExit, openAgentsModal, openMcpModal, openScheduleModal, processMessage, resetToNewSession],
[
agent,
handleExit,
openAgentsModal,
openMcpModal,
openSandboxPicker,
openScheduleModal,
processMessage,
resetToNewSession,
],
);
const blockPrompt =
@@ -1951,6 +2008,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
showTelegramTokenModal ||
showTelegramPairModal ||
showMcpModal ||
showSandboxPicker ||
showScheduleModal ||
showAgentsModal ||
showAgentsEditor;
@@ -2467,6 +2525,29 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
}
return;
}
if (showSandboxPicker) {
if (isEscapeKey(key)) {
setShowSandboxPicker(false);
return;
}
if (key.name === "up") {
setSandboxPickerIndex((i) => Math.max(0, i - 1));
return;
}
if (key.name === "down") {
setSandboxPickerIndex((i) => Math.min(SANDBOX_OPTIONS.length - 1, i + 1));
return;
}
if (key.name === "return") {
const selected = SANDBOX_OPTIONS[sandboxPickerIndex];
if (selected) {
applySandboxMode(selected.id);
}
setShowSandboxPicker(false);
return;
}
return;
}
if (isEscapeKey(key) && interruptActiveRun(key)) {
return;
@@ -2572,8 +2653,11 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
planTabCount,
pqs,
removeEditingSubagent,
applySandboxMode,
sandboxPickerIndex,
showModelPicker,
showPlanPanel,
showSandboxPicker,
showSlashMenu,
slashMenuIndex,
submitApiKey,
@@ -2721,6 +2805,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
inputRef={inputRef}
isProcessing={isProcessing}
showModelPicker={showModelPicker}
showSandboxPicker={showSandboxPicker}
showSlashMenu={showSlashMenu}
showPlanQuestions={showPlanPanel}
showApiKeyModal={showApiKeyModal}
@@ -2737,6 +2822,11 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
/>
</box>
</box>
<box paddingLeft={2} paddingRight={2} paddingBottom={1} flexDirection="row" flexShrink={0}>
<text fg={t.textDim}>{agent.getCwd().replace(os.homedir(), "~")}</text>
{sandboxMode === "shuru" ? <text fg="#f97316">{" · sandbox"}</text> : null}
<box flexGrow={1} />
</box>
</box>
) : (
/* ── Home ───────────────────────────────────────── */
@@ -2753,6 +2843,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
inputRef={inputRef}
isProcessing={isProcessing}
showModelPicker={showModelPicker}
showSandboxPicker={showSandboxPicker}
showSlashMenu={showSlashMenu}
showPlanQuestions={showPlanPanel}
showApiKeyModal={showApiKeyModal}
@@ -2772,6 +2863,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
</box>
<box paddingLeft={2} paddingRight={2} paddingBottom={1} flexDirection="row" flexShrink={0}>
<text fg={t.textDim}>{agent.getCwd().replace(os.homedir(), "~")}</text>
{sandboxMode === "shuru" ? <text fg="#f97316">{" · sandbox"}</text> : null}
<box flexGrow={1} />
<text fg={t.textDim}>{"v1.0.0"}</text>
</box>
@@ -2876,6 +2968,15 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
reasoningEffortByModel={reasoningEffortByModel}
/>
)}
{showSandboxPicker && (
<SandboxPickerModal
t={t}
currentMode={sandboxMode}
selectedIndex={sandboxPickerIndex}
width={width}
height={height}
/>
)}
{showConnectModal && (
<ConnectModal
t={t}
@@ -2972,6 +3073,7 @@ function PromptBox({
inputRef,
isProcessing,
showModelPicker,
showSandboxPicker,
showSlashMenu,
showPlanQuestions,
showApiKeyModal,
@@ -2991,6 +3093,7 @@ function PromptBox({
inputRef: React.RefObject<TextareaRenderable | null>;
isProcessing: boolean;
showModelPicker: boolean;
showSandboxPicker: boolean;
showSlashMenu: boolean;
showPlanQuestions: boolean;
showApiKeyModal: boolean;
@@ -3055,7 +3158,14 @@ function PromptBox({
<box flexGrow={1}>
<textarea
ref={inputRef}
focused={!showModelPicker && !showSlashMenu && !showPlanQuestions && !showApiKeyModal && !blockPrompt}
focused={
!showModelPicker &&
!showSandboxPicker &&
!showSlashMenu &&
!showPlanQuestions &&
!showApiKeyModal &&
!blockPrompt
}
placeholder={isProcessing ? "Queue a follow-up... (esc to interrupt)" : placeholder || "Message Grok..."}
textColor={t.text}
backgroundColor={t.backgroundElement}
@@ -4287,6 +4397,75 @@ function ModelPickerModal({
);
}
function SandboxPickerModal({
t,
currentMode,
selectedIndex,
width,
height,
}: {
t: Theme;
currentMode: SandboxMode;
selectedIndex: number;
width: number;
height: number;
}) {
const panelHeight = Math.min(SANDBOX_OPTIONS.length + 5, Math.floor(height * 0.4));
const top = bottomAlignedModalTop(height, panelHeight);
const overlayBg = "#000000cc" as string;
return (
<box
position="absolute"
left={0}
top={0}
width={width}
height={height}
alignItems="center"
paddingTop={top}
backgroundColor={overlayBg}
>
<box
width={Math.min(64, width - 6)}
height={panelHeight}
backgroundColor={t.backgroundPanel}
paddingTop={1}
paddingBottom={1}
flexDirection="column"
>
<box flexShrink={0} flexDirection="row" justifyContent="space-between" paddingLeft={2} paddingRight={2}>
<text fg={t.primary}>
<b>{"Select sandbox mode"}</b>
</text>
<text fg={t.textMuted}>{"esc"}</text>
</box>
<scrollbox flexGrow={1} minHeight={0}>
{SANDBOX_OPTIONS.map((option, idx) => {
const selected = idx === selectedIndex;
const current = option.id === currentMode;
return (
<box
key={option.id}
backgroundColor={selected ? t.selectedBg : undefined}
paddingLeft={2}
paddingRight={2}
width="100%"
>
<box width="100%" flexDirection="row" justifyContent="space-between">
<text fg={current ? t.accent : selected ? t.selected : t.text}>{option.label}</text>
<text fg={selected ? t.primary : t.textMuted}>{option.description}</text>
</box>
</box>
);
})}
</scrollbox>
<box flexShrink={0} paddingLeft={2} paddingRight={2} paddingTop={1}>
<text fg={t.textMuted}>{"enter select esc close"}</text>
</box>
</box>
</box>
);
}
/* ── Helpers ──────────────────────────────────────────────────── */
function isEscapeKey(key: KeyEvent): boolean {
+17
View File
@@ -6,6 +6,7 @@ import type { ReasoningEffort } from "../types/index";
export type TelegramStreamingMode = "off" | "partial";
export type TelegramAudioInputEngine = "whisper.cpp";
export type SandboxMode = "off" | "shuru";
export const DEFAULT_TELEGRAM_AUDIO_INPUT_BINARY = "whisper-cli";
export const DEFAULT_TELEGRAM_AUDIO_INPUT_MODEL = "tiny.en";
@@ -107,6 +108,7 @@ export function loadValidSubAgents(): CustomSubagentConfig[] {
export interface UserSettings {
apiKey?: string;
defaultModel?: string;
sandboxMode?: SandboxMode;
reasoningEffortByModel?: Record<string, ReasoningEffort>;
telegram?: TelegramSettings;
mcp?: McpSettings;
@@ -115,6 +117,7 @@ export interface UserSettings {
export interface ProjectSettings {
model?: string;
sandboxMode?: SandboxMode;
}
const USER_DIR = path.join(os.homedir(), ".grok");
@@ -151,6 +154,7 @@ export function saveUserSettings(partial: Partial<UserSettings>): void {
...partial,
...(partial.apiKey !== undefined ? { apiKey: partial.apiKey } : {}),
...(partial.defaultModel !== undefined ? { defaultModel: normalizeModelId(partial.defaultModel) } : {}),
...(partial.sandboxMode !== undefined ? { sandboxMode: normalizeSandboxMode(partial.sandboxMode) } : {}),
...(partial.reasoningEffortByModel !== undefined
? {
reasoningEffortByModel: Object.fromEntries(
@@ -211,6 +215,7 @@ export function saveProjectSettings(partial: Partial<ProjectSettings>): void {
...current,
...partial,
...(partial.model !== undefined ? { model: normalizeModelId(partial.model) } : {}),
...(partial.sandboxMode !== undefined ? { sandboxMode: normalizeSandboxMode(partial.sandboxMode) } : {}),
});
}
@@ -230,6 +235,18 @@ export function getCurrentModel(): string {
return user.defaultModel ? normalizeModelId(user.defaultModel) : DEFAULT_MODEL;
}
export function normalizeSandboxMode(value: unknown): SandboxMode {
return value === "shuru" ? "shuru" : "off";
}
export function getCurrentSandboxMode(): SandboxMode {
const project = loadProjectSettings();
if (project.sandboxMode) return normalizeSandboxMode(project.sandboxMode);
const user = loadUserSettings();
if (user.sandboxMode) return normalizeSandboxMode(user.sandboxMode);
return "off";
}
export function getReasoningEffortForModel(modelId: string): ReasoningEffort | undefined {
const normalizedModelId = normalizeModelId(modelId);
const savedEfforts = loadUserSettings().reasoningEffortByModel ?? {};