Skip to content

Commit 5043b07

Browse files
zxyasfashomanp
andauthored
feat: prompt for workspace sandbox trust (#291)
* feat: prompt for workspace sandbox trust * fix: preserve sandbox default for trust prompt --------- Co-authored-by: Ismail Pelaseyed <homanp@gmail.com>
1 parent 8896dfc commit 5043b07

4 files changed

Lines changed: 278 additions & 0 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,8 @@ Grok CLI can run shell commands inside a [Shuru](https://github.com/superhq-ai/s
338338

339339
Enable it with `--sandbox` on the CLI, or toggle it from the TUI with `/sandbox`.
340340

341+
On the first interactive run in a new directory, Grok asks whether to remember sandbox or host mode for that workspace and stores the choice in `~/.grok/workspace-trust.json`. Explicit `--sandbox` / `--no-sandbox` flags and non-interactive commands keep their current behavior.
342+
341343
When sandbox mode is active you can configure:
342344

343345
- **Network** — off by default; enable with `--allow-net`, restrict with `--allow-host`

src/index.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
#!/usr/bin/env bun
22
import { InvalidArgumentError, program } from "commander";
33
import * as dotenv from "dotenv";
4+
import readline from "readline";
45
import packageJson from "../package.json";
56
import { Agent } from "./agent/agent";
67
import { completeDelegation, failDelegation, loadDelegation } from "./agent/delegations";
@@ -29,6 +30,12 @@ import {
2930
saveUserSettings,
3031
} from "./utils/settings";
3132
import { runUpdate } from "./utils/update-checker";
33+
import {
34+
getWorkspaceTrustDecision,
35+
isShuruSandboxSupported,
36+
resolveWorkspaceTrustPromptAnswer,
37+
saveWorkspaceTrustDecision,
38+
} from "./utils/workspace-trust";
3239
import { buildVerifyPrompt, getVerifyCliError } from "./verify/entrypoint";
3340

3441
dotenv.config();
@@ -178,6 +185,69 @@ function resolveCliSandboxMode(value: string | boolean | undefined): SandboxMode
178185
return undefined;
179186
}
180187

188+
function hasExplicitSandboxOption(options: CliOptions): boolean {
189+
return options.sandbox === true || options.sandbox === false;
190+
}
191+
192+
async function promptWorkspaceTrust(
193+
cwd: string,
194+
sandboxSupported = isShuruSandboxSupported(),
195+
): Promise<ReturnType<typeof resolveWorkspaceTrustPromptAnswer>> {
196+
const message = sandboxSupported
197+
? [
198+
"",
199+
`Grok has not been run in ${cwd} before.`,
200+
"",
201+
"Sandbox mode isolates agent shell commands in a Shuru microVM so",
202+
"untrusted repos cannot touch your host filesystem or network by default.",
203+
"",
204+
"Run grok in sandbox mode for this directory?",
205+
"",
206+
" [Y] Yes, always in sandbox",
207+
" [n] No, run on host",
208+
" [s] Yes, this session only",
209+
"",
210+
"Choice [Y/n/s]: ",
211+
].join("\n")
212+
: [
213+
"",
214+
`Grok has not been run in ${cwd} before.`,
215+
"",
216+
"Sandbox mode is only available on macOS Apple Silicon in this version.",
217+
"",
218+
"Run grok directly on the host for this directory?",
219+
"",
220+
" [Y] Yes, remember host mode",
221+
" [s] Yes, this session only",
222+
"",
223+
"Choice [Y/s]: ",
224+
].join("\n");
225+
226+
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
227+
try {
228+
const answer = await new Promise<string>((resolve) => {
229+
rl.question(message, resolve);
230+
});
231+
return resolveWorkspaceTrustPromptAnswer(answer, sandboxSupported);
232+
} finally {
233+
rl.close();
234+
}
235+
}
236+
237+
async function resolveWorkspaceTrustSandboxMode(sandboxMode: SandboxMode, options: CliOptions): Promise<SandboxMode> {
238+
if (sandboxMode === "shuru" || hasExplicitSandboxOption(options)) return sandboxMode;
239+
if (process.env.GROK_TRUST_WORKSPACE) return sandboxMode;
240+
241+
const cwd = process.cwd();
242+
const saved = getWorkspaceTrustDecision(cwd);
243+
if (saved) return saved;
244+
if (!process.stdin.isTTY || !process.stderr.isTTY) return sandboxMode;
245+
246+
const decision = await promptWorkspaceTrust(cwd);
247+
if (decision.remember) saveWorkspaceTrustDecision(cwd, decision.sandboxMode);
248+
return decision.sandboxMode;
249+
}
250+
181251
async function runBackgroundDelegation(jobPath: string, options: CliOptions) {
182252
let output = "";
183253
let agent: Agent | undefined;
@@ -353,6 +423,7 @@ program
353423
}
354424

355425
const initialMessage = message.length > 0 ? message.join(" ") : undefined;
426+
config.sandboxMode = await resolveWorkspaceTrustSandboxMode(config.sandboxMode, options);
356427
await startInteractive(
357428
config.apiKey,
358429
config.baseURL,

src/utils/workspace-trust.test.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import fs from "fs";
2+
import os from "os";
3+
import path from "path";
4+
import { afterEach, describe, expect, it } from "vitest";
5+
import {
6+
getWorkspaceTrustDecision,
7+
getWorkspaceTrustKey,
8+
getWorkspaceTrustPath,
9+
isShuruSandboxSupported,
10+
loadWorkspaceTrustStore,
11+
resolveWorkspaceTrustPromptAnswer,
12+
saveWorkspaceTrustDecision,
13+
WORKSPACE_TRUST_FILENAME,
14+
} from "./workspace-trust";
15+
16+
let tempDirs: string[] = [];
17+
18+
afterEach(() => {
19+
for (const dir of tempDirs) fs.rmSync(dir, { recursive: true, force: true });
20+
tempDirs = [];
21+
});
22+
23+
function createTempDir(prefix: string): string {
24+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
25+
tempDirs.push(dir);
26+
return dir;
27+
}
28+
29+
describe("workspace trust settings", () => {
30+
it("detects Shuru support only on macOS Apple Silicon", () => {
31+
expect(isShuruSandboxSupported("darwin", "arm64")).toBe(true);
32+
expect(isShuruSandboxSupported("darwin", "x64")).toBe(false);
33+
expect(isShuruSandboxSupported("linux", "arm64")).toBe(false);
34+
expect(isShuruSandboxSupported("win32", "x64")).toBe(false);
35+
});
36+
37+
it("only disables supported sandbox mode on explicit no answers", () => {
38+
expect(resolveWorkspaceTrustPromptAnswer("", true)).toEqual({ sandboxMode: "shuru", remember: true });
39+
expect(resolveWorkspaceTrustPromptAnswer("y", true)).toEqual({ sandboxMode: "shuru", remember: true });
40+
expect(resolveWorkspaceTrustPromptAnswer("typo", true)).toEqual({ sandboxMode: "shuru", remember: true });
41+
expect(resolveWorkspaceTrustPromptAnswer("n", true)).toEqual({ sandboxMode: "off", remember: true });
42+
expect(resolveWorkspaceTrustPromptAnswer("no", true)).toEqual({ sandboxMode: "off", remember: true });
43+
expect(resolveWorkspaceTrustPromptAnswer("s", true)).toEqual({ sandboxMode: "shuru", remember: false });
44+
});
45+
46+
it("keeps unsupported sandbox prompt decisions in host mode", () => {
47+
expect(resolveWorkspaceTrustPromptAnswer("", false)).toEqual({ sandboxMode: "off", remember: true });
48+
expect(resolveWorkspaceTrustPromptAnswer("typo", false)).toEqual({ sandboxMode: "off", remember: true });
49+
expect(resolveWorkspaceTrustPromptAnswer("s", false)).toEqual({ sandboxMode: "off", remember: false });
50+
});
51+
52+
it("stores sandbox decisions by canonical workspace path", () => {
53+
const homeDir = createTempDir("grok-trust-home-");
54+
const workspace = createTempDir("grok-trust-workspace-");
55+
const trustPath = getWorkspaceTrustPath(homeDir);
56+
57+
saveWorkspaceTrustDecision(workspace, "shuru", trustPath);
58+
59+
expect(getWorkspaceTrustDecision(workspace, trustPath)).toBe("shuru");
60+
expect(loadWorkspaceTrustStore(trustPath).workspaces[getWorkspaceTrustKey(workspace)]?.sandboxMode).toBe("shuru");
61+
expect(path.basename(trustPath)).toBe(WORKSPACE_TRUST_FILENAME);
62+
});
63+
64+
it("preserves existing entries when saving another workspace", () => {
65+
const homeDir = createTempDir("grok-trust-home-");
66+
const firstWorkspace = createTempDir("grok-trust-first-");
67+
const secondWorkspace = createTempDir("grok-trust-second-");
68+
const trustPath = getWorkspaceTrustPath(homeDir);
69+
70+
saveWorkspaceTrustDecision(firstWorkspace, "shuru", trustPath);
71+
saveWorkspaceTrustDecision(secondWorkspace, "off", trustPath);
72+
73+
expect(getWorkspaceTrustDecision(firstWorkspace, trustPath)).toBe("shuru");
74+
expect(getWorkspaceTrustDecision(secondWorkspace, trustPath)).toBe("off");
75+
});
76+
77+
it("ignores malformed files and entries", () => {
78+
const homeDir = createTempDir("grok-trust-home-");
79+
const workspace = createTempDir("grok-trust-workspace-");
80+
const trustPath = getWorkspaceTrustPath(homeDir);
81+
fs.mkdirSync(path.dirname(trustPath), { recursive: true });
82+
fs.writeFileSync(
83+
trustPath,
84+
JSON.stringify({
85+
workspaces: {
86+
[getWorkspaceTrustKey(workspace)]: { sandboxMode: "invalid" },
87+
},
88+
}),
89+
);
90+
91+
expect(getWorkspaceTrustDecision(workspace, trustPath)).toBeNull();
92+
expect(loadWorkspaceTrustStore(path.join(homeDir, ".grok", "broken.json"))).toEqual({
93+
version: 1,
94+
workspaces: {},
95+
});
96+
});
97+
});

src/utils/workspace-trust.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import * as fs from "fs";
2+
import * as os from "os";
3+
import * as path from "path";
4+
import type { SandboxMode } from "./settings";
5+
6+
interface WorkspaceTrustEntry {
7+
sandboxMode: SandboxMode;
8+
updatedAt: string;
9+
}
10+
11+
interface WorkspaceTrustStore {
12+
version: 1;
13+
workspaces: Record<string, WorkspaceTrustEntry>;
14+
}
15+
16+
export interface WorkspaceTrustPromptDecision {
17+
sandboxMode: SandboxMode;
18+
remember: boolean;
19+
}
20+
21+
export const WORKSPACE_TRUST_FILENAME = "workspace-trust.json";
22+
23+
export function isShuruSandboxSupported(platform = process.platform, arch = process.arch): boolean {
24+
return platform === "darwin" && arch === "arm64";
25+
}
26+
27+
function isRecord(value: unknown): value is Record<string, unknown> {
28+
return typeof value === "object" && value !== null && !Array.isArray(value);
29+
}
30+
31+
function normalizeTrustEntry(value: unknown): WorkspaceTrustEntry | null {
32+
if (!isRecord(value)) return null;
33+
const sandboxMode = value.sandboxMode === "shuru" ? "shuru" : value.sandboxMode === "off" ? "off" : null;
34+
if (!sandboxMode) return null;
35+
return {
36+
sandboxMode,
37+
updatedAt: typeof value.updatedAt === "string" ? value.updatedAt : "",
38+
};
39+
}
40+
41+
export function getWorkspaceTrustPath(homeDir = os.homedir()): string {
42+
return path.join(homeDir, ".grok", WORKSPACE_TRUST_FILENAME);
43+
}
44+
45+
export function getWorkspaceTrustKey(cwd = process.cwd()): string {
46+
try {
47+
return fs.realpathSync.native(cwd);
48+
} catch {
49+
return path.resolve(cwd);
50+
}
51+
}
52+
53+
export function resolveWorkspaceTrustPromptAnswer(
54+
answer: string,
55+
sandboxSupported: boolean,
56+
): WorkspaceTrustPromptDecision {
57+
const normalized = answer.trim().toLowerCase();
58+
if (normalized === "s" || normalized === "session") {
59+
return { sandboxMode: sandboxSupported ? "shuru" : "off", remember: false };
60+
}
61+
62+
if (sandboxSupported) {
63+
if (normalized === "n" || normalized === "no") {
64+
return { sandboxMode: "off", remember: true };
65+
}
66+
return { sandboxMode: "shuru", remember: true };
67+
}
68+
69+
return { sandboxMode: "off", remember: true };
70+
}
71+
72+
export function loadWorkspaceTrustStore(trustPath = getWorkspaceTrustPath()): WorkspaceTrustStore {
73+
try {
74+
if (!fs.existsSync(trustPath)) return { version: 1, workspaces: {} };
75+
const parsed = JSON.parse(fs.readFileSync(trustPath, "utf-8")) as unknown;
76+
const rawWorkspaces = isRecord(parsed) && isRecord(parsed.workspaces) ? parsed.workspaces : {};
77+
const workspaces: Record<string, WorkspaceTrustEntry> = {};
78+
for (const [workspace, entry] of Object.entries(rawWorkspaces)) {
79+
const normalized = normalizeTrustEntry(entry);
80+
if (normalized) workspaces[workspace] = normalized;
81+
}
82+
return { version: 1, workspaces };
83+
} catch {
84+
return { version: 1, workspaces: {} };
85+
}
86+
}
87+
88+
export function getWorkspaceTrustDecision(
89+
cwd = process.cwd(),
90+
trustPath = getWorkspaceTrustPath(),
91+
): SandboxMode | null {
92+
const store = loadWorkspaceTrustStore(trustPath);
93+
return store.workspaces[getWorkspaceTrustKey(cwd)]?.sandboxMode ?? null;
94+
}
95+
96+
export function saveWorkspaceTrustDecision(
97+
cwd: string,
98+
sandboxMode: SandboxMode,
99+
trustPath = getWorkspaceTrustPath(),
100+
): void {
101+
const store = loadWorkspaceTrustStore(trustPath);
102+
store.workspaces[getWorkspaceTrustKey(cwd)] = {
103+
sandboxMode,
104+
updatedAt: new Date().toISOString(),
105+
};
106+
fs.mkdirSync(path.dirname(trustPath), { recursive: true, mode: 0o700 });
107+
fs.writeFileSync(trustPath, JSON.stringify(store, null, 2), { mode: 0o600 });
108+
}

0 commit comments

Comments
 (0)