From a7d04e18eecd68f5053715abc0a6e8c69bda5042 Mon Sep 17 00:00:00 2001 From: Wesley Matos Date: Tue, 8 Sep 2026 23:04:51 -0300 Subject: [PATCH 1/2] feat(tui): auto-approve safe tool prompts via policy classifier Adds a middle permission tier to the terminal client: before rendering the permission dialog, requestToolPermission() consults a static policy (read-only command prefixes and tools are allowed; rm -rf, sudo, curl|sh, force pushes and credential paths are denied with a reason; everything else falls through to the normal dialog). The classifier returns the same response objects the dialog produces, so runtime behavior is unchanged; verdicts are surfaced as TUI notices for transparency. Policy defaults live in code and can be overridden point-wise via ZCODE_AUTO_PERMISSIONS_CONFIG (unmatched defaults to ask, so the classifier is strictly fail-open toward the human dialog). Verified: bun test (unit), typecheck and biome clean, plus a live A/B over a PTY against the real runtime: the stock bundle renders the write dialog and writes nothing; the patched bundle auto-approves the covered write with a visible notice and still defers uncovered writes. --- packages/zcode-tui/src/auto-permissions.ts | 217 +++++++++++++++++++++ packages/zcode-tui/src/index.ts | 65 +++++- test/auto-permissions.test.ts | 107 ++++++++++ 3 files changed, 381 insertions(+), 8 deletions(-) create mode 100644 packages/zcode-tui/src/auto-permissions.ts create mode 100644 test/auto-permissions.test.ts diff --git a/packages/zcode-tui/src/auto-permissions.ts b/packages/zcode-tui/src/auto-permissions.ts new file mode 100644 index 0000000..c4d0121 --- /dev/null +++ b/packages/zcode-tui/src/auto-permissions.ts @@ -0,0 +1,217 @@ +// Auto permission classifier: a middle permission tier between "ask for +// everything" (build) and "bypass everything" (yolo). +// +// Inspired by Claude Code's `auto` permission mode. Classification happens in +// the TUI layer, at the seam where the runtime's permission request would +// otherwise render a dialog: allow/deny verdicts return the same response +// objects the dialog produces ({ decision, reason, permissionUpdates }), and +// unmatched requests return null so the normal human dialog runs. +// +// The classifier is fail-open toward the dialog by design: any internal +// error, missing config, or unmatched request defers to the user. It can +// never widen yolo mode (it only runs when a prompt would show) and cannot +// override runtime-side explicit deny rules (those never reach a prompt). + +import { existsSync, readFileSync } from "node:fs" + +import { asString, isRecord } from "./types.ts" + +export interface AutoPermissionRule { + tool: string | string[] + commandPrefix?: string + commandRegex?: string + pathPrefix?: string + pathRegex?: string + note?: string +} + +export interface AutoPermissionConfig { + defaults: { unmatched: "ask" | "allow" | "deny" } + allow: AutoPermissionRule[] + softDeny: AutoPermissionRule[] + hardDeny: AutoPermissionRule[] +} + +export interface PermissionRequestShape { + toolName: string + input: unknown + riskLevel?: string +} + +export interface AutoPermissionVerdict { + behavior: "allow" | "deny" + reason: string + matchedRule: AutoPermissionRule +} + +// Paths that carry credentials. Never auto-approved; denied outright when a +// hardDeny rule targets them. +const secretPathPattern = String.raw`(^|[/\\])\.(env|ssh|aws|gnupg|kube|netrc|npmrc)([/\\]|$)|\.pem$|id_rsa|credentials` + +function ruleToBuiltin(rule: Omit, note: string): AutoPermissionRule { + return { ...rule, note } +} + +export function builtinAutoPermissionConfig(): AutoPermissionConfig { + const readOnlyCommands = [ + "git status", + "git log", + "git diff", + "git show", + "git branch", + "ls", + "pwd", + "cat", + "head", + "tail", + "wc", + "rg", + "grep", + "find", + "which", + "file", + "stat" + ] + return { + defaults: { unmatched: "ask" }, + allow: [ + ...readOnlyCommands.map((command) => ruleToBuiltin({ tool: "Bash", commandPrefix: command }, "read-only command")), + ruleToBuiltin({ tool: ["Read", "Glob", "Grep", "TodoRead", "WebSearch"] }, "read-only tool") + ], + softDeny: [ + ruleToBuiltin({ tool: "Bash", commandRegex: String.raw`\bgit\s+reset\s+--hard\b` }, "history rewrite of working tree") + ], + hardDeny: [ + ruleToBuiltin({ tool: "Bash", commandRegex: String.raw`\brm\s+-[a-zA-Z]*r[a-zA-Z]*f|\brm\s+-[a-zA-Z]*f[a-zA-Z]*r` }, "recursive force delete"), + ruleToBuiltin({ tool: "Bash", commandRegex: String.raw`\bsudo\s` }, "privilege escalation"), + ruleToBuiltin({ tool: "Bash", commandRegex: String.raw`\b(curl|wget)\b[^|;&]*\|\s*(ba|z|fi)?sh\b` }, "remote code piped to shell"), + ruleToBuiltin({ tool: "Bash", commandRegex: String.raw`\bgit\s+push\b[^;&]*--force` }, "force push"), + ruleToBuiltin({ tool: ["Read", "Write", "Edit"], pathRegex: secretPathPattern }, "credential path"), + ruleToBuiltin({ tool: "Bash", commandRegex: secretPathPattern }, "credential path in command") + ] + } +} + +export function loadAutoPermissionConfig(configPath: string | undefined): AutoPermissionConfig { + const base = builtinAutoPermissionConfig() + if (!configPath) return base + try { + if (!existsSync(configPath)) return base + const parsed: unknown = JSON.parse(readFileSync(configPath, "utf8")) + if (!isRecord(parsed)) return base + const defaults = isRecord(parsed.defaults) ? parsed.defaults : {} + const unmatched = defaults.unmatched === "allow" || defaults.unmatched === "deny" || defaults.unmatched === "ask" + ? defaults.unmatched + : base.defaults.unmatched + const rules = (key: "allow" | "softDeny" | "hardDeny"): AutoPermissionRule[] => { + const value = parsed[key] + if (!Array.isArray(value)) return base[key] + return value.flatMap((entry): AutoPermissionRule[] => { + if (!isRecord(entry)) return [] + const tool = asString(entry.tool) ?? (Array.isArray(entry.tool) ? entry.tool.filter((item): item is string => typeof item === "string") : undefined) + if (!tool) return [] + return [{ + tool, + commandPrefix: asString(entry.commandPrefix), + commandRegex: asString(entry.commandRegex), + pathPrefix: asString(entry.pathPrefix), + pathRegex: asString(entry.pathRegex), + note: asString(entry.note) + }] + }) + } + return { + defaults: { unmatched }, + allow: rules("allow"), + softDeny: rules("softDeny"), + hardDeny: rules("hardDeny") + } + } catch { + // A broken config file must never break the TUI: fall back to built-ins. + return base + } +} + +function commandOf(input: unknown): string { + if (!isRecord(input)) return "" + return asString(input.command) ?? "" +} + +function pathOf(input: unknown): string { + if (!isRecord(input)) return "" + return asString(input.file_path) ?? asString(input.path) ?? "" +} + +function prefixMatches(value: string, prefix: string): boolean { + if (!value.startsWith(prefix)) return false + if (value.length === prefix.length) return true + return /[\s/]/u.test(value[prefix.length]) +} + +function ruleMatches(rule: AutoPermissionRule, request: PermissionRequestShape): boolean { + const tools = Array.isArray(rule.tool) ? rule.tool : [rule.tool] + if (!tools.includes(request.toolName)) return false + const command = commandOf(request.input) + const path = pathOf(request.input) + if (rule.commandPrefix !== undefined && !prefixMatches(command, rule.commandPrefix)) return false + if (rule.commandRegex !== undefined && !new RegExp(rule.commandRegex, "u").test(command)) return false + if (rule.pathPrefix !== undefined && !prefixMatches(path, rule.pathPrefix)) return false + if (rule.pathRegex !== undefined && !new RegExp(rule.pathRegex, "u").test(path)) return false + return true +} + +function firstMatch(rules: AutoPermissionRule[], request: PermissionRequestShape): AutoPermissionRule | undefined { + return rules.find((rule) => ruleMatches(rule, request)) +} + +function describeRule(rule: AutoPermissionRule): string { + if (rule.note) return rule.note + if (rule.commandPrefix) return rule.commandPrefix + if (rule.pathPrefix) return rule.pathPrefix + const pattern = rule.commandRegex ?? rule.pathRegex + if (pattern) return `pattern match (${pattern})` + return "rule" +} + +export function classifyPermissionRequest( + request: PermissionRequestShape, + config: AutoPermissionConfig +): AutoPermissionVerdict | null { + const hardDeny = firstMatch(config.hardDeny, request) + if (hardDeny) return { behavior: "deny", reason: `auto-permissions: ${describeRule(hardDeny)}`, matchedRule: hardDeny } + const allowed = firstMatch(config.allow, request) + if (allowed) return { behavior: "allow", reason: `auto-permissions: ${describeRule(allowed)} (${request.toolName})`, matchedRule: allowed } + const softDeny = firstMatch(config.softDeny, request) + if (softDeny) return { behavior: "deny", reason: `auto-permissions: ${describeRule(softDeny)}`, matchedRule: softDeny } + switch (config.defaults.unmatched) { + case "allow": + return { behavior: "allow", reason: "auto-permissions: defaults.unmatched=allow", matchedRule: { tool: request.toolName, note: "defaults.unmatched=allow" } } + case "deny": + return { behavior: "deny", reason: "auto-permissions: defaults.unmatched=deny", matchedRule: { tool: request.toolName, note: "defaults.unmatched=deny" } } + default: + return null + } +} + +// The exact response object shape the permission dialog returns to the +// runtime (see defaultPermissionChoices / requestToolPermission). A null +// verdict means "no auto decision": the caller renders the human dialog. +export type PermissionDialogResponse = { decision: "allow" | "deny"; reason: string } + +// Auto classification is opt-in: it runs only in the client's auto overlay +// mode, and only for ordinary tool-permission prompts. AskUserQuestion and +// plan approval are human decisions by design and are never auto-answered. +export function shouldAutoClassify(mode: string | undefined, toolName: string): boolean { + if (mode !== "auto") return false + const normalized = toolName.toLowerCase().replace(/[^a-z0-9]/gu, "") + return normalized !== "askuserquestion" && normalized !== "exitplanmode" && normalized !== "exitplanmodev2" +} + +export function autoPermissionResponse( + request: PermissionRequestShape, + config: AutoPermissionConfig +): PermissionDialogResponse | null { + const verdict = classifyPermissionRequest(request, config) + if (!verdict) return null + return { decision: verdict.behavior, reason: verdict.reason } +} diff --git a/packages/zcode-tui/src/index.ts b/packages/zcode-tui/src/index.ts index 3eac537..c0f81f1 100644 --- a/packages/zcode-tui/src/index.ts +++ b/packages/zcode-tui/src/index.ts @@ -57,6 +57,11 @@ import { AssistantStream } from "./assistant-stream.ts"; import { BackgroundTaskEventStore } from "./background-task-events.ts"; import { readBackgroundTaskOutput } from "./background-task-output.ts"; import { BoundedToolText, toolTextValue } from "./bounded-tool-text.ts"; +import { + autoPermissionResponse, + loadAutoPermissionConfig, + shouldAutoClassify +} from "./auto-permissions.ts"; import { choose, promptText, type ChoiceItem } from "./choice-dialog.ts"; import { colorSchemeFromRgb, @@ -217,12 +222,17 @@ import { import { appliesToSetting, modes, + clientModes, + nextClientMode, nextMode, + initialClientMode, nextPickerCommand, nextPickerValue, + normalizedClientMode, normalizedMode, settingTargetForCommand, transcriptPageDirection, + type ClientMode, type Mode, type SettingTarget } from "./shortcuts.ts"; @@ -638,7 +648,8 @@ class ZCodeTui { private currentToolGroupMessageId?: string; private pendingAttachments: PromptImageAttachment[] = []; private readonly editorHistory: string[] = []; - private mode: Mode; + private mode: ClientMode; + private autoModeActive = false; private model: string; private tuiMode: TuiMode; private copyOnSelect = true; @@ -719,7 +730,8 @@ class ZCodeTui { (width) => this.fullscreenHeader.identity(width), { loginRequired: options.loginRequired === true, includeIdentity: true } ); - this.mode = normalizedMode(options.initialMode); + this.mode = initialClientMode(options.initialMode, process.env.ZCODE_CLIENT_MODE); + this.autoModeActive = this.mode === "auto"; this.model = modelLabel(options.initialModel); this.thoughtLevel = options.initialThoughtLevel; this.modelOptions = [...(options.modelOptions ?? [])]; @@ -2116,7 +2128,16 @@ class ZCodeTui { this.recordAssistantText(this.assistantStream.reconcile(response)); } if (appliesToSetting(settingTarget, "mode") && typeof result.mode === "string") { - this.mode = normalizedMode(result.mode, this.mode); + if (settingTarget === "mode") { + // An explicit typed /mode command executed in the runtime: the runtime + // owns its enum, so any confirmed value exits the auto overlay. + this.autoModeActive = false; + this.mode = normalizedClientMode(result.mode, this.mode); + } else if (!this.autoModeActive) { + // Runtime state echoes while the overlay is active describe the build + // mode the overlay forces — they must not clear the overlay. + this.mode = normalizedClientMode(result.mode, this.mode); + } } if (appliesToSetting(settingTarget, "model") && result.model !== undefined) { this.model = modelLabel(result.model); @@ -3322,6 +3343,19 @@ class ZCodeTui { payload: choice.response }))); } + const autoResponse = shouldAutoClassify(this.mode, toolName) + ? autoPermissionResponse( + { toolName, input: request.input, riskLevel: asString(request.riskLevel) }, + loadAutoPermissionConfig(process.env.ZCODE_AUTO_PERMISSIONS_CONFIG) + ) + : null; + if (autoResponse) { + this.addNotice( + `auto-permissions · ${autoResponse.decision.toUpperCase()} · ${toolName} · ${autoResponse.reason}`, + autoResponse.decision === "deny" ? "warning" : "muted" + ); + return autoResponse; + } const selected = await this.showChoice({ title: `Permission · ${toolName}`, prompt: asString(request.reason) ?? `${toolName} requests permission to continue.`, @@ -3649,7 +3683,7 @@ class ZCodeTui { * setMode bridge so the runtime owns the exact mode-switching semantics. */ private async showModePicker(): Promise { - const picker = modePicker(this.mode, modes); + const picker = modePicker(this.mode, clientModes); if (picker.items.length === 0) return false; const selected = await this.showChoice({ title: "Select mode", @@ -3661,7 +3695,10 @@ class ZCodeTui { const mode = selected?.payload; if (typeof mode !== "string") return true; - await this.applyModeShortcut(normalizedMode(mode)); + // The picker and Shift+Tab route through the client: "auto" is a + // client-side overlay (runtime is held in build); runtime modes apply + // through the setMode bridge and clear the overlay. + await this.applyModeShortcut(normalizedClientMode(mode)); return true; } @@ -4436,10 +4473,11 @@ class ZCodeTui { private async switchMode(): Promise { if (!this.shortcutAvailable()) return; - await this.applyModeShortcut(nextMode(this.mode)); + // Shift+Tab cycles client-side modes, including the auto overlay. + await this.applyModeShortcut(nextClientMode(this.mode)); } - private async applyModeShortcut(requestedMode: Mode): Promise { + private async applyModeShortcut(requestedMode: Mode | ClientMode): Promise { if (this.settingSwitchInFlight) return; if (!this.options.setMode) { this.addNotice("Mode switching is unavailable in this runtime.", "warning"); @@ -4447,9 +4485,20 @@ class ZCodeTui { } this.settingSwitchInFlight = true; try { + if (requestedMode === "auto") { + // Client-side overlay: the runtime stays in build (so permission + // prompts still reach this client) while the TUI displays auto and + // the permission classifier decides prompts. + await this.options.setMode("build"); + this.autoModeActive = true; + this.mode = "auto"; + this.updateMetadata(); + return; + } + this.autoModeActive = false; const result = await this.options.setMode(requestedMode); const returnedMode = isRecord(result) ? asString(result.mode) : asString(result); - this.mode = normalizedMode(returnedMode, requestedMode); + this.mode = normalizedClientMode(returnedMode, requestedMode); this.updateMetadata(); } catch (error) { this.addNotice(error instanceof Error ? error.message : String(error), "error"); diff --git a/test/auto-permissions.test.ts b/test/auto-permissions.test.ts new file mode 100644 index 0000000..b602119 --- /dev/null +++ b/test/auto-permissions.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, test } from "bun:test"; + +import { + autoPermissionResponse, + classifyPermissionRequest, + loadAutoPermissionConfig, + type AutoPermissionConfig +} from "../packages/zcode-tui/src/auto-permissions.ts"; + +const baseRequest = { + toolName: "Bash", + input: { command: "git status" } as unknown, + riskLevel: "high" as string | undefined +}; + +function makeConfig(overrides: Partial = {}): AutoPermissionConfig { + return { + defaults: { unmatched: "ask" }, + allow: [], + softDeny: [], + hardDeny: [], + ...overrides + }; +} + +describe("auto-permissions classification seam", () => { + test("classifies a read-only bash command as allow", () => { + const verdict = classifyPermissionRequest(baseRequest, makeConfig({ + allow: [{ tool: "Bash", commandPrefix: "git status" }] + })); + expect(verdict).toEqual({ + behavior: "allow", + reason: expect.stringContaining("git status"), + matchedRule: expect.anything() + }); + }); + + test("classification result maps to the dialog's response shape", () => { + const verdict = classifyPermissionRequest(baseRequest, makeConfig({ + allow: [{ tool: "Bash", commandPrefix: "git status" }] + })); + // This is the exact contract requestPermission() returns to the runtime. + expect(verdict).not.toBeNull(); + expect(verdict?.behavior).toBe("allow"); + expect(verdict?.reason).toContain("git status"); + }); + + test("hard deny wins over allow on compound commands", () => { + const verdict = classifyPermissionRequest({ + toolName: "Bash", + input: { command: "git status && rm -rf /tmp/x" }, + riskLevel: "critical" + }, makeConfig({ + allow: [{ tool: "Bash", commandPrefix: "git status" }], + hardDeny: [{ tool: "Bash", commandRegex: String.raw`\brm\s+-[a-zA-Z]*r[a-zA-Z]*f` }] + })); + expect(verdict).not.toBeNull(); + expect(verdict?.behavior).toBe("deny"); + }); + + test("unmatched tool defers to the dialog when defaults ask", () => { + const verdict = classifyPermissionRequest({ + toolName: "Write", + input: { file_path: "/repo/src/app.ts" }, + riskLevel: "medium" + }, makeConfig()); + expect(verdict).toBeNull(); + }); + + test("credential paths are never auto-allowed", () => { + const verdict = classifyPermissionRequest({ + toolName: "Read", + input: { file_path: "/repo/.env" }, + riskLevel: "medium" + }, makeConfig({ + allow: [{ tool: "Read" }], + hardDeny: [{ tool: ["Read", "Write", "Edit"], pathRegex: String.raw`(^|[/\\])\.env([/\\]|$)` }] + })); + expect(verdict).not.toBeNull(); + expect(verdict?.behavior).toBe("deny"); + }); + + test("built-in config loads and carries conservative defaults", () => { + const config = loadAutoPermissionConfig(undefined); + expect(config.defaults.unmatched).toBe("ask"); + expect(config.allow.length).toBeGreaterThan(0); + expect(config.hardDeny.length).toBeGreaterThan(0); + }); + + test("autoPermissionResponse returns the dialog's exact response object for allow and deny", () => { + const config = makeConfig({ + allow: [{ tool: "Bash", commandPrefix: "git status" }], + hardDeny: [{ tool: "Bash", commandRegex: String.raw`\bsudo\s` }] + }); + expect(autoPermissionResponse({ toolName: "Bash", input: { command: "git status" } }, config)) + .toEqual({ decision: "allow", reason: expect.stringContaining("auto-permissions") }); + expect(autoPermissionResponse({ toolName: "Bash", input: { command: "sudo ls" } }, config)) + .toEqual({ decision: "deny", reason: expect.stringContaining("pattern match") }); + }); + + test("autoPermissionResponse returns null (dialog path) for unmatched requests", () => { + expect(autoPermissionResponse( + { toolName: "Write", input: { file_path: "/repo/src/app.ts" } }, + makeConfig() + )).toBeNull(); + }); +}); From 9e8007c22d9e7a05b7cc299fd0c5313cc503caf0 Mon Sep 17 00:00:00 2001 From: Wesley Matos Date: Tue, 8 Sep 2026 23:04:51 -0300 Subject: [PATCH 2/2] feat(tui): add opt-in auto client mode as classifier overlay The runtime's mode enum has no auto value (its reserved one denies everything), so auto ships as a client-side overlay: Shift+Tab and the /mode picker cycle a client list (build, edit, auto, yolo, plan) where selecting auto pins the runtime to build and routes permission prompts through the classifier. One-shot runs can boot into the overlay via ZCODE_CLIENT_MODE=auto (only over a build runtime). Typed /mode commands still execute in the runtime and exit the overlay; runtime mode echoes during a session no longer clear it. Classification is gated on the overlay being active, so build/edit users see exactly the behavior they chose; AskUserQuestion and plan approvals are never auto-answered. --- packages/zcode-tui/src/shortcuts.ts | 30 ++++++++++++++++++++ test/shortcuts-auto.test.ts | 43 +++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+) create mode 100644 test/shortcuts-auto.test.ts diff --git a/packages/zcode-tui/src/shortcuts.ts b/packages/zcode-tui/src/shortcuts.ts index 26427ca..eadb54a 100644 --- a/packages/zcode-tui/src/shortcuts.ts +++ b/packages/zcode-tui/src/shortcuts.ts @@ -4,8 +4,21 @@ import type { PickerSpec } from "./selectors.ts"; export const modes = ["build", "edit", "yolo", "plan"] as const; export type Mode = (typeof modes)[number]; + +// Client-side modes: adds "auto", a classifier overlay on top of the runtime's +// build mode. The runtime owns its enum and has no auto mode (its reserved +// value denies everything), so "auto" is client-side only: entering it forces +// the runtime to build (prompts still reach the client) while the TUI shows +// auto and decides prompts through the permission classifier. +export const clientModes = ["build", "edit", "auto", "yolo", "plan"] as const; +export type ClientMode = (typeof clientModes)[number]; export type SettingTarget = "mode" | "model" | "effort"; +export function normalizedClientMode(mode?: string, fallback: ClientMode = "build"): ClientMode { + const candidate = mode as ClientMode; + return clientModes.includes(candidate) ? candidate : fallback; +} + export function normalizedMode(mode?: string, fallback: Mode = "build"): Mode { const candidate = mode as Mode; return modes.includes(candidate) ? candidate : fallback; @@ -16,6 +29,23 @@ export function nextMode(currentMode?: string): Mode { return modes[(currentIndex + 1) % modes.length] ?? modes[0]; } +// Shift+Tab cycles the client-side list (which includes the auto overlay); +// runtime mode state is always representable because auto rides on build. +export function nextClientMode(currentMode?: string): ClientMode { + const currentIndex = clientModes.indexOf(normalizedClientMode(currentMode)); + return clientModes[(currentIndex + 1) % clientModes.length] ?? clientModes[0]; +} + +// Boot-time selection for one-shot/headless runs: ZCODE_CLIENT_MODE=auto +// activates the overlay only when the runtime booted in build (its prompt +// modes are the only ones where client-side classification is meaningful). +export function initialClientMode(runtimeMode: string | undefined, envClientMode: string | undefined): ClientMode { + if (envClientMode === "auto") { + return normalizedMode(runtimeMode) === "build" ? "auto" : normalizedMode(runtimeMode) + } + return normalizedMode(runtimeMode) +} + export function settingTargetForCommand(input: string): SettingTarget | undefined { const command = /^\/([^\s]+)/u.exec(input.trim())?.[1]?.toLowerCase(); if (command === "mode") return "mode"; diff --git a/test/shortcuts-auto.test.ts b/test/shortcuts-auto.test.ts new file mode 100644 index 0000000..33ccd46 --- /dev/null +++ b/test/shortcuts-auto.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from "bun:test"; + +import { clientModes, initialClientMode, nextClientMode, nextMode, normalizedClientMode, normalizedMode } from "../packages/zcode-tui/src/shortcuts.ts"; +import { shouldAutoClassify } from "../packages/zcode-tui/src/auto-permissions.ts"; + +describe("auto client mode", () => { + test("auto sits in the client cycle between edit and yolo; official cycle unchanged", () => { + expect(clientModes).toEqual(["build", "edit", "auto", "yolo", "plan"]); + expect(nextClientMode("edit")).toBe("auto"); + expect(nextClientMode("auto")).toBe("yolo"); + expect(nextMode("edit")).toBe("yolo"); + expect(nextMode("yolo")).toBe("plan"); + }); + + test("runtime mode validation still rejects auto (runtime owns its enum)", () => { + expect(normalizedMode("auto")).toBe("build"); + expect(normalizedMode("yolo")).toBe("yolo"); + }); + + test("boot mode selection honors the client-mode env only over a build runtime", () => { + expect(initialClientMode(undefined, "auto")).toBe("auto"); + expect(initialClientMode("build", "auto")).toBe("auto"); + expect(initialClientMode("yolo", "auto")).toBe("yolo"); + expect(initialClientMode("auto", undefined)).toBe("build"); + expect(initialClientMode("plan", undefined)).toBe("plan"); + // Only "auto" is a supported env value; anything else means no overlay. + expect(initialClientMode(undefined, "yolo")).toBe("build"); + expect(initialClientMode(undefined, undefined)).toBe("build"); + }); + + test("client mode validation accepts auto", () => { + expect(normalizedClientMode("auto")).toBe("auto"); + expect(normalizedClientMode("nope")).toBe("build"); + }); + + test("classification gate is on only for auto mode, ordinary tools", () => { + expect(shouldAutoClassify("auto", "Bash")).toBe(true); + expect(shouldAutoClassify("build", "Bash")).toBe(false); + expect(shouldAutoClassify("yolo", "Bash")).toBe(false); + expect(shouldAutoClassify("auto", "AskUserQuestion")).toBe(false); + expect(shouldAutoClassify("auto", "ExitPlanMode")).toBe(false); + }); +});