From f90054b5b55cdc1c3d0126960c6279e62304890b Mon Sep 17 00:00:00 2001 From: Wesley Matos Date: Wed, 9 Sep 2026 00:07:39 -0300 Subject: [PATCH] feat(tui): learn Always-allow answers into a project policy file Mirrors the dialog's 'Always allow in this project' response (the runtime's addRules payload) into a project-local policy overlay at /.zcode/auto-permissions.json. Learned rules are sparse (only learnings are stored), merged over the built-ins at load time, and re-read on every prompt, so a dialog answer teaches the classifier for the rest of the session and future sessions in the same project. Layering: built-ins <- learned file <- ZCODE_AUTO_PERMISSIONS_CONFIG. The learned file preserves an existing defaults.unmatched value and deduplicates identical rules. Learnings are best-effort: failures log a notice and never disturb the permission flow. Live proof over a PTY (isolated HOME): an 'Always allow' answer on third.txt produced the learned rule in the probe project, and a fresh session auto-answered a later Write to the same path in 14s with no dialog (covered by the learned rule), while an unrelated path still rendered the human dialog. --- packages/zcode-tui/src/auto-permissions.ts | 149 +++++++++++++++++++-- packages/zcode-tui/src/index.ts | 23 +++- test/auto-permissions-learn.test.ts | 90 +++++++++++++ 3 files changed, 249 insertions(+), 13 deletions(-) create mode 100644 test/auto-permissions-learn.test.ts diff --git a/packages/zcode-tui/src/auto-permissions.ts b/packages/zcode-tui/src/auto-permissions.ts index c4d0121..e1a042a 100644 --- a/packages/zcode-tui/src/auto-permissions.ts +++ b/packages/zcode-tui/src/auto-permissions.ts @@ -12,7 +12,8 @@ // 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 { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs" +import { dirname, join } from "node:path" import { asString, isRecord } from "./types.ts" @@ -92,9 +93,7 @@ export function builtinAutoPermissionConfig(): AutoPermissionConfig { } } -export function loadAutoPermissionConfig(configPath: string | undefined): AutoPermissionConfig { - const base = builtinAutoPermissionConfig() - if (!configPath) return base +function readConfigFile(configPath: string, base: AutoPermissionConfig): AutoPermissionConfig { try { if (!existsSync(configPath)) return base const parsed: unknown = JSON.parse(readFileSync(configPath, "utf8")) @@ -122,16 +121,34 @@ export function loadAutoPermissionConfig(configPath: string | undefined): AutoPe } return { defaults: { unmatched }, - allow: rules("allow"), - softDeny: rules("softDeny"), - hardDeny: rules("hardDeny") + // File rules layer on top of built-ins: learned/custom rules can add + // to the base policy, and the built-ins always stay active. + allow: [...base.allow, ...rules("allow")], + softDeny: [...base.softDeny, ...rules("softDeny")], + hardDeny: [...base.hardDeny, ...rules("hardDeny")] } } catch { - // A broken config file must never break the TUI: fall back to built-ins. + // A broken config file must never break the TUI: fall back to the input. return base } } +// Load order (later layers add rules on top of earlier ones): +// built-ins → /.zcode/auto-permissions.json (learned rules) +// → ZCODE_AUTO_PERMISSIONS_CONFIG (explicit user override) +export function loadAutoPermissionConfig(configPath: string | undefined, projectRoot?: string): AutoPermissionConfig { + let config = builtinAutoPermissionConfig() + if (projectRoot) config = readConfigFile(learnedPolicyPath(projectRoot), config) + if (configPath) config = readConfigFile(configPath, config) + return config +} + +// The full layered policy the overlay should evaluate: built-ins + learned +// file + explicit env override, in one config. +export function effectiveAutoPermissionConfig(projectRoot: string | undefined, envConfigPath: string | undefined): AutoPermissionConfig { + return loadAutoPermissionConfig(envConfigPath, projectRoot) +} + function commandOf(input: unknown): string { if (!isRecord(input)) return "" return asString(input.command) ?? "" @@ -193,10 +210,113 @@ export function classifyPermissionRequest( } } -// 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 } +// ---- learning: mirror "Always allow" dialog answers into the policy file ---- +// +// The dialog's "Always allow in this project" choice returns the runtime's +// addRules payload. We mirror it into a project-local policy file +// (/.zcode/auto-permissions.json) so learned rules are portable, +// diffable, and editable — and feed them back through the same classifier +// config the overlay already loads. + +export interface LearnedRule { + tool: string + ruleContent?: string + behavior: "allow" | "deny" +} + +export function extractLearningRule(response: unknown): LearnedRule | null { + if (!isRecord(response)) return null + const updates = response.permissionUpdates + if (!Array.isArray(updates)) return null + for (const update of updates) { + if (!isRecord(update) || update.type !== "addRules") continue + const rules = update.rules + if (!Array.isArray(rules) || rules.length === 0) continue + const first = rules.find(isRecord) + if (!first) continue + const toolName = asString(first.toolName) + if (!toolName) continue + const ruleContent = asString(first.ruleContent) + return { + tool: toolName, + ...(ruleContent ? { ruleContent } : {}), + behavior: "allow" + } + } + return null +} + +export function learnedPolicyPath(projectRoot: string): string { + return join(projectRoot, ".zcode", "auto-permissions.json") +} + +function learnedRuleToPolicyRule(rule: LearnedRule): AutoPermissionRule { + const base: AutoPermissionRule = { tool: rule.tool, note: "learned from dialog" } + if (!rule.ruleContent) return base + return rule.behavior === "allow" + ? { ...base, pathPrefix: rule.ruleContent } + : { ...base, commandRegex: escapeRegExp(rule.ruleContent) } +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&") +} + +export function appendLearnedRule(projectRoot: string, rule: LearnedRule): string { + const path = learnedPolicyPath(projectRoot) + // The learned file is a sparse overlay: it stores only learned rules so it + // stays small, diffable, and does not pin stale copies of the built-ins + // (which the loader always layers underneath). + let allow: AutoPermissionRule[] = [] + let softDeny: AutoPermissionRule[] = [] + let hardDeny: AutoPermissionRule[] = [] + try { + if (existsSync(path)) { + const parsed: unknown = JSON.parse(readFileSync(path, "utf8")) + if (isRecord(parsed)) { + if (Array.isArray(parsed.allow)) allow = parsed.allow as AutoPermissionRule[] + if (Array.isArray(parsed.softDeny)) softDeny = parsed.softDeny as AutoPermissionRule[] + if (Array.isArray(parsed.hardDeny)) hardDeny = parsed.hardDeny as AutoPermissionRule[] + } + } + } catch { + // Unreadable file: start from a fresh overlay rather than clobbering blindly. + } + const target = rule.behavior === "allow" ? allow : softDeny + const candidate = learnedRuleToPolicyRule(rule) + const duplicate = target.some((existing) => JSON.stringify(existing) === JSON.stringify(candidate)) + if (!duplicate) target.push(candidate) + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, `${JSON.stringify({ + defaults: { unmatched: existingUnmatched(path, allow, softDeny, hardDeny) }, + allow, + softDeny, + hardDeny + }, null, 2)}\n`) + return path +} + +function existingUnmatched( + path: string, + allow: AutoPermissionRule[], + softDeny: AutoPermissionRule[], + hardDeny: AutoPermissionRule[] +): "ask" | "allow" | "deny" { + try { + const parsed: unknown = JSON.parse(readFileSync(path, "utf8")) + if (isRecord(parsed) && isRecord(parsed.defaults)) { + const value = parsed.defaults.unmatched + if (value === "ask" || value === "allow" || value === "deny") return value + } + } catch { + // Fall through to the default below. + } + void allow + void softDeny + void hardDeny + return "ask" +} + // Auto classification is opt-in: it runs only in the client's auto overlay // mode, and only for ordinary tool-permission prompts. AskUserQuestion and @@ -207,6 +327,11 @@ export function shouldAutoClassify(mode: string | undefined, toolName: string): return normalized !== "askuserquestion" && normalized !== "exitplanmode" && normalized !== "exitplanmodev2" } +// 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 } + export function autoPermissionResponse( request: PermissionRequestShape, config: AutoPermissionConfig diff --git a/packages/zcode-tui/src/index.ts b/packages/zcode-tui/src/index.ts index c0f81f1..6dafaf1 100644 --- a/packages/zcode-tui/src/index.ts +++ b/packages/zcode-tui/src/index.ts @@ -58,7 +58,9 @@ import { BackgroundTaskEventStore } from "./background-task-events.ts"; import { readBackgroundTaskOutput } from "./background-task-output.ts"; import { BoundedToolText, toolTextValue } from "./bounded-tool-text.ts"; import { + appendLearnedRule, autoPermissionResponse, + extractLearningRule, loadAutoPermissionConfig, shouldAutoClassify } from "./auto-permissions.ts"; @@ -650,6 +652,7 @@ class ZCodeTui { private readonly editorHistory: string[] = []; private mode: ClientMode; private autoModeActive = false; + private readonly workspaceDirectory: string; private model: string; private tuiMode: TuiMode; private copyOnSelect = true; @@ -732,6 +735,7 @@ class ZCodeTui { ); this.mode = initialClientMode(options.initialMode, process.env.ZCODE_CLIENT_MODE); this.autoModeActive = this.mode === "auto"; + this.workspaceDirectory = options.workspaceDirectory ?? process.cwd(); this.model = modelLabel(options.initialModel); this.thoughtLevel = options.initialThoughtLevel; this.modelOptions = [...(options.modelOptions ?? [])]; @@ -3315,6 +3319,23 @@ class ZCodeTui { } this.updateToolView(tool, allowed ? "running" : decision === "deny" ? "rejected" : "cancelled"); } + if (this.autoModeActive) { + // Mirror "Always allow"-style dialog answers into the project policy + // file so the classifier learns them for future prompts. Best-effort: + // a write failure must never disturb the permission flow. + try { + const learned = extractLearningRule(response); + if (learned) { + const path = appendLearnedRule(this.workspaceDirectory, learned); + this.addNotice( + `auto-permissions · learned rule (${learned.tool}${learned.ruleContent ? ` · ${learned.ruleContent}` : ""}) → ${path}`, + "muted" + ); + } + } catch { + // ignore + } + } return response; } @@ -3346,7 +3367,7 @@ class ZCodeTui { const autoResponse = shouldAutoClassify(this.mode, toolName) ? autoPermissionResponse( { toolName, input: request.input, riskLevel: asString(request.riskLevel) }, - loadAutoPermissionConfig(process.env.ZCODE_AUTO_PERMISSIONS_CONFIG) + loadAutoPermissionConfig(process.env.ZCODE_AUTO_PERMISSIONS_CONFIG, this.workspaceDirectory) ) : null; if (autoResponse) { diff --git a/test/auto-permissions-learn.test.ts b/test/auto-permissions-learn.test.ts new file mode 100644 index 0000000..c1e644d --- /dev/null +++ b/test/auto-permissions-learn.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + extractLearningRule, + learnedPolicyPath, + appendLearnedRule +} from "../packages/zcode-tui/src/auto-permissions.ts"; + +describe("policy learning from dialog answers", () => { + test("extracts an addRules allow rule from an Always-allow response", () => { + const response = { + decision: "allow", + reason: "Approved for this project", + permissionUpdates: [ + { behavior: "allow", type: "addRules", rules: [{ toolName: "Write", ruleContent: "other.txt" }] } + ] + }; + expect(extractLearningRule(response)).toEqual({ + tool: "Write", + ruleContent: "other.txt", + behavior: "allow" + }); + }); + + test("returns null for plain allow/deny responses and non-addRules updates", () => { + expect(extractLearningRule({ decision: "allow", reason: "x" })).toBeNull(); + expect(extractLearningRule({ decision: "deny" })).toBeNull(); + expect(extractLearningRule(null)).toBeNull(); + expect(extractLearningRule(undefined)).toBeNull(); + expect(extractLearningRule({ + decision: "allow", + permissionUpdates: [{ type: "removeRules", rules: [{ toolName: "Write" }] }] + })).toBeNull(); + expect(extractLearningRule({ + decision: "allow", + permissionUpdates: [{ type: "addRules", rules: [] }] + })).toBeNull(); + }); + + test("learnedPolicyPath is project-local under .zcode", () => { + expect(learnedPolicyPath("/repo")).toBe(join("/repo", ".zcode", "auto-permissions.json")); + }); + + test("appendLearnedRule creates the project policy file and adds the rule", () => { + const dir = mkdtempSync(join(tmpdir(), "zc-learn-")); + const rule = { tool: "Write", ruleContent: "other.txt", behavior: "allow" as const }; + const path = appendLearnedRule(dir, rule); + expect(existsSync(path)).toBe(true); + const parsed = JSON.parse(readFileSync(path, "utf8")); + expect(parsed.defaults.unmatched).toBe("ask"); + expect(parsed.allow).toEqual([{ tool: "Write", pathPrefix: "other.txt", note: "learned from dialog" }]); + }); + + test("appendLearnedRule merges without duplicating existing rules", () => { + const dir = mkdtempSync(join(tmpdir(), "zc-learn-")); + const rule = { tool: "Write", ruleContent: "other.txt", behavior: "allow" as const }; + appendLearnedRule(dir, rule); + appendLearnedRule(dir, rule); + const parsed = JSON.parse(readFileSync(learnedPolicyPath(dir), "utf8")); + expect(parsed.allow.length).toBe(1); + }); + + test("appendLearnedRule preserves existing custom rules in the file", () => { + const dir = mkdtempSync(join(tmpdir(), "zc-learn-")); + const zcodeDir = join(dir, ".zcode"); + mkdirSync(zcodeDir, { recursive: true }); + writeFileSync(learnedPolicyPath(dir), JSON.stringify({ + defaults: { unmatched: "deny" }, + allow: [{ tool: "Bash", commandPrefix: "npm test" }], + softDeny: [], + hardDeny: [] + })); + appendLearnedRule(dir, { tool: "Write", ruleContent: "other.txt", behavior: "allow" }); + const parsed = JSON.parse(readFileSync(learnedPolicyPath(dir), "utf8")); + expect(parsed.defaults.unmatched).toBe("deny"); + expect(parsed.allow).toContainEqual({ tool: "Bash", commandPrefix: "npm test" }); + expect(parsed.allow).toContainEqual({ tool: "Write", pathPrefix: "other.txt", note: "learned from dialog" }); + }); + + test("appendLearnedRule never writes deny learnings as allow rules", () => { + const dir = mkdtempSync(join(tmpdir(), "zc-learn-")); + appendLearnedRule(dir, { tool: "Bash", ruleContent: "cargo build", behavior: "deny" }); + const parsed = JSON.parse(readFileSync(learnedPolicyPath(dir), "utf8")); + expect(parsed.allow).toEqual([]); + expect(parsed.softDeny).toEqual([{ tool: "Bash", commandRegex: "cargo build", note: "learned from dialog" }]); + }); +});