feat: add /plan command and allow gh commands in plan mode (#544)
Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -68,9 +68,17 @@ export const commands: Record<string, Command> = {
|
||||
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<string, Command> = {
|
||||
},
|
||||
"/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
|
||||
|
||||
@@ -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<string, Set<string> | 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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user