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
149 changes: 137 additions & 12 deletions packages/zcode-tui/src/auto-permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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"))
Expand Down Expand Up @@ -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 → <project>/.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) ?? ""
Expand Down Expand Up @@ -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
// (<cwd>/.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
Expand All @@ -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
Expand Down
23 changes: 22 additions & 1 deletion packages/zcode-tui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 ?? [])];
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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) {
Expand Down
90 changes: 90 additions & 0 deletions test/auto-permissions-learn.test.ts
Original file line number Diff line number Diff line change
@@ -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" }]);
});
});