Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
217 changes: 217 additions & 0 deletions packages/zcode-tui/src/auto-permissions.ts
Original file line number Diff line number Diff line change
@@ -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<AutoPermissionRule, "note">, 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 }
}
65 changes: 57 additions & 8 deletions packages/zcode-tui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 ?? [])];
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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.`,
Expand Down Expand Up @@ -3649,7 +3683,7 @@ class ZCodeTui {
* setMode bridge so the runtime owns the exact mode-switching semantics.
*/
private async showModePicker(): Promise<boolean> {
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",
Expand All @@ -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;
}

Expand Down Expand Up @@ -4436,20 +4473,32 @@ class ZCodeTui {

private async switchMode(): Promise<void> {
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<void> {
private async applyModeShortcut(requestedMode: Mode | ClientMode): Promise<void> {
if (this.settingSwitchInFlight) return;
if (!this.options.setMode) {
this.addNotice("Mode switching is unavailable in this runtime.", "warning");
return;
}
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");
Expand Down
Loading