diff --git a/packages/zcode-tui/src/auto-permissions.ts b/packages/zcode-tui/src/auto-permissions.ts new file mode 100644 index 0000000..eee85f1 --- /dev/null +++ b/packages/zcode-tui/src/auto-permissions.ts @@ -0,0 +1,236 @@ +// 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. +// +// Boundaries are token-aware because these regexes also run against whole +// command strings, where the credential path sits mid-token: a dotfile name +// must not be glued to a preceding name character (`my.env` is a different +// file, `cat .env` is not), while anything after it that is not a name +// character ends the token — including shell separators (`;`, `|`, `&`, +// whitespace, quotes) and another dotted component (`.env.local` is the same +// credential family). `*.pem` is a suffix convention rather than a dotfile, +// so only its trailing boundary matters. +const secretPathPattern = String.raw`(^|[^A-Za-z0-9_.-])\.(env|ssh|aws|gnupg|kube|netrc|npmrc)($|[^A-Za-z0-9_-])|\.pem($|[^A-Za-z0-9_-])|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", + "which", + "file", + "stat" + ] + // `find` is read-only only while it carries no mutating action: -exec, + // -execdir, -ok and -okdir run arbitrary programs, and -delete, -fprint*, + // -fls write or remove files, so a bare `find` prefix rule would + // auto-allow `find . -exec sh ...`. The classifier sees the command as one + // string, so the lookahead scans the whole command — bounding it at a + // shell separator let `find . ; find . -exec ...` (or a separator inside a + // quoted argument) slip past — and the trailing \b keeps quoted flags + // covered without blocking -executable. Anything the guard blocks falls + // through to the dialog. + const findAllow = String.raw`^find\b(?![\s\S]*-(?:execdir|exec|okdir|ok|delete|fprintf|fprint0|fprint|fls)\b)` + return { + defaults: { unmatched: "ask" }, + allow: [ + ...readOnlyCommands.map((command) => ruleToBuiltin({ tool: "Bash", commandPrefix: command }, "read-only command")), + ruleToBuiltin({ tool: "Bash", commandRegex: findAllow }, "read-only command (find without mutating actions)"), + 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..751140b 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,18 @@ import { import { appliesToSetting, modes, + clientModes, + nextClientMode, nextMode, + initialClientMode, nextPickerCommand, nextPickerValue, + normalizedClientMode, normalizedMode, + runtimeResultClientMode, settingTargetForCommand, transcriptPageDirection, + type ClientMode, type Mode, type SettingTarget } from "./shortcuts.ts"; @@ -638,7 +649,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 +731,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 +2129,20 @@ 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. The + // confirmed value normalizes through the runtime-only validator — + // the reserved `auto` must not re-enter client mode here, or the + // classifier gate (which reads the mode alone) would stay armed + // after the overlay exited. + this.autoModeActive = false; + this.mode = runtimeResultClientMode(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 = runtimeResultClientMode(result.mode, this.mode); + } } if (appliesToSetting(settingTarget, "model") && result.model !== undefined) { this.model = modelLabel(result.model); @@ -3322,6 +3348,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 +3688,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 +3700,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 +4478,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 +4490,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 = runtimeResultClientMode(returnedMode, requestedMode); this.updateMetadata(); } catch (error) { this.addNotice(error instanceof Error ? error.message : String(error), "error"); diff --git a/packages/zcode-tui/src/shortcuts.ts b/packages/zcode-tui/src/shortcuts.ts index 26427ca..5ac2323 100644 --- a/packages/zcode-tui/src/shortcuts.ts +++ b/packages/zcode-tui/src/shortcuts.ts @@ -4,18 +4,62 @@ 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; } +// Client-side acceptance of a runtime-confirmed mode result (typed /mode +// answers, runtime state echoes, setMode confirmations). The runtime owns its +// enum and reserves `auto`, so confirmed values must normalize through the +// runtime-only validator above: letting the reserved value pass +// normalizedClientMode would leave the client mode at "auto" with the overlay +// exited, and the classifier gate reads the mode alone. +export function runtimeResultClientMode(resultMode: string | undefined, currentMode: ClientMode): ClientMode { + return normalizedMode(resultMode, normalizedMode(currentMode)); +} + export function nextMode(currentMode?: string): Mode { const currentIndex = modes.indexOf(normalizedMode(currentMode)); 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") { + // Reject a runtime genuinely in its reserved `auto` before normalizing: + // normalization would silently re-label it "build" and switch the overlay + // on over a deny-everything runtime. + if (runtimeMode === "auto") return normalizedMode(runtimeMode) + 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/scripts/smoke-tui-features.ts b/scripts/smoke-tui-features.ts index 2023b84..e2b0168 100644 --- a/scripts/smoke-tui-features.ts +++ b/scripts/smoke-tui-features.ts @@ -121,6 +121,9 @@ try { await sendAndWait("/help\r", "long help", /Use \/help for details/i); await sendAndWait("\x1b[Z", "edit mode shortcut", /◈ alpha\/model ─ ◉ edit ─ ⚡ low/i); await Bun.sleep(1_100); + // The auto client mode sits between edit and yolo in the Shift+Tab cycle: + // edit -> auto (classifier overlay, footer shows auto) -> yolo -> plan. + await sendAndWait("\x1b[Z", "auto mode shortcut", /◈ alpha\/model ─ ◉ auto ─ ⚡ low/i); await sendAndWait("\x1b[Z", "yolo mode shortcut", /◈ alpha\/model ─ ◉ yolo ─ ⚡ low/i); await sendAndWait("\x1b[Z", "plan mode shortcut", /◈ alpha\/model ─ ◉ plan ─ ⚡ low/i); await sendAndWait("\x0e", "model shortcut", /◈ beta\/model ─ ◉ plan ─ ⚡ low/i); @@ -564,6 +567,7 @@ if (plain.includes("feature-secret-api-key") || plain.includes("override-fixture let stateOffset = 0; for (const [label, pattern] of [ ["edit mode shortcut", /◈ alpha\/model ─ ◉ edit ─ ⚡ low/i], + ["auto mode shortcut", /◈ alpha\/model ─ ◉ auto ─ ⚡ low/i], ["yolo mode shortcut", /◈ alpha\/model ─ ◉ yolo ─ ⚡ low/i], ["plan mode shortcut", /◈ alpha\/model ─ ◉ plan ─ ⚡ low/i], ["model shortcut preserving plan", /◈ beta\/model ─ ◉ plan ─ ⚡ low/i], diff --git a/test/auto-permissions.test.ts b/test/auto-permissions.test.ts new file mode 100644 index 0000000..07a6d5e --- /dev/null +++ b/test/auto-permissions.test.ts @@ -0,0 +1,185 @@ +import { describe, expect, test } from "bun:test"; + +import { + autoPermissionResponse, + builtinAutoPermissionConfig, + 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(); + }); +}); + +describe("built-in credential deny: token boundaries (regression: `cat .env` bypass)", () => { + const config = builtinAutoPermissionConfig(); + const bashBehavior = (command: string) => + classifyPermissionRequest({ toolName: "Bash", input: { command }, riskLevel: "medium" }, config)?.behavior; + const pathBehavior = (toolName: string, file_path: string) => + classifyPermissionRequest({ toolName, input: { file_path }, riskLevel: "medium" }, config)?.behavior; + + test("relative credential paths in commands are hard-denied, not auto-allowed", () => { + for (const command of [ + "cat .env", + "git diff -- .env", + "head -50 .npmrc", + "git show HEAD:.env", + "cat ~/.ssh/config", + // Quoted paths still land on the token boundary. + 'cat ".env"' + ]) { + expect(bashBehavior(command)).toBe("deny"); + } + }); + + test("credential path followed by a shell separator is hard-denied", () => { + expect(bashBehavior(".env;cat /etc/passwd")).toBe("deny"); + expect(bashBehavior("cat key.pem && echo done")).toBe("deny"); + }); + + test("dotted credential variants are denied for direct file tools", () => { + expect(pathBehavior("Read", ".env.local")).toBe("deny"); + expect(pathBehavior("Read", "/repo/.env.production")).toBe("deny"); + expect(pathBehavior("Edit", "keys/server.pem.bak")).toBe("deny"); + expect(pathBehavior("Read", "certs/server.pem")).toBe("deny"); + }); + + test("similarly named non-credential files are not denied (no false-positive widening)", () => { + expect(bashBehavior("cat environment")).toBe("allow"); + expect(bashBehavior("cat .environment")).toBe("allow"); + expect(bashBehavior("cat notes/.envsample")).toBe("allow"); + expect(pathBehavior("Read", "src/my.env")).toBe("allow"); + }); +}); + +describe("built-in find allowlist: execution actions (regression: `find . -exec sh` auto-allow)", () => { + const config = builtinAutoPermissionConfig(); + const verdictFor = (command: string) => + classifyPermissionRequest({ toolName: "Bash", input: { command }, riskLevel: "high" }, config); + + test("plain find stays auto-allowed", () => { + expect(verdictFor("find . -name '*.test.ts'")?.behavior).toBe("allow"); + expect(verdictFor("find src -type f -newer README.md")?.behavior).toBe("allow"); + // Only the trailing word boundary keeps -executable distinct from -exec. + expect(verdictFor("find . -type f -executable")?.behavior).toBe("allow"); + }); + + test("find with -exec/-execdir/-ok/-okdir falls through to the dialog", () => { + expect(verdictFor("find . -exec sh -c 'touch /tmp/pwned' \\;")).toBeNull(); + expect(verdictFor("find . -type f -execdir chmod 777 {} +")).toBeNull(); + expect(verdictFor("find . -ok rm {} \\;")).toBeNull(); + expect(verdictFor("find . -okdir rm {} \\;")).toBeNull(); + // A quoted flag still reaches find's parser, so the guard must too. + expect(verdictFor("find . '-exec' sh -c 'x' \\;")).toBeNull(); + // Separators must not rescind the guard: the classifier sees one + // command string, whether the separator is quoted or compound. + expect(verdictFor("find . -name 'a|b' -exec sh -c 'x' \\;")).toBeNull(); + expect(verdictFor("find . ; find . -exec sh -c 'x' \\;")).toBeNull(); + }); + + test("find's file-mutating actions fall through to the dialog", () => { + expect(verdictFor("find . -name '*.log' -delete")).toBeNull(); + expect(verdictFor("find . -fprintf /tmp/pwned '%p\\n'")).toBeNull(); + expect(verdictFor("find . -fls /tmp/pwned")).toBeNull(); + }); + + test("file names that merely contain a flag substring fail safe to the dialog", () => { + expect(verdictFor("find . -name 'foo-exec'")).toBeNull(); + }); +}); diff --git a/test/shortcuts-auto.test.ts b/test/shortcuts-auto.test.ts new file mode 100644 index 0000000..a426df3 --- /dev/null +++ b/test/shortcuts-auto.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, test } from "bun:test"; + +import { clientModes, initialClientMode, nextClientMode, nextMode, normalizedClientMode, normalizedMode, runtimeResultClientMode } 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("env auto over a runtime genuinely in reserved auto is rejected, not re-labeled", () => { + // The overlay is documented to ride only on a build runtime; a runtime in + // its reserved `auto` denies every prompt, so the overlay must stay off. + // (Regression: normalization first silently re-labeled it "build".) + expect(initialClientMode("auto", "auto")).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); + }); +}); + +describe("runtime-confirmed mode results (regression: typed /mode re-armed the classifier)", () => { + test("reserved auto from the runtime rejects instead of re-entering client mode", () => { + // Overlay was active (mode "auto"); a typed `/mode auto` confirms the + // runtime's reserved value and exits the overlay. The confirmed result + // must not leave the client mode at "auto" — the classifier gate reads + // the mode alone. + expect(runtimeResultClientMode("auto", "auto")).toBe("build"); + expect(runtimeResultClientMode("auto", "build")).toBe("build"); + }); + + test("genuine confirmed values pass through; missing results keep the normalized current", () => { + expect(runtimeResultClientMode("edit", "auto")).toBe("edit"); + expect(runtimeResultClientMode("yolo", "plan")).toBe("yolo"); + expect(runtimeResultClientMode(undefined, "yolo")).toBe("yolo"); + expect(runtimeResultClientMode("nope", "edit")).toBe("edit"); + }); + + test("classifier gate cannot be re-armed by a runtime-confirmed reserved auto", () => { + expect(shouldAutoClassify(runtimeResultClientMode("auto", "auto"), "Bash")).toBe(false); + }); +});