From 1fc3b516187b94aaf048a182639502c33925c47e Mon Sep 17 00:00:00 2001 From: tt-a1i Date: Thu, 20 Aug 2026 21:52:18 +0800 Subject: [PATCH 1/5] fix(plan-mode): always offer an exit from plan mode --- extensions/plan-mode/index.test.ts | 151 +++++++++++++++++++++++++++++ extensions/plan-mode/index.ts | 60 ++++++++---- 2 files changed, 192 insertions(+), 19 deletions(-) diff --git a/extensions/plan-mode/index.test.ts b/extensions/plan-mode/index.test.ts index 179fe549..6bc632bc 100644 --- a/extensions/plan-mode/index.test.ts +++ b/extensions/plan-mode/index.test.ts @@ -274,6 +274,157 @@ test("Plan Ready keeps the write gate closed until the user prepares an editable ); }); +test("bare /plan offers to turn planning mode off", async () => { + let commandHandler: + | ((args: string, ctx: ExtensionCommandContext) => Promise) + | undefined; + let toolHandler: + | ((event: { toolName: string }) => { block?: boolean } | void) + | undefined; + const entries: unknown[] = []; + const pi = { + registerTool() {}, + getActiveTools: () => [], + setActiveTools() {}, + registerCommand( + _name: string, + command: { + handler: (args: string, ctx: ExtensionCommandContext) => Promise; + }, + ) { + commandHandler = command.handler; + }, + on(event: string, handler: unknown) { + if (event === "tool_call") toolHandler = handler as typeof toolHandler; + }, + events: { emit() {} }, + appendEntry(_type: string, state: unknown) { + entries.push(state); + }, + sendMessage() {}, + } as unknown as ExtensionAPI; + const ctx = { + hasUI: true, + ui: { + setStatus() {}, + notify() {}, + select: async () => PLAN_READY_ACTIONS.off, + }, + } as unknown as ExtensionCommandContext; + + planMode(pi); + assert.ok(commandHandler); + assert.ok(toolHandler); + await commandHandler("", ctx); + await commandHandler("", ctx); + + assert.equal(toolHandler({ toolName: "write" }), undefined); + assert.deepEqual(entries.at(-1), { version: 1, status: "inactive" }); +}); + +test("bare /plan offers to turn ready mode off", async () => { + let commandHandler: + | ((args: string, ctx: ExtensionCommandContext) => Promise) + | undefined; + let toolHandler: + | ((event: { toolName: string }) => { block?: boolean } | void) + | undefined; + let readyExecute: PlanReadyExecute | undefined; + const entries: unknown[] = []; + const pi = { + registerTool(definition: { name: string; execute: PlanReadyExecute }) { + if (definition.name === "plan_ready") readyExecute = definition.execute; + }, + getActiveTools: () => [], + setActiveTools() {}, + registerCommand( + _name: string, + command: { + handler: (args: string, ctx: ExtensionCommandContext) => Promise; + }, + ) { + commandHandler = command.handler; + }, + on(event: string, handler: unknown) { + if (event === "tool_call") toolHandler = handler as typeof toolHandler; + }, + events: { emit() {} }, + appendEntry(_type: string, state: unknown) { + entries.push(state); + }, + sendMessage() {}, + } as unknown as ExtensionAPI; + const ctx = { + hasUI: true, + ui: { + setStatus() {}, + notify() {}, + select: async () => PLAN_READY_ACTIONS.off, + }, + } as unknown as ExtensionCommandContext; + + planMode(pi); + assert.ok(commandHandler); + assert.ok(readyExecute); + assert.ok(toolHandler); + await commandHandler("", ctx); + await readyExecute( + "ready-off", + { plan: "# Plan" }, + new AbortController().signal, + undefined, + ctx, + ); + await commandHandler("", ctx); + + assert.equal(toolHandler({ toolName: "write" }), undefined); + assert.deepEqual(entries.at(-1), { version: 1, status: "inactive" }); +}); + +test("/plan off clears plan mode", async () => { + let commandHandler: + | ((args: string, ctx: ExtensionCommandContext) => Promise) + | undefined; + let toolHandler: + | ((event: { toolName: string }) => { block?: boolean } | void) + | undefined; + const entries: unknown[] = []; + const pi = { + registerTool() {}, + getActiveTools: () => [], + setActiveTools() {}, + registerCommand( + _name: string, + command: { + handler: (args: string, ctx: ExtensionCommandContext) => Promise; + }, + ) { + commandHandler = command.handler; + }, + on(event: string, handler: unknown) { + if (event === "tool_call") toolHandler = handler as typeof toolHandler; + }, + events: { emit() {} }, + appendEntry(_type: string, state: unknown) { + entries.push(state); + }, + sendMessage() {}, + } as unknown as ExtensionAPI; + const ctx = { + hasUI: true, + ui: { setStatus() {}, notify() {} }, + } as unknown as ExtensionCommandContext; + + planMode(pi); + assert.ok(commandHandler); + assert.ok(toolHandler); + await commandHandler("", ctx); + await commandHandler("off", ctx); + + assert.equal(toolHandler({ toolName: "write" }), undefined); + assert.deepEqual(entries.at(-1), { version: 1, status: "inactive" }); +}); + test("fresh implementation links a new session and prefills without submitting", async () => { let commandHandler: | ((args: string, ctx: ExtensionCommandContext) => Promise) diff --git a/extensions/plan-mode/index.ts b/extensions/plan-mode/index.ts index 85792dad..b5f91008 100644 --- a/extensions/plan-mode/index.ts +++ b/extensions/plan-mode/index.ts @@ -142,6 +142,7 @@ export const PLAN_READY_ACTIONS = { continue: "Continue planning", current: "Implement in this session", fresh: "Start a fresh session", + off: "Turn plan mode off", } as const; export function buildPlanImplementationPrompt(plan: string) { @@ -257,11 +258,7 @@ export default function planMode(pi: ExtensionAPI) { if (!ctx.hasUI) return; ctx.ui.setStatus( "plan-mode", - readyPlan - ? "plan mode · ready" - : planning - ? "plan mode · read-only" - : undefined, + readyPlan ? "plan ready" : planning ? "plan mode" : undefined, ); }; @@ -371,9 +368,25 @@ export default function planMode(pi: ExtensionAPI) { implementHere(ctx); } else if (choice === PLAN_READY_ACTIONS.fresh) { await implementFresh(ctx); + } else if (choice === PLAN_READY_ACTIONS.off) { + clearPlan(ctx); + ctx.ui.notify("Plan mode is off.", "info"); } }; + const requestPlanFinalization = () => { + pi.sendMessage( + { + customType: "plan-finalize-requested", + content: + "Finalize the plan now. Resolve any remaining material ambiguity with ask_user; otherwise call plan_ready alone with the complete implementation-ready Markdown plan. Do not implement it.", + display: true, + details: {}, + }, + { deliverAs: "followUp", triggerTurn: true }, + ); + }; + pi.registerTool({ name: "plan_ready", label: "Plan Ready", @@ -462,16 +475,7 @@ export default function planMode(pi: ExtensionAPI) { ctx.ui.notify("Plan mode is not active.", "warning"); return; } - pi.sendMessage( - { - customType: "plan-finalize-requested", - content: - "Finalize the plan now. Resolve any remaining material ambiguity with ask_user; otherwise call plan_ready alone with the complete implementation-ready Markdown plan. Do not implement it.", - display: true, - details: {}, - }, - { deliverAs: "followUp", triggerTurn: true }, - ); + requestPlanFinalization(); return; } @@ -481,10 +485,28 @@ export default function planMode(pi: ExtensionAPI) { } if (planning) { - ctx.ui.notify( - "Plan mode is already active. `/plan done` requests completion; `/plan off` cancels.", - "info", + if (!ctx.hasUI) { + ctx.ui.notify( + "Plan mode is already active. `/plan done` requests completion; `/plan off` cancels.", + "info", + ); + return; + } + const choice = await ctx.ui.select( + "Plan Mode — choose what happens next", + ["Continue planning", "Finalize now", PLAN_READY_ACTIONS.off], ); + if (choice === "Continue planning") { + ctx.ui.notify( + "Plan mode is already active. `/plan done` requests completion; `/plan off` cancels.", + "info", + ); + } else if (choice === "Finalize now") { + requestPlanFinalization(); + } else if (choice === PLAN_READY_ACTIONS.off) { + clearPlan(ctx); + ctx.ui.notify("Plan mode is off.", "info"); + } return; } @@ -510,7 +532,7 @@ export default function planMode(pi: ExtensionAPI) { return { block: true as const, reason: - "The plan is ready and the write gate remains closed. Wait for the user to choose the next action with `/plan`; do not call more tools.", + "The plan is ready and the write gate remains closed. Wait for the user to choose the next action with `/plan` or turn it off with `/plan off`; do not call more tools.", }; } const batchDecision = From 461a0caa58fe9a479349d3b852b78a2fd53dc15c Mon Sep 17 00:00:00 2001 From: tt-a1i Date: Thu, 20 Aug 2026 21:52:30 +0800 Subject: [PATCH 2/5] feat(footer): inline a single status into the first footer line when it fits --- extensions/ui-customization/footer.test.ts | 33 +++++++++++ extensions/ui-customization/footer.ts | 69 ++++++++++++++++++---- 2 files changed, 92 insertions(+), 10 deletions(-) diff --git a/extensions/ui-customization/footer.test.ts b/extensions/ui-customization/footer.test.ts index 1325442d..4af4f519 100644 --- a/extensions/ui-customization/footer.test.ts +++ b/extensions/ui-customization/footer.test.ts @@ -201,6 +201,39 @@ test("context uses warning and error tones at thresholds", () => { assert.equal(ok.tone, "muted"); }); +test("a single status inlines into the first footer line when it fits", () => { + const lines = renderFooter({ + cwd: "/tmp/project", + modelInfo, + gitInfo, + style: "plain", + lines: DEFAULT_FOOTER_LINES, + width: 140, + theme, + statuses: ["plan mode"], + }); + + assert.equal(lines.length, 1); + assert.match(lines[0]!, /seal\/gpt-5\.6-sol/); + assert.match(lines[0]!, /plan mode/); +}); + +test("a single status stays on its own line when it cannot fit", () => { + const lines = renderFooter({ + cwd: "/tmp/project", + modelInfo, + gitInfo, + style: "plain", + lines: [["model"]], + width: 25, + theme, + statuses: ["plan mode"], + }); + + assert.equal(lines.length, 2); + assert.match(lines[1]!, /plan mode/); +}); + test("operational statuses always append after layout lines", () => { const lines = renderFooter({ cwd: "/tmp/project", diff --git a/extensions/ui-customization/footer.ts b/extensions/ui-customization/footer.ts index 43ec8b12..98dfaef7 100644 --- a/extensions/ui-customization/footer.ts +++ b/extensions/ui-customization/footer.ts @@ -450,27 +450,76 @@ export function renderFooter(options: FooterRenderOptions) { formatPullRequest, ); const lines: string[] = []; + const statusLines = options.statuses + ? Array.from(options.statuses).flatMap((status) => status.split("\n")) + : []; + const singleStatus = statusLines.length === 1 ? statusLines[0] : undefined; + const separator = options.theme.fg("dim", " · "); + const styledStatus = + singleStatus === undefined + ? undefined + : options.theme.fg("dim", singleStatus); + const statusWidth = + styledStatus === undefined + ? 0 + : visibleWidth(separator) + visibleWidth(styledStatus); + let inlinedStatus = false; for (const layout of options.lines) { - const line = renderFooterLine( + const resolved = resolveLineSegments( layout, catalog, options.style, + options.modelInfo.contextPercent, + ); + const fitted = fitSegmentsToWidth( + resolved.left, + resolved.right, options.width, + options.style, + options.theme, + ); + const canInlineStatus = + lines.length === 0 && + styledStatus !== undefined && + naturalLineWidth( + fitted.left, + fitted.right, + options.style, + options.theme, + ) + + statusWidth <= + options.width; + const line = renderFooterLine( + layout, + catalog, + options.style, + canInlineStatus ? options.width - statusWidth : options.width, options.theme, options.modelInfo.contextPercent, ); - if (line) lines.push(line); + if (!line) continue; + lines.push( + canInlineStatus + ? truncateToWidth( + `${line}${separator}${styledStatus}`, + options.width, + options.theme.fg("dim", "..."), + ) + : line, + ); + inlinedStatus ||= canInlineStatus; } - if (options.statuses) { - for (const statusLine of options.statuses) { - for (const part of statusLine.split("\n")) { - lines.push( - truncateToWidth(part, options.width, options.theme.fg("dim", "...")), - ); - } - } + if (inlinedStatus) return lines; + for (const statusLine of statusLines) { + lines.push( + truncateToWidth( + statusLine, + options.width, + options.theme.fg("dim", "..."), + ), + ); } return lines; From d74966252bc3f3576dd9cbadb028aec729a423e9 Mon Sep 17 00:00:00 2001 From: tt-a1i Date: Thu, 20 Aug 2026 22:03:22 +0800 Subject: [PATCH 3/5] feat(plan-mode): judge bash by command effect instead of syntax Allow quoted literal arguments and a narrow set of read-only search and inspection tools.\nKeep shell expansion, composition, unsafe flags, and unknown effects fail-closed so plan mode cannot mutate before approval. --- extensions/plan-mode/bash-policy.test.ts | 75 +++--- extensions/plan-mode/bash-policy.ts | 286 +++++++++++++++++++---- 2 files changed, 295 insertions(+), 66 deletions(-) diff --git a/extensions/plan-mode/bash-policy.test.ts b/extensions/plan-mode/bash-policy.test.ts index bd56b161..f845e880 100644 --- a/extensions/plan-mode/bash-policy.test.ts +++ b/extensions/plan-mode/bash-policy.test.ts @@ -104,10 +104,21 @@ test("an unrecognized flag is refused rather than assumed harmless", () => { ); }); -test("anything that is more than one plain command is refused", () => { - // The policy does not parse shell. Every one of these would need a parser to - // judge, and every parser bug would be a bypass, so the shape itself is the - // rejection criterion. +test("quoted arguments and read-only search tools are allowed", () => { + for (const command of [ + 'rg -n "foo bar" src', + "rg -l --glob '*.ts' pattern", + 'rg -e "^export" -t ts .', + "fd -e ts src", + "ls -la", + "wc -l file.txt", + "head -n 20 file.txt", + ]) { + assert.equal(allowed(command), true, `${command} should be allowed`); + } +}); + +test("shell composition, expansion, and unsafe input syntax are refused", () => { for (const command of [ "git log; rm -rf /tmp/x", "git log && npm publish", @@ -118,44 +129,53 @@ test("anything that is more than one plain command is refused", () => { "git log `rm -rf x`", "git log &", "git log \\\n--oneline", + "rg foo > out.txt", + "rg foo | head", + "rg $(whoami)", + 'rg "$HOME"', + "rg `whoami`", + "rg foo \\", + 'rg "foo', + "rg --glob *.ts foo", "git show *.ts", + "git log ~/notes", ]) { assert.equal(allowed(command), false, `${command} must be refused`); } + assert.match( + planBashDecision("rg --glob *.ts foo").reason ?? "", + /quote it instead/, + ); }); -test("quoted commands are refused because quoting hides word boundaries", () => { - // `git log --pretty='%h; rm x'` tokenizes differently than it executes, so - // the tokenizer is only trustworthy on unquoted input. - assert.equal(allowed(`git log --pretty="%h %s"`), false); - assert.equal(allowed("git log --author='someone'"), false); -}); - -test("only git and gh are admitted at all", () => { - // ls/cat/head/tail/wc/file/stat/du/tree/date were dropped: plan mode already - // grants the read, ls, grep and fd tools, so each added no capability while - // contributing a flag grammar to get wrong. `file --compile` wrote a file - // and `tree -ao` slipped past an anchored -o check. - for (const command of [ - "file --compile -m /tmp/evilmagic", - "tree -ao /tmp/out.txt", - "date -s 12:00", - "cat /etc/passwd", - "ls -la", - "head -100 README.md", - "npm install", - "rm -rf node_modules", - "git-receive-pack .", - ]) { +test("search and inspection flags remain effect allowlisted", () => { + for (const [command, reason] of [ + ["rg --pre cat foo", /does not recognize/], + ["rg --pre-glob '*.js' foo", /does not recognize/], + ["rg --hostname-bin cat foo", /does not recognize/], + ["rg -z foo", /does not recognize/], + ["rg --search-zip foo", /does not recognize/], + ["fd -x rm", /does not recognize/], + ["fd -X rm", /does not recognize/], + ["tail -f log", /block forever/], + ] as const) { assert.equal(allowed(command), false, `${command} must be refused`); + assert.match(planBashDecision(command).reason ?? "", reason); } }); +test("unknown programs explain the available read-only commands", () => { + const decision = planBashDecision("curl example.com"); + assert.equal(decision.allowed, false); + assert.match(decision.reason ?? "", /git, gh, rg, fd, ls, wc, head or tail/); +}); + test("write subcommands stay refused now that they cannot be reached sideways", () => { for (const command of [ "git commit -m wip", "git push", "git checkout main", + "git checkout .", "git reset --hard", "git clean -fd", "git stash", @@ -183,6 +203,7 @@ test("pathspecs after -- are not mistaken for flags", () => { test("tilde is refused where a shell expands it, not inside a revision", () => { // `HEAD~3` is ordinary git syntax and must survive; `~/x` is a path this // module never gets to inspect. + assert.equal(allowed("git diff HEAD~3"), true); assert.equal(allowed("git diff HEAD~3..HEAD"), true); assert.equal(allowed("git log ~/notes"), false); assert.equal(allowed("git log ~user/notes"), false); diff --git a/extensions/plan-mode/bash-policy.ts b/extensions/plan-mode/bash-policy.ts index f98223bb..45788139 100644 --- a/extensions/plan-mode/bash-policy.ts +++ b/extensions/plan-mode/bash-policy.ts @@ -25,24 +25,22 @@ */ /** - * Any of these means the text is more than one plain command — a pipeline, a - * sequence, a redirect, a substitution, a glob, or a background job. Rather - * than parse shell (where every parser bug is a bypass), refuse outright. - * - * `\` is here because a line continuation splices in the next line; `$` covers - * both `$(...)` and a `$VAR` that expands into arguments never inspected here. + * This module does not parse shell or admit shell composition. Its small + * tokenizer only recognizes words and quoted literal spans. `$`, backticks + * and `\` are refused everywhere; shell metacharacters are refused outside + * quotes. Globs are also refused outside quotes because the shell would expand + * them before the allowlisted program sees them, while a quoted glob is a + * literal pattern interpreted by that read-only program itself. */ -const SHELL_METACHARACTERS = /[;&|<>$`\\!*?{}()[\]\n\r#]/; +const UNQUOTED_SHELL_METACHARACTERS = /[;&|<>(){}\n\r#]/; +const EXPANSION_CHARACTERS = /[$`\\]/; +const UNQUOTED_GLOB_CHARACTERS = new Set(["*", "?", "[", "]"]); /** - * Tilde expansion, but only where a shell would actually expand it: at the - * start of a word. `HEAD~3` is ordinary revision syntax and must survive, - * while `~/notes` and `~user/x` resolve to a path this module never sees. + * Tilde expansion is refused only at the start of an unquoted word. `HEAD~3` + * is ordinary revision syntax and must survive, while `~/notes` and + * `~user/x` resolve to a path this module never sees. */ -const TILDE_EXPANSION = /(^|\s)~/; - -/** Quotes hide word boundaries from the tokenizer below, so they are refused too. */ -const QUOTES = /["']/; /** * Read-only git subcommands. Absent on purpose: `config`, `stash`, `tag`, @@ -194,6 +192,94 @@ const GH_FLAGS = new Set([ "--comments", ]); +const RG_FLAGS = new Set([ + "-n", + "--line-number", + "-i", + "--ignore-case", + "-l", + "--files-with-matches", + "-c", + "--count", + "-w", + "-F", + "--fixed-strings", + "-e", + "--regexp", + "-g", + "--glob", + "-t", + "--type", + "--files", + "--hidden", + "--no-ignore", + "-A", + "-B", + "-C", + "--after-context", + "--before-context", + "--context", + "-m", + "--max-count", + "-o", + "--only-matching", + "--sort", + "--json", + "--color", + "-H", + "-N", + "--no-filename", + "-v", + "--invert-match", + "-u", + "-uu", +]); + +const FD_FLAGS = new Set([ + "-e", + "--extension", + "-t", + "--type", + "-d", + "--max-depth", + "--min-depth", + "-H", + "--hidden", + "-I", + "--no-ignore", + "-g", + "--glob", + "-F", + "--fixed-strings", + "-p", + "--full-path", + "-a", + "--absolute-path", + "-l", + "--list-details", + "--color", + "-0", + "-S", + "--size", +]); + +const LS_FLAGS = new Set([ + "-l", + "-a", + "-A", + "-h", + "-t", + "-r", + "-R", + "-d", + "-1", + "-S", + "-F", + "--color", +]); +const WC_FLAGS = new Set(["-l", "-w", "-c", "-m"]); +const HEAD_TAIL_FLAGS = new Set(["-n", "-c", "--lines", "--bytes"]); + /** `-5`, `-20`: git's count shorthand, which is a number rather than a flag. */ const NUMERIC_SHORTHAND = /^-\d+$/; @@ -208,6 +294,83 @@ const refuse = (reason: string): BashPlanDecision => ({ reason, }); +/** + * Tokenize words without pretending to be a shell parser. Quoted spans are + * removed and become literal text; no expansion or command composition is + * supported. The validation happens while tokenizing so an unquoted glob can + * never be mistaken for a literal program argument. + */ +function tokenize(command: string) { + const words: string[] = []; + let word = ""; + let inWord = false; + let quote: "'" | '"' | undefined; + let wordStartsUnquoted = false; + + for (const character of command) { + if (quote) { + if (character === quote) { + quote = undefined; + } else if (EXPANSION_CHARACTERS.test(character)) { + return refuse( + `plan mode rejected expansion character ${JSON.stringify(character)} — remove expansion syntax and pass literal arguments instead`, + ); + } else { + word += character; + } + inWord = true; + continue; + } + + if (EXPANSION_CHARACTERS.test(character)) { + return refuse( + `plan mode rejected expansion character ${JSON.stringify(character)} — remove expansion syntax and pass literal arguments instead`, + ); + } + if (UNQUOTED_SHELL_METACHARACTERS.test(character)) { + return refuse( + `plan mode rejected unquoted shell metacharacter ${JSON.stringify(character)} — run a single plain command without shell composition`, + ); + } + if (UNQUOTED_GLOB_CHARACTERS.has(character)) { + return refuse( + "plan mode will not run an unquoted glob because the shell would expand it — quote it instead, e.g. --glob '*.ts'", + ); + } + if (character === "'" || character === '"') { + quote = character; + inWord = true; + if (!word) wordStartsUnquoted = false; + continue; + } + if (/\s/.test(character)) { + if (inWord) { + words.push(word); + word = ""; + inWord = false; + wordStartsUnquoted = false; + } + continue; + } + if (!inWord) wordStartsUnquoted = true; + if (wordStartsUnquoted && word.length === 0 && character === "~") { + return refuse( + "plan mode does not run tilde-expanded paths — use a path relative to the project instead", + ); + } + word += character; + inWord = true; + } + + if (quote) { + return refuse( + `plan mode rejected an unterminated ${quote === "'" ? "single" : "double"} quote — close the quote or pass a literal argument instead`, + ); + } + if (inWord) words.push(word); + return { allowed: true as const, words }; +} + /** Split `--flag=value` into the flag part the allowlists are keyed on. */ function flagName(word: string) { const eq = word.indexOf("="); @@ -227,12 +390,44 @@ function scanArguments( for (const word of words) { if (word === "--") break; if (!word.startsWith("-")) continue; - if (NUMERIC_SHORTHAND.test(word)) continue; + if ((program === "git" || program === "gh") && NUMERIC_SHORTHAND.test(word)) + continue; if (!allowed.has(flagName(word))) { + const isShortCluster = + word.startsWith("-") && + !word.startsWith("--") && + [...word.slice(1)].every((character) => allowed.has(`-${character}`)); + if (isShortCluster) continue; + return refuse( + `plan mode does not recognize "${word}" as a read-only ${program} option — use only the allowlisted ${program} flags`, + ); + } + } + return { allowed: true }; +} + +function scanShortFlagClusters( + words: readonly string[], + allowed: ReadonlySet, + program: string, +): BashPlanDecision { + for (const word of words) { + if (word === "--") break; + if (!word.startsWith("-") || word.startsWith("--")) continue; + if (allowed.has(word)) continue; + const shortFlags = word.slice(1); + if (new Set(shortFlags).size !== shortFlags.length) { return refuse( - `plan mode does not recognize "${word}" as a read-only ${program} option, so it will not run this command`, + `plan mode does not recognize "${word}" as a read-only ${program} option — use only the allowlisted ${program} flags`, ); } + for (const flag of shortFlags) { + if (!allowed.has(`-${flag}`)) { + return refuse( + `plan mode does not recognize "${word}" as a read-only ${program} option — use only the allowlisted ${program} flags`, + ); + } + } } return { allowed: true }; } @@ -249,21 +444,10 @@ export function planBashDecision(command: unknown): BashPlanDecision { const text = command.trim(); if (!text) return refuse("plan mode received an empty command"); - if (SHELL_METACHARACTERS.test(text)) { - return refuse( - "plan mode only runs a single plain command — no pipes, redirects, substitutions, globs, or chained commands", - ); - } - if (QUOTES.test(text)) { - return refuse("plan mode only runs unquoted commands while planning"); - } - if (TILDE_EXPANSION.test(text)) { - return refuse( - "plan mode does not run commands with `~` paths — give a path relative to the project instead", - ); - } - - const [program, ...rest] = text.split(/\s+/); + const tokenized = tokenize(text); + if (!("words" in tokenized)) return tokenized; + const [program, ...rest] = tokenized.words; + if (!program) return refuse("plan mode received an empty command"); /* * The subcommand must be the FIRST word, never "the first word that is not a @@ -300,14 +484,38 @@ export function planBashDecision(command: unknown): BashPlanDecision { return scanArguments(args, GH_FLAGS, "gh"); } - /* - * Nothing else is admitted. `ls`, `cat`, `head`, `tail` and `wc` were on an - * earlier version of this list and are gone: plan mode already grants the - * `ls`, `read`, `grep` and `fd`/`rg` TOOLS, so those shell forms added no - * capability while each contributed its own flag grammar to get wrong - * (`file --compile` and `tree -ao` both write files). - */ + const readOnlyPrograms = new Map([ + ["rg", RG_FLAGS], + ["fd", FD_FLAGS], + ["ls", LS_FLAGS], + ["wc", WC_FLAGS], + ["head", HEAD_TAIL_FLAGS], + ["tail", HEAD_TAIL_FLAGS], + ]); + const flags = readOnlyPrograms.get(program); + if (flags) { + if ( + program === "tail" && + rest.some((word) => word === "-f" || word === "--follow") + ) { + return refuse( + 'plan mode refuses "tail -f/--follow" because it can block forever — use a finite tail command instead', + ); + } + const decision = scanArguments(rest, flags, program); + if (!decision.allowed) return decision; + if ( + program === "rg" || + program === "fd" || + program === "ls" || + program === "wc" + ) { + return scanShortFlagClusters(rest, flags, program); + } + return { allowed: true }; + } + return refuse( - `plan mode runs only read-only git and gh investigation commands while planning, not "${program}" — use the read, ls, grep or fd tools for files`, + `plan mode does not allow "${program}" — use read-only git and gh investigation commands; available commands are git, gh, rg, fd, ls, wc, head or tail, plus the read/grep/fd tools`, ); } From b1056cfbe31a102e8fc213927251b8129f8a1e2d Mon Sep 17 00:00:00 2001 From: tt-a1i Date: Thu, 20 Aug 2026 22:08:19 +0800 Subject: [PATCH 4/5] refactor(plan-mode): fold short-flag clusters into one scanner The first pass added cluster handling twice: once inside scanArguments and once in a separate scanShortFlagClusters pass. The scanArguments branch was not gated by program, so it silently widened git and gh too. Collapse both into one opt-in parameter that only the file-inspection programs pass. --- extensions/plan-mode/bash-policy.ts | 67 ++++++++--------------------- extensions/plan-mode/index.ts | 9 ++-- 2 files changed, 24 insertions(+), 52 deletions(-) diff --git a/extensions/plan-mode/bash-policy.ts b/extensions/plan-mode/bash-policy.ts index 45788139..0519b8c2 100644 --- a/extensions/plan-mode/bash-policy.ts +++ b/extensions/plan-mode/bash-policy.ts @@ -386,48 +386,27 @@ function scanArguments( words: readonly string[], allowed: ReadonlySet, program: string, + /** + * Whether `-la` may stand for `-l -a`. Only the file-inspection programs opt + * in: git and gh keep their historical one-flag-per-word rule, so widening + * the tokenizer cannot quietly widen their surface too. + */ + allowShortClusters = false, ): BashPlanDecision { for (const word of words) { if (word === "--") break; if (!word.startsWith("-")) continue; - if ((program === "git" || program === "gh") && NUMERIC_SHORTHAND.test(word)) - continue; - if (!allowed.has(flagName(word))) { - const isShortCluster = - word.startsWith("-") && - !word.startsWith("--") && - [...word.slice(1)].every((character) => allowed.has(`-${character}`)); - if (isShortCluster) continue; - return refuse( - `plan mode does not recognize "${word}" as a read-only ${program} option — use only the allowlisted ${program} flags`, - ); - } - } - return { allowed: true }; -} - -function scanShortFlagClusters( - words: readonly string[], - allowed: ReadonlySet, - program: string, -): BashPlanDecision { - for (const word of words) { - if (word === "--") break; - if (!word.startsWith("-") || word.startsWith("--")) continue; - if (allowed.has(word)) continue; - const shortFlags = word.slice(1); - if (new Set(shortFlags).size !== shortFlags.length) { - return refuse( - `plan mode does not recognize "${word}" as a read-only ${program} option — use only the allowlisted ${program} flags`, - ); - } - for (const flag of shortFlags) { - if (!allowed.has(`-${flag}`)) { - return refuse( - `plan mode does not recognize "${word}" as a read-only ${program} option — use only the allowlisted ${program} flags`, - ); - } - } + if (NUMERIC_SHORTHAND.test(word)) continue; + if (allowed.has(flagName(word))) continue; + const isAllowedCluster = + allowShortClusters && + !word.startsWith("--") && + word.length > 2 && + [...word.slice(1)].every((character) => allowed.has(`-${character}`)); + if (isAllowedCluster) continue; + return refuse( + `plan mode does not recognize "${word}" as a read-only ${program} option — use only the allowlisted ${program} flags`, + ); } return { allowed: true }; } @@ -502,17 +481,7 @@ export function planBashDecision(command: unknown): BashPlanDecision { 'plan mode refuses "tail -f/--follow" because it can block forever — use a finite tail command instead', ); } - const decision = scanArguments(rest, flags, program); - if (!decision.allowed) return decision; - if ( - program === "rg" || - program === "fd" || - program === "ls" || - program === "wc" - ) { - return scanShortFlagClusters(rest, flags, program); - } - return { allowed: true }; + return scanArguments(rest, flags, program, true); } return refuse( diff --git a/extensions/plan-mode/index.ts b/extensions/plan-mode/index.ts index b5f91008..e5dad57b 100644 --- a/extensions/plan-mode/index.ts +++ b/extensions/plan-mode/index.ts @@ -145,6 +145,9 @@ export const PLAN_READY_ACTIONS = { off: "Turn plan mode off", } as const; +/** Menu label for the same effect as `/plan done`. */ +const FINALIZE_NOW = "Finalize now"; + export function buildPlanImplementationPrompt(plan: string) { return [ "Implement the approved plan below. Re-check the repository state before editing, follow the project instructions, and verify the finished change.", @@ -494,14 +497,14 @@ export default function planMode(pi: ExtensionAPI) { } const choice = await ctx.ui.select( "Plan Mode — choose what happens next", - ["Continue planning", "Finalize now", PLAN_READY_ACTIONS.off], + [PLAN_READY_ACTIONS.continue, FINALIZE_NOW, PLAN_READY_ACTIONS.off], ); - if (choice === "Continue planning") { + if (choice === PLAN_READY_ACTIONS.continue) { ctx.ui.notify( "Plan mode is already active. `/plan done` requests completion; `/plan off` cancels.", "info", ); - } else if (choice === "Finalize now") { + } else if (choice === FINALIZE_NOW) { requestPlanFinalization(); } else if (choice === PLAN_READY_ACTIONS.off) { clearPlan(ctx); From 6f6e2b8c94d304dbbc06c703172735fefcdc3955 Mon Sep 17 00:00:00 2001 From: tt-a1i Date: Thu, 20 Aug 2026 22:14:36 +0800 Subject: [PATCH 5/5] test(footer): assert status visibility rather than a dedicated line The leaner default layout from #26 leaves room for a single status to inline at width 80, so the old lines.length >= 2 assertion encoded placement rather than the property it meant to protect: the status must stay visible. --- extensions/ui-customization/footer.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/extensions/ui-customization/footer.test.ts b/extensions/ui-customization/footer.test.ts index 4af4f519..00d2af97 100644 --- a/extensions/ui-customization/footer.test.ts +++ b/extensions/ui-customization/footer.test.ts @@ -263,9 +263,10 @@ test("statuses remain visible even when metric layout is empty after normalize", statuses: ["bg: sleep"], }); - // normalize falls back to the default metric line; statuses still append. - assert.ok(lines.length >= 2); - assert.match(lines.at(-1)!, /bg: sleep/); + // normalize falls back to the default metric line; the status stays visible + // whether it inlines into that line or lands on its own. + assert.ok(lines.length >= 1); + assert.match(lines.join("\n"), /bg: sleep/); }); test("legacy buildFooterContent still groups selected items", () => {