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

+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 ?? {};