Skip to content
Closed
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
236 changes: 236 additions & 0 deletions packages/zcode-tui/src/auto-permissions.ts
Original file line number Diff line number Diff line change
@@ -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<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",
"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")),

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in dd46e97: find moved from the unconditional prefix allowlist to an argument-aware rule — auto-allowed only when the whole command carries no mutating action (-exec/-execdir/-ok/-okdir, and -delete/-fprint*/-fls); the lookahead scans the full command string, so quoted separators and compound find . ; find . -exec forms cannot rescind the guard, and any blocked form falls through to the human dialog. Covered by the "built-in find allowlist: execution actions" tests in test/auto-permissions.test.ts, including quoted-flag, quoted-separator, compound, and benign -executable pins.

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 }
}
70 changes: 62 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,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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 ?? [])];
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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.`,
Expand Down Expand Up @@ -3649,7 +3688,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 +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;
}

Expand Down Expand Up @@ -4436,20 +4478,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 = runtimeResultClientMode(returnedMode, requestedMode);
this.updateMetadata();
} catch (error) {
this.addNotice(error instanceof Error ? error.message : String(error), "error");
Expand Down
Loading