diff --git a/src/cli/App.tsx b/src/cli/App.tsx index bb0a89a..0126b89 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -4818,6 +4818,27 @@ export default function App({ return { submitted: true }; } + // Special handling for /plan command - enter plan mode + if (trimmed === "/plan") { + // Generate plan file path and enter plan mode + const planPath = generatePlanFilePath(); + permissionMode.setPlanFilePath(planPath); + permissionMode.setMode("plan"); + setUiPermissionMode("plan"); + + // Add status message to transcript + const statusId = uid("status"); + buffersRef.current.byId.set(statusId, { + kind: "status", + id: statusId, + lines: [`Plan mode enabled. Plan file: ${planPath}`], + }); + buffersRef.current.order.push(statusId); + refreshDerived(); + + return { submitted: true }; + } + // Special handling for /init command - initialize agent memory if (trimmed === "/init") { // Check for pending approvals before sending diff --git a/src/cli/commands/registry.ts b/src/cli/commands/registry.ts index 15184f2..538d059 100644 --- a/src/cli/commands/registry.ts +++ b/src/cli/commands/registry.ts @@ -68,9 +68,17 @@ export const commands: Record = { return "Opening message search..."; }, }, + "/plan": { + desc: "Enter plan mode", + order: 17, + handler: () => { + // Handled specially in App.tsx + return "Entering plan mode..."; + }, + }, "/clear": { desc: "Start a new conversation (keep agent memory)", - order: 17, + order: 18, handler: () => { // Handled specially in App.tsx to create new conversation return "Starting new conversation..."; @@ -78,7 +86,7 @@ export const commands: Record = { }, "/clear-messages": { desc: "Reset all agent messages (destructive)", - order: 18, + order: 19, hidden: true, // Advanced command, not shown in autocomplete handler: () => { // Handled specially in App.tsx to reset agent messages diff --git a/src/permissions/readOnlyShell.ts b/src/permissions/readOnlyShell.ts index 1cd97cc..ab296f4 100644 --- a/src/permissions/readOnlyShell.ts +++ b/src/permissions/readOnlyShell.ts @@ -61,6 +61,19 @@ const SAFE_GIT_SUBCOMMANDS = new Set([ "remote", ]); +// gh CLI read-only commands: category -> allowed actions +// null means any action is allowed for that category +const SAFE_GH_COMMANDS: Record | null> = { + pr: new Set(["list", "status", "checks", "diff", "view"]), + issue: new Set(["list", "status", "view"]), + repo: new Set(["list", "view", "gitignore", "license"]), + run: new Set(["list", "view", "watch", "download"]), + release: new Set(["list", "view", "download"]), + search: null, // all search subcommands are read-only + api: null, // usually GET requests for exploration + status: null, // top-level command, no action needed +}; + /** * Read-only bundled skill scripts that are safe to execute without approval. * Only scripts from the bundled searching-messages skill are allowed. @@ -168,6 +181,29 @@ function isSafeSegment(segment: string): boolean { } return SAFE_GIT_SUBCOMMANDS.has(subcommand); } + if (command === "gh") { + const category = tokens[1]; + if (!category) { + return false; + } + if (!(category in SAFE_GH_COMMANDS)) { + return false; + } + const allowedActions = SAFE_GH_COMMANDS[category]; + // null means any action is allowed (e.g., gh search, gh api, gh status) + if (allowedActions === null) { + return true; + } + // undefined means category not in map (shouldn't happen after 'in' check) + if (allowedActions === undefined) { + return false; + } + const action = tokens[2]; + if (!action) { + return false; + } + return allowedActions.has(action); + } if (command === "find") { return !/-delete|\s-exec\b/.test(segment); } diff --git a/src/tests/permissions/readOnlyShell.test.ts b/src/tests/permissions/readOnlyShell.test.ts index ca198c1..0427a97 100644 --- a/src/tests/permissions/readOnlyShell.test.ts +++ b/src/tests/permissions/readOnlyShell.test.ts @@ -61,6 +61,70 @@ describe("isReadOnlyShellCommand", () => { }); }); + describe("gh commands", () => { + test("allows read-only gh pr commands", () => { + expect(isReadOnlyShellCommand("gh pr list")).toBe(true); + expect(isReadOnlyShellCommand("gh pr view 123")).toBe(true); + expect(isReadOnlyShellCommand("gh pr diff 123")).toBe(true); + expect(isReadOnlyShellCommand("gh pr checks 123")).toBe(true); + expect(isReadOnlyShellCommand("gh pr status")).toBe(true); + expect( + isReadOnlyShellCommand( + "gh pr list --state merged --limit 20 --json number,title", + ), + ).toBe(true); + }); + + test("blocks write gh pr commands", () => { + expect(isReadOnlyShellCommand("gh pr create")).toBe(false); + expect(isReadOnlyShellCommand("gh pr merge 123")).toBe(false); + expect(isReadOnlyShellCommand("gh pr close 123")).toBe(false); + expect(isReadOnlyShellCommand("gh pr edit 123")).toBe(false); + }); + + test("allows read-only gh issue commands", () => { + expect(isReadOnlyShellCommand("gh issue list")).toBe(true); + expect(isReadOnlyShellCommand("gh issue view 123")).toBe(true); + expect(isReadOnlyShellCommand("gh issue status")).toBe(true); + }); + + test("blocks write gh issue commands", () => { + expect(isReadOnlyShellCommand("gh issue create")).toBe(false); + expect(isReadOnlyShellCommand("gh issue close 123")).toBe(false); + }); + + test("allows gh search commands", () => { + expect(isReadOnlyShellCommand("gh search repos letta")).toBe(true); + expect(isReadOnlyShellCommand("gh search issues bug")).toBe(true); + expect(isReadOnlyShellCommand("gh search prs fix")).toBe(true); + }); + + test("allows gh api commands", () => { + expect(isReadOnlyShellCommand("gh api repos/owner/repo")).toBe(true); + expect( + isReadOnlyShellCommand("gh api repos/owner/repo/pulls/123/comments"), + ).toBe(true); + }); + + test("allows gh status command", () => { + expect(isReadOnlyShellCommand("gh status")).toBe(true); + }); + + test("blocks unsafe gh categories", () => { + expect(isReadOnlyShellCommand("gh auth login")).toBe(false); + expect(isReadOnlyShellCommand("gh config set")).toBe(false); + expect(isReadOnlyShellCommand("gh secret set")).toBe(false); + }); + + test("blocks bare gh", () => { + expect(isReadOnlyShellCommand("gh")).toBe(false); + }); + + test("blocks gh with unknown category", () => { + expect(isReadOnlyShellCommand("gh unknown")).toBe(false); + }); + }); + describe("find command", () => { test("allows safe find", () => { expect(isReadOnlyShellCommand("find . -name '*.js'")).toBe(true);