diff --git a/src/acp/agent.ts b/src/acp/agent.ts index ee8358c..bf021c2 100644 --- a/src/acp/agent.ts +++ b/src/acp/agent.ts @@ -34,7 +34,8 @@ import { seedSystemMessages } from "../agent/system-prompt.js"; import { stripStrayTextToolCallArtifacts } from "../agent/text-tool-artifacts.js"; import { checkPermission, loadPermissions, persistAlwaysAllow, SAFE_TOOLS, - type PermDecision, type PermissionsFile, + loadClaudeCompatRules, + type PermDecision, type PermissionsFile, type CompiledRules, } from "../permissions/index.js"; import { resolveProjectId } from "../utils/project-id.js"; import { loadConfig } from "../auth/credentials.js"; @@ -232,7 +233,7 @@ export class AcpAgent { private async runTool( sessionId: string, projectRoot: string, client: KlaatAIClient, tc: ToolCall, - perms: PermissionsFile, sessionApproved: Set, + perms: PermissionsFile, sessionApproved: Set, claudeCompatRules: CompiledRules | null, ): Promise { const name = tc.function.name; let args: Record = {}; @@ -247,7 +248,7 @@ export class AcpAgent { const isSafe = SAFE_TOOLS.has(name) || name === "todo_write"; if (!isSafe && !sessionApproved.has(name)) { - const check = checkPermission(tc, perms); + const check = checkPermission(tc, perms, claudeCompatRules); if (check === "deny") { this.notifyUpdate(sessionId, { sessionUpdate: "tool_call_update", toolCallId: tc.id, status: "failed", content: [textContent("Permission denied (matched a deny rule).")] }); return "Error: Permission denied (matched deny rule)."; @@ -299,6 +300,9 @@ export class AcpAgent { const perms = loadPermissions(); const sessionApproved = new Set(); const config = loadConfig(); + const claudeCompatRules = config.compat?.importClaudeSettings !== false + ? loadClaudeCompatRules(state.projectRoot) + : null; const maxTurns = 60; // editor sessions run longer than a single bench task let turns = 0; let loopRefusals = 0; @@ -365,7 +369,7 @@ export class AcpAgent { for (const tc of pendingToolCalls) { if (state.cancelled) return { stopReason: "cancelled" as StopReason }; - const result = await this.runTool(params.sessionId, state.projectRoot, client, tc, perms, sessionApproved); + const result = await this.runTool(params.sessionId, state.projectRoot, client, tc, perms, sessionApproved, claudeCompatRules); state.messages.push({ role: "tool", content: result.slice(0, 20_000), tool_call_id: tc.id }); } continue; diff --git a/src/auth/credentials.ts b/src/auth/credentials.ts index edc6cdc..8185a9c 100644 --- a/src/auth/credentials.ts +++ b/src/auth/credentials.ts @@ -69,9 +69,10 @@ export interface Config { importClaudeSkills?: boolean; /** Import MCP servers from .mcp.json / .claude.json / .cursor/mcp.json (default: true). */ importMcpConfigs?: boolean; + /** Honor permissions.allow/deny/ask from .claude/settings.json (default: true). */ + importClaudeSettings?: boolean; }; } - export interface CustomModelConfig { /** Display name used to select it: /model . */ name: string; diff --git a/src/permissions/claude-settings.test.ts b/src/permissions/claude-settings.test.ts new file mode 100644 index 0000000..703e114 --- /dev/null +++ b/src/permissions/claude-settings.test.ts @@ -0,0 +1,216 @@ +import { expect, test, describe } from "bun:test"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { type ToolCall } from "../api/client.js"; +import { checkPermission, type PermissionsFile } from "./index.js"; +import { + loadClaudeCompatRules, + parseRule, + ruleMatchesCall, + checkImportedRules, + summarizeCompatRules, +} from "./claude-settings.js"; + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +function tc(name: string, args: Record): ToolCall { + return { id: "1", type: "function", function: { name, arguments: JSON.stringify(args) } }; +} + +function withProject(files: Record): string { + const root = mkdtempSync(join(tmpdir(), "klaatai-claude-compat-")); + for (const [rel, content] of Object.entries(files)) { + const abs = join(root, rel); + mkdirSync(join(abs, ".."), { recursive: true }); + writeFileSync(abs, content); + } + return root; +} + +const EMPTY_PERMS: PermissionsFile = { trusted_tools: [], allowed_commands: [], denied_commands: [] }; + +// ─── Rule parsing ─────────────────────────────────────────────────────────── + +describe("parseRule", () => { + test("bare tool name", () => { + expect(parseRule("Bash")).toEqual({ raw: "Bash", tool: "Bash", arg: undefined }); + }); + test("tool with arg", () => { + expect(parseRule("Bash(git *)")).toEqual({ raw: "Bash(git *)", tool: "Bash", arg: "git *" }); + }); + test("WebFetch domain rule", () => { + expect(parseRule("WebFetch(domain:example.com)")?.arg).toBe("domain:example.com"); + }); + test("malformed rule returns null", () => { + expect(parseRule("(nope")).toBeNull(); + }); +}); + +// ─── loadClaudeCompatRules: no-file no-op ────────────────────────────────── + +describe("loadClaudeCompatRules — no settings files", () => { + test("returns null when neither file exists (no behavior change)", () => { + const root = withProject({}); + expect(loadClaudeCompatRules(root)).toBeNull(); + rmSync(root, { recursive: true, force: true }); + }); +}); + +// ─── Fixture: allow / deny / ask honored ─────────────────────────────────── + +describe("fixture: allow/deny/ask each honored", () => { + const root = withProject({ + ".claude/settings.json": JSON.stringify({ + permissions: { + allow: ["Bash(git *)"], + deny: ["Bash(rm *)"], + ask: ["Bash(npm publish*)"], + }, + }), + }); + const rules = loadClaudeCompatRules(root); + + test("compiles allow/deny/ask into separate buckets", () => { + expect(rules).not.toBeNull(); + expect(rules!.allow).toHaveLength(1); + expect(rules!.deny).toHaveLength(1); + expect(rules!.ask).toHaveLength(1); + }); + + test("allow rule permits a matching Bash call", () => { + expect(checkImportedRules(tc("run_command", { command: "git status" }), rules!)).toBe("allow"); + }); + + test("deny rule rejects a matching Bash call", () => { + expect(checkImportedRules(tc("run_command", { command: "rm -rf build" }), rules!)).toBe("deny"); + }); + + test("ask rule surfaces a prompt for a matching Bash call", () => { + expect(checkImportedRules(tc("run_command", { command: "npm publish --tag next" }), rules!)).toBe("ask"); + }); + + test("non-matching call falls through to null", () => { + expect(checkImportedRules(tc("run_command", { command: "ls -la" }), rules!)).toBeNull(); + }); + + test("checkPermission honors an imported allow when native tier has no opinion", () => { + expect(checkPermission(tc("run_command", { command: "git log --oneline" }), EMPTY_PERMS, rules)).toBe("allow"); + }); + + test("checkPermission honors an imported deny", () => { + expect(checkPermission(tc("run_command", { command: "rm -rf node_modules" }), EMPTY_PERMS, rules)).toBe("deny"); + }); +}); + +// ─── Glob patterns ────────────────────────────────────────────────────────── + +describe("glob patterns", () => { + const root = withProject({ + ".claude/settings.json": JSON.stringify({ + permissions: { allow: ["Edit(src/**)"], deny: ["Edit(src/secrets/*)"] }, + }), + }); + const rules = loadClaudeCompatRules(root)!; + + test("** crosses directories for Edit rules", () => { + expect(ruleMatchesCall(rules.allow[0]!, tc("edit_file", { path: "src/a/b/c.ts" }))).toBe(true); + }); + + test("single * stays within a path segment for deny rules", () => { + expect(ruleMatchesCall(rules.deny[0]!, tc("edit_file", { path: "src/secrets/key.pem" }))).toBe(true); + expect(ruleMatchesCall(rules.deny[0]!, tc("edit_file", { path: "src/secrets/nested/key.pem" }))).toBe(false); + }); + + test("Edit rules also match apply_patch by scanning patch file headers", () => { + const patch = "*** Update File: src/app/index.ts\n@@\n-old\n+new\n"; + expect(ruleMatchesCall(rules.allow[0]!, tc("apply_patch", { patch }))).toBe(true); + }); + + test("Edit rules match multi_edit via its path field", () => { + expect(ruleMatchesCall(rules.allow[0]!, tc("multi_edit", { path: "src/util.ts", edits: [] }))).toBe(true); + }); + + test("WebFetch domain rule matches subdomains", () => { + const wf = parseRule("WebFetch(domain:example.com)")!; + expect(ruleMatchesCall(wf, tc("web_fetch", { url: "https://docs.example.com/api" }))).toBe(true); + expect(ruleMatchesCall(wf, tc("web_fetch", { url: "https://evil.com" }))).toBe(false); + }); +}); + +// ─── Precedence ───────────────────────────────────────────────────────────── + +describe("precedence", () => { + test("deny beats allow within the imported layer", () => { + const root = withProject({ + ".claude/settings.json": JSON.stringify({ + permissions: { allow: ["Bash(git *)"], deny: ["Bash(git push*)"] }, + }), + }); + const rules = loadClaudeCompatRules(root)!; + expect(checkImportedRules(tc("run_command", { command: "git push origin main" }), rules)).toBe("deny"); + }); + + test("native config always outranks imported rules", () => { + const root = withProject({ + ".claude/settings.json": JSON.stringify({ permissions: { deny: ["Bash"] } }), + }); + const rules = loadClaudeCompatRules(root)!; + // Native tier 1: run_command isn't in SAFE_TOOLS, but a native trusted_tools + // entry or an explicit native allow should still win over an imported deny. + const perms: PermissionsFile = { trusted_tools: [], allowed_commands: ["git status"], denied_commands: [] }; + expect(checkPermission(tc("run_command", { command: "git status" }), perms, rules)).toBe("allow"); + }); + + test("local settings win over global on a conflicting rule", () => { + const root = withProject({ + ".claude/settings.json": JSON.stringify({ permissions: { deny: ["Bash(git *)"] } }), + ".claude/settings.local.json": JSON.stringify({ permissions: { allow: ["Bash(git *)"] } }), + }); + const rules = loadClaudeCompatRules(root)!; + expect(rules.deny).toHaveLength(0); + expect(rules.allow).toHaveLength(1); + expect(checkImportedRules(tc("run_command", { command: "git status" }), rules)).toBe("allow"); + }); +}); + +// ─── Unmappable-rule skip ─────────────────────────────────────────────────── + +describe("unmappable rules are logged and skipped, not applied", () => { + test("unknown tool name is collected separately and never matches", () => { + const root = withProject({ + ".claude/settings.json": JSON.stringify({ + permissions: { allow: ["NotebookEdit(*.ipynb)", "Bash(git *)"] }, + }), + }); + const rules = loadClaudeCompatRules(root)!; + expect(rules.allow).toHaveLength(1); // only the Bash rule compiled + expect(rules.unmappable).toHaveLength(1); + expect(rules.unmappable[0]!.tool).toBe("NotebookEdit"); + }); + + test("summary reports unmappable count", () => { + const root = withProject({ + ".claude/settings.json": JSON.stringify({ + permissions: { allow: ["NotebookEdit(*.ipynb)"] }, + }), + }); + const rules = loadClaudeCompatRules(root); + const summary = summarizeCompatRules(rules); + expect(summary).toContain("1 unmappable rule"); + }); +}); + +// ─── No .claude/settings.json: zero behavior change ──────────────────────── + +describe("no behavior change when no .claude/settings.json exists", () => { + test("checkPermission behaves identically with null imported rules", () => { + const root = withProject({}); + const rules = loadClaudeCompatRules(root); + expect(rules).toBeNull(); + const withoutLayer = checkPermission(tc("edit_file", { path: "src/x.ts" }), EMPTY_PERMS); + const withNullLayer = checkPermission(tc("edit_file", { path: "src/x.ts" }), EMPTY_PERMS, rules); + expect(withoutLayer).toBe(withNullLayer); + expect(withNullLayer).toBe("ask"); + }); +}); \ No newline at end of file diff --git a/src/permissions/claude-settings.ts b/src/permissions/claude-settings.ts new file mode 100644 index 0000000..6f8a201 --- /dev/null +++ b/src/permissions/claude-settings.ts @@ -0,0 +1,231 @@ +/** + * Compat: honor permissions.allow/deny/ask from .claude/settings.json (and + * .claude/settings.local.json, which wins on conflict) so teams switching + * from Claude Code keep their curated guardrails. + * + * This is a rule-matching layer that sits *in front of* the native + * permission model in ./index.ts. It never overrides a native decision — + * see resolveImportedDecision() in index.ts for how the two are combined. + */ + +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { type ToolCall } from "../api/client.js"; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +export interface ClaudeSettingsFile { + permissions?: { + allow?: string[]; + deny?: string[]; + ask?: string[]; + }; +} + +export interface ParsedRule { + /** The raw rule string, e.g. "Bash(git *)" — kept for logging/tests. */ + raw: string; + /** Claude Code tool name, e.g. "Bash", "Edit". */ + tool: string; + /** Text inside the parens, if any, e.g. "git *" or "domain:example.com". */ + arg?: string; +} + +export interface CompiledRules { + allow: ParsedRule[]; + deny: ParsedRule[]; + ask: ParsedRule[]; + /** Rules whose tool name has no mapping onto our tools — logged, then skipped. */ + unmappable: ParsedRule[]; +} + +export type ImportedDecision = "allow" | "deny" | "ask" | null; + +// ─── Tool name mapping ─────────────────────────────────────────────────────── + +/** Claude Code tool name -> our internal tool name(s). */ +export const CLAUDE_TOOL_MAP: Record = { + Bash: ["run_command"], + Read: ["read_file"], + Write: ["write_file"], + Edit: ["edit_file", "multi_edit", "apply_patch"], + Glob: ["glob"], + Grep: ["grep"], + LS: ["list_dir"], + WebFetch: ["web_fetch"], + WebSearch: ["web_search"], +}; + +// ─── Parsing ───────────────────────────────────────────────────────────────── + +const RULE_RE = /^([A-Za-z_]+)(?:\((.*)\))?$/; + +export function parseRule(raw: string): ParsedRule | null { + const m = RULE_RE.exec(raw.trim()); + if (!m) return null; + const arg = m[2]; + return { raw, tool: m[1]!, arg: arg === undefined ? undefined : arg.trim() }; +} + +/** + * Merge global (.claude/settings.json) and local (.claude/settings.local.json) + * rule lists. Local wins: any rule string present in local is removed from + * global's categories first, so a rule can't linger in both an allow and a + * deny list after a local override. + */ +function mergeCategories( + global: ClaudeSettingsFile["permissions"], + local: ClaudeSettingsFile["permissions"], +): { allow: string[]; deny: string[]; ask: string[] } { + const g = { allow: global?.allow ?? [], deny: global?.deny ?? [], ask: global?.ask ?? [] }; + const l = { allow: local?.allow ?? [], deny: local?.deny ?? [], ask: local?.ask ?? [] }; + const localRules = new Set([...l.allow, ...l.deny, ...l.ask]); + + const dedupe = (base: string[], override: string[]) => + Array.from(new Set([...base.filter(r => !localRules.has(r)), ...override])); + + return { + allow: dedupe(g.allow, l.allow), + deny: dedupe(g.deny, l.deny), + ask: dedupe(g.ask, l.ask), + }; +} + +function readSettingsFile(path: string): ClaudeSettingsFile | null { + try { + if (!existsSync(path)) return null; + return JSON.parse(readFileSync(path, "utf-8")) as ClaudeSettingsFile; + } catch { + return null; + } +} + +/** + * Load and compile .claude/settings.json (+ settings.local.json) from a + * project root. Returns null when neither file exists, so callers can skip + * the whole layer with zero behavior change (acceptance criterion). + */ +export function loadClaudeCompatRules(projectRoot: string): CompiledRules | null { + const globalPath = join(projectRoot, ".claude", "settings.json"); + const localPath = join(projectRoot, ".claude", "settings.local.json"); + const global = readSettingsFile(globalPath); + const local = readSettingsFile(localPath); + if (!global && !local) return null; + + const merged = mergeCategories(global?.permissions, local?.permissions); + const compiled: CompiledRules = { allow: [], deny: [], ask: [], unmappable: [] }; + + for (const [category, rules] of [ + ["allow", merged.allow], + ["deny", merged.deny], + ["ask", merged.ask], + ] as const) { + for (const raw of rules) { + const parsed = parseRule(raw); + if (!parsed) { compiled.unmappable.push({ raw, tool: raw }); continue; } + if (!CLAUDE_TOOL_MAP[parsed.tool]) { compiled.unmappable.push(parsed); continue; } + compiled[category].push(parsed); + } + } + + return compiled; +} + +// ─── Matching ──────────────────────────────────────────────────────────────── + +/** `*` -> any run of chars (used for Bash command patterns). */ +function globToRegexSingleStar(pattern: string): RegExp { + const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*"); + return new RegExp(`^${escaped}$`); +} + +/** Gitignore-ish glob: `**` crosses directories, `*` stays within a segment. */ +function globToRegexPath(pattern: string): RegExp { + const placeholder = "\u0000DOUBLESTAR\u0000"; + let escaped = pattern + .replace(/\*\*/g, placeholder) + .replace(/[.+^${}()|[\]\\]/g, "\\$&") + .replace(/\*/g, "[^/]*") + .replace(new RegExp(placeholder, "g"), ".*"); + return new RegExp(`^${escaped}$`); +} + +function extractPatchPaths(patch: string): string[] { + const paths: string[] = []; + const re = /^\*\*\* (?:Add|Update|Delete) File:\s*(.+)$/gm; + let m: RegExpExecArray | null; + while ((m = re.exec(patch)) !== null) paths.push(m[1]!.trim()); + return paths; +} + +function safeArgs(tc: ToolCall): Record { + try { return JSON.parse(tc.function.arguments) as Record; } + catch { return {}; } +} + +/** Does a single parsed rule match this tool call? */ +export function ruleMatchesCall(rule: ParsedRule, tc: ToolCall): boolean { + const mapped = CLAUDE_TOOL_MAP[rule.tool]; + if (!mapped || !mapped.includes(tc.function.name)) return false; + if (rule.arg === undefined) return true; // bare tool name — matches every call + + const args = safeArgs(tc); + + switch (rule.tool) { + case "Bash": { + const cmd = String(args["command"] ?? ""); + return globToRegexSingleStar(rule.arg.trim()).test(cmd.trim()); + } + case "WebFetch": { + const domainMatch = /^domain:(.+)$/.exec(rule.arg.trim()); + if (!domainMatch) return false; + try { + const host = new URL(String(args["url"] ?? "")).hostname; + return host === domainMatch[1] || host.endsWith(`.${domainMatch[1]}`); + } catch { return false; } + } + case "Read": + case "Write": + case "Glob": + case "Grep": + case "LS": { + const path = String(args["path"] ?? args["pattern"] ?? ""); + return globToRegexPath(rule.arg.trim()).test(path); + } + case "Edit": { + if (tc.function.name === "apply_patch") { + const patch = String(args["patch"] ?? ""); + const re = globToRegexPath(rule.arg.trim()); + return extractPatchPaths(patch).some(p => re.test(p)); + } + const path = String(args["path"] ?? ""); + return globToRegexPath(rule.arg.trim()).test(path); + } + default: + return false; + } +} + +/** + * Check a tool call against imported .claude rules only. + * Precedence within this layer: deny > ask > allow. + * Returns null when nothing matches (caller falls back to native "ask"). + */ +export function checkImportedRules(tc: ToolCall, rules: CompiledRules): ImportedDecision { + if (rules.deny.some(r => ruleMatchesCall(r, tc))) return "deny"; + if (rules.ask.some(r => ruleMatchesCall(r, tc))) return "ask"; + if (rules.allow.some(r => ruleMatchesCall(r, tc))) return "allow"; + return null; +} + +/** One-line startup summary, or null if there's nothing to report. */ +export function summarizeCompatRules(rules: CompiledRules | null): string | null { + if (!rules) return null; + const total = rules.allow.length + rules.deny.length + rules.ask.length; + if (total === 0 && rules.unmappable.length === 0) return null; + const parts = [`${total} rule${total === 1 ? "" : "s"} from .claude/settings.json`]; + if (rules.unmappable.length > 0) { + parts.push(`${rules.unmappable.length} unmappable rule${rules.unmappable.length === 1 ? "" : "s"} skipped`); + } + return `Compat: honoring ${parts.join(", ")}.`; +} \ No newline at end of file diff --git a/src/permissions/index.ts b/src/permissions/index.ts index bac69db..7177f1a 100644 --- a/src/permissions/index.ts +++ b/src/permissions/index.ts @@ -20,7 +20,13 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import { type ToolCall } from "../api/client.js"; +import { type CompiledRules, checkImportedRules } from "./claude-settings.js"; +export { + type CompiledRules, + loadClaudeCompatRules, + summarizeCompatRules, +} from "./claude-settings.js"; // ─── Constants ─────────────────────────────────────────────────────────────── const KLAATAI_DIR = join(homedir(), ".klaatai"); @@ -148,7 +154,11 @@ function matchesPattern(subject: string, pattern: string): boolean { * "deny" — reject silently with an error message * "ask" — show the PermissionPrompt to the user */ -export function checkPermission(tc: ToolCall, perms: PermissionsFile): PermCheckResult { +export function checkPermission( + tc: ToolCall, + perms: PermissionsFile, + importedRules?: CompiledRules | null, +): PermCheckResult { const tool = tc.function.name; // Tier 1: always-safe tools @@ -170,7 +180,15 @@ export function checkPermission(tc: ToolCall, perms: PermissionsFile): PermCheck for (const pattern of perms.allowed_commands) { if (matchesPattern(cmd, pattern)) return "allow"; } - return "ask"; + // Native config has no opinion — fall through to imported rules below. + } + + // Compat: .claude/settings.json permissions.allow/deny/ask. Our native + // config (tiers 1-3 above) always outranks this — it's only consulted + // when the native model would otherwise ask. + if (importedRules) { + const imported = checkImportedRules(tc, importedRules); + if (imported) return imported; } // write_file / edit_file — not permanently trusted, ask diff --git a/src/screens/repl.ts b/src/screens/repl.ts index abf6253..cd805f3 100644 --- a/src/screens/repl.ts +++ b/src/screens/repl.ts @@ -94,7 +94,10 @@ import { loadPermissions, persistAlwaysAllow, SAFE_TOOLS, + loadClaudeCompatRules, + summarizeCompatRules, type PermDecision, + type CompiledRules, } from "../permissions/index.js"; import { exec, spawnSync } from "child_process"; import { appendFileSync, readFileSync, writeFileSync, unlinkSync, readdirSync, existsSync, mkdirSync } from "node:fs"; @@ -560,6 +563,15 @@ export async function runREPL( mcpManager.connect(mcpConfig); } + // Compiled once per session — .claude/settings.json rarely changes mid-session, + // and this keeps the hot tool-call path free of extra fs reads. + let claudeCompatRules: CompiledRules | null = null; + if (config.compat?.importClaudeSettings !== false) { + claudeCompatRules = loadClaudeCompatRules(projectRoot); + const summary = summarizeCompatRules(claudeCompatRules); + if (summary) pushSystemMsg(summary); + } + // ─── Plugins (user tools from ~/.klaatai/plugins + .klaatai/tools) ──────── const pluginRegistry = new PluginRegistry(); void pluginRegistry.load(projectRoot).then(() => { @@ -1504,7 +1516,7 @@ export async function runREPL( } const perms = loadPermissions(); - const check = checkPermission(tc, perms); + const check = checkPermission(tc, perms, claudeCompatRules); if (check === "allow") { const result = await runTool(); runHooks("after_tool", { KLAATAI_TOOL_NAME: tool, KLAATAI_TOOL_RESULT: result });