/** * KHAELOR * File: src/permissions/capabilities.ts * Description: The 7-capability taxonomy and the per-tool → capability-request mapping (PERMISSION_MODEL.md §1–§2). * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import { KhaelorError, err, ok } from "../shared/index.js"; import type { Result } from "../shared/index.js"; import { analyzeBashCommand, OBFUSCATION_RISK_NOTE } from "./bash-analysis.js"; import type { BashAnalysis } from "./bash-analysis.js"; import { isUnderRoot, resolveSubjectPath } from "./paths.js"; import type { PathContext } from "./paths.js"; // ───────────────────────────── taxonomy (§1) ───────────────────────────── export type Capability = | "file.read" | "file.write.project" | "file.write.outsideProject" | "process.execute" | "process.background" | "network.access" | "git.modify"; /** Capabilities whose subject is command text (matched with collapsed whitespace, §4.1). */ export const COMMAND_CAPABILITIES: ReadonlySet = new Set([ "process.execute", "process.background", "network.access", "git.modify", ]); /** One capability request per action a tool call wants to take (§1.1). */ export interface CapabilityRequest { capability: Capability; /** What rules' patterns match against: resolved path or command text. */ subject: string; /** Human-readable line for the panel, e.g. `Run npm install`. */ display: string; /** * Candidate "always allow" patterns, most specific first. * EMPTY for compound/obfuscated commands (§3.3 refusal rule) and for * anything the analyzer could not classify. */ alwaysPatterns: string[]; /** Shown in the panel: why this asks, what is unusual. */ riskNotes: string[]; /** * Fail-closed matching: an `allow` rule satisfies this request only when * its pattern equals the subject exactly (no wildcard generalization). * Set for obfuscated commands and redirect-carrying whole commands (§3.2). * `ask`/`deny` rules still match by wildcard. */ exactOnly?: boolean; /** UI extras: diff for writes, parsed command parts, cwd. Never model-facing. */ metadata?: Record; } // ───────────────────────────── mapping context ───────────────────────────── export interface CapabilityMappingContext extends PathContext { /** `process` tool `write` action: command of the target process (§2). */ processCommandLookup?: (processId: string) => string | undefined; } function invalidInput(tool: string, message: string): KhaelorError { return new KhaelorError("invalid-event", `${tool}: ${message}`, { tool }); } function requireString( tool: string, input: Record, field: string, ): Result { const value = input[field]; if (typeof value !== "string" || value.length === 0) { return err(invalidInput(tool, `"${field}" must be a non-empty string`)); } return ok(value); } // ───────────────────────────── per-tool mapping (§2) ───────────────────────────── function fileReadRequest(subject: string, display: string): CapabilityRequest { return { capability: "file.read", subject, display, alwaysPatterns: [subject], riskNotes: [] }; } function fileWriteRequest( tool: "write" | "edit", rawPath: string, ctx: CapabilityMappingContext, ): CapabilityRequest { const subject = resolveSubjectPath(rawPath, ctx); const inside = isUnderRoot(subject, ctx.projectRoot); return { capability: inside ? "file.write.project" : "file.write.outsideProject", subject, display: `${tool === "write" ? "Write" : "Edit"} ${subject}`, alwaysPatterns: [subject], riskNotes: inside ? [] : ["Path is outside the project root."], }; } function commandRequests( command: string, capability: "process.execute" | "process.background", ctx: CapabilityMappingContext, ): CapabilityRequest[] { const analysis: BashAnalysis = analyzeBashCommand(command, ctx); const verb = capability === "process.background" ? "Start process" : "Run"; const requests: CapabilityRequest[] = []; if (analysis.classification === "simple") { requests.push({ capability, subject: analysis.collapsed, display: `${verb} ${analysis.collapsed}`, alwaysPatterns: analysis.alwaysPatterns, riskNotes: [...analysis.riskNotes], }); } else if (analysis.classification === "compound") { // §3.2: every part is analyzed; auto-allow only if EVERY part matches allow. analysis.parts.forEach((part, index) => { requests.push({ capability, subject: part.text, display: `${verb} ${part.text}`, alwaysPatterns: [], riskNotes: index === 0 ? [...analysis.riskNotes] : [], }); }); if (analysis.hasRedirect) { // Redirects change what a part does; the whole command must be allowed // exactly, never via a part-shaped wildcard (`cat * > file` asks, §4.5). requests.push({ capability, subject: analysis.collapsed, display: `${verb} ${analysis.collapsed}`, alwaysPatterns: [], riskNotes: ["Command redirects output — evaluated as a whole."], exactOnly: true, }); } } else { requests.push({ capability, subject: analysis.collapsed, display: `${verb} ${analysis.collapsed}`, alwaysPatterns: [], riskNotes: analysis.riskNotes.length > 0 ? [...analysis.riskNotes] : [OBFUSCATION_RISK_NOTE], exactOnly: true, }); } // Derived requests (§3.4–§3.5): union over parts. for (const part of analysis.parts) { if (part.network) { requests.push({ capability: "network.access", subject: part.text, display: `Network access: ${part.text}`, alwaysPatterns: [], riskNotes: [], }); } if (part.gitModify) { requests.push({ capability: "git.modify", subject: part.text, display: `Modify git state: ${part.text}`, alwaysPatterns: [], riskNotes: [], }); } for (const outsidePath of part.outsideWrites) { requests.push({ capability: "file.write.outsideProject", subject: outsidePath, display: `Write outside the project: ${outsidePath}`, alwaysPatterns: [], riskNotes: ["Filesystem write outside the project root (detected from the command)."], }); } } for (const outsidePath of analysis.outsideRedirectWrites) { requests.push({ capability: "file.write.outsideProject", subject: outsidePath, display: `Write outside the project: ${outsidePath}`, alwaysPatterns: [], riskNotes: ["Output redirect targets a file outside the project root."], }); } if (analysis.classification === "obfuscated" && analysis.rawNetworkHint) { requests.push({ capability: "network.access", subject: analysis.collapsed, display: `Network access: ${analysis.collapsed}`, alwaysPatterns: [], riskNotes: ["A network tool name appears in a command KHAELOR could not fully parse."], }); } return requests; } /** * The normative per-tool mapping (PERMISSION_MODEL.md §2). Anything not * expressible here is a design error — unknown tools/actions fail closed * with an error, never a silent empty request list. */ export function mapToolCapabilities( toolName: string, input: unknown, ctx: CapabilityMappingContext, ): Result { if (input === null || typeof input !== "object" || Array.isArray(input)) { return err(invalidInput(toolName, "tool input must be an object")); } const raw = input as Record; switch (toolName) { case "read": { const filePath = requireString(toolName, raw, "file_path"); if (!filePath.ok) return filePath; const subject = resolveSubjectPath(filePath.value, ctx); return ok([fileReadRequest(subject, `Read ${subject}`)]); } case "design": // Pure phase-gate bookkeeping — records events, touches nothing (v2 §1). return ok([]); case "remember": { // Writes exclusively to the project memory file (v2 §5). const subject = resolveSubjectPath(".khaelor/MEMORY.md", ctx); return ok([ { capability: "file.write.project", subject, display: `Append to project memory ${subject}`, alwaysPatterns: [subject], riskNotes: [], }, ]); } case "symbols": case "refs": { // Read-only queries over the local semantic index (v2 §3). const subject = resolveSubjectPath(ctx.cwd, ctx); return ok([fileReadRequest(subject, `Query symbol index ${subject}`)]); } case "glob": case "grep": { const pathValue = raw["path"]; if (pathValue !== undefined && typeof pathValue !== "string") { return err(invalidInput(toolName, `"path" must be a string when present`)); } const subject = resolveSubjectPath(pathValue ?? ctx.cwd, ctx); return ok([fileReadRequest(subject, `Search ${subject}`)]); } case "write": case "edit": { const filePath = requireString(toolName, raw, "file_path"); if (!filePath.ok) return filePath; return ok([fileWriteRequest(toolName, filePath.value, ctx)]); } case "bash": { const command = requireString(toolName, raw, "command"); if (!command.ok) return command; return ok(commandRequests(command.value, "process.execute", ctx)); } case "process": { const action = requireString(toolName, raw, "action"); if (!action.ok) return action; switch (action.value) { case "start": { const command = requireString(toolName, raw, "command"); if (!command.ok) return command; return ok(commandRequests(command.value, "process.background", ctx)); } case "list": case "read": case "stop": // Pure observation / control of KHAELOR-owned state — no request (§2). return ok([]); case "write": { const id = requireString(toolName, raw, "id"); if (!id.ok) return id; const targetCommand = ctx.processCommandLookup?.(id.value) ?? id.value; return ok([ { capability: "process.background", subject: `stdin:${targetCommand}`, display: `Send input to ${targetCommand}`, alwaysPatterns: [], riskNotes: [], }, ]); } default: return err(invalidInput(toolName, `unknown action "${action.value}"`)); } } default: return err( new KhaelorError( "internal", `No capability mapping for tool "${toolName}" — a new behavior needs a new capability or an ADR amendment, never a bypass (PERMISSION_MODEL.md §2).`, { tool: toolName }, ), ); } }