spb/khaelor Public
KHAELOR — a terminal-native autonomous engineering agent powered by Anthropic.
TypeScript 82.9%
HTML 14.9%
CSS 1.1%
JavaScript 0.7%
1/**2 * KHAELOR3 * File: src/permissions/capabilities.ts4 * Description: The 7-capability taxonomy and the per-tool → capability-request mapping (PERMISSION_MODEL.md §1–§2).5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910import { KhaelorError, err, ok } from "../shared/index.js";11import type { Result } from "../shared/index.js";12import { analyzeBashCommand, OBFUSCATION_RISK_NOTE } from "./bash-analysis.js";13import type { BashAnalysis } from "./bash-analysis.js";14import { isUnderRoot, resolveSubjectPath } from "./paths.js";15import type { PathContext } from "./paths.js";1617// ───────────────────────────── taxonomy (§1) ─────────────────────────────1819export type Capability =20 | "file.read"21 | "file.write.project"22 | "file.write.outsideProject"23 | "process.execute"24 | "process.background"25 | "network.access"26 | "git.modify";2728/** Capabilities whose subject is command text (matched with collapsed whitespace, §4.1). */29export const COMMAND_CAPABILITIES: ReadonlySet<Capability> = new Set([30 "process.execute",31 "process.background",32 "network.access",33 "git.modify",34]);3536/** One capability request per action a tool call wants to take (§1.1). */37export interface CapabilityRequest {38 capability: Capability;39 /** What rules' patterns match against: resolved path or command text. */40 subject: string;41 /** Human-readable line for the panel, e.g. `Run npm install`. */42 display: string;43 /**44 * Candidate "always allow" patterns, most specific first.45 * EMPTY for compound/obfuscated commands (§3.3 refusal rule) and for46 * anything the analyzer could not classify.47 */48 alwaysPatterns: string[];49 /** Shown in the panel: why this asks, what is unusual. */50 riskNotes: string[];51 /**52 * Fail-closed matching: an `allow` rule satisfies this request only when53 * its pattern equals the subject exactly (no wildcard generalization).54 * Set for obfuscated commands and redirect-carrying whole commands (§3.2).55 * `ask`/`deny` rules still match by wildcard.56 */57 exactOnly?: boolean;58 /** UI extras: diff for writes, parsed command parts, cwd. Never model-facing. */59 metadata?: Record<string, unknown>;60}6162// ───────────────────────────── mapping context ─────────────────────────────6364export interface CapabilityMappingContext extends PathContext {65 /** `process` tool `write` action: command of the target process (§2). */66 processCommandLookup?: (processId: string) => string | undefined;67}6869function invalidInput(tool: string, message: string): KhaelorError {70 return new KhaelorError("invalid-event", `${tool}: ${message}`, { tool });71}7273function requireString(74 tool: string,75 input: Record<string, unknown>,76 field: string,77): Result<string, KhaelorError> {78 const value = input[field];79 if (typeof value !== "string" || value.length === 0) {80 return err(invalidInput(tool, `"${field}" must be a non-empty string`));81 }82 return ok(value);83}8485// ───────────────────────────── per-tool mapping (§2) ─────────────────────────────8687function fileReadRequest(subject: string, display: string): CapabilityRequest {88 return { capability: "file.read", subject, display, alwaysPatterns: [subject], riskNotes: [] };89}9091function fileWriteRequest(92 tool: "write" | "edit",93 rawPath: string,94 ctx: CapabilityMappingContext,95): CapabilityRequest {96 const subject = resolveSubjectPath(rawPath, ctx);97 const inside = isUnderRoot(subject, ctx.projectRoot);98 return {99 capability: inside ? "file.write.project" : "file.write.outsideProject",100 subject,101 display: `${tool === "write" ? "Write" : "Edit"} ${subject}`,102 alwaysPatterns: [subject],103 riskNotes: inside ? [] : ["Path is outside the project root."],104 };105}106107function commandRequests(108 command: string,109 capability: "process.execute" | "process.background",110 ctx: CapabilityMappingContext,111): CapabilityRequest[] {112 const analysis: BashAnalysis = analyzeBashCommand(command, ctx);113 const verb = capability === "process.background" ? "Start process" : "Run";114 const requests: CapabilityRequest[] = [];115116 if (analysis.classification === "simple") {117 requests.push({118 capability,119 subject: analysis.collapsed,120 display: `${verb} ${analysis.collapsed}`,121 alwaysPatterns: analysis.alwaysPatterns,122 riskNotes: [...analysis.riskNotes],123 });124 } else if (analysis.classification === "compound") {125 // §3.2: every part is analyzed; auto-allow only if EVERY part matches allow.126 analysis.parts.forEach((part, index) => {127 requests.push({128 capability,129 subject: part.text,130 display: `${verb} ${part.text}`,131 alwaysPatterns: [],132 riskNotes: index === 0 ? [...analysis.riskNotes] : [],133 });134 });135 if (analysis.hasRedirect) {136 // Redirects change what a part does; the whole command must be allowed137 // exactly, never via a part-shaped wildcard (`cat * > file` asks, §4.5).138 requests.push({139 capability,140 subject: analysis.collapsed,141 display: `${verb} ${analysis.collapsed}`,142 alwaysPatterns: [],143 riskNotes: ["Command redirects output — evaluated as a whole."],144 exactOnly: true,145 });146 }147 } else {148 requests.push({149 capability,150 subject: analysis.collapsed,151 display: `${verb} ${analysis.collapsed}`,152 alwaysPatterns: [],153 riskNotes: analysis.riskNotes.length > 0 ? [...analysis.riskNotes] : [OBFUSCATION_RISK_NOTE],154 exactOnly: true,155 });156 }157158 // Derived requests (§3.4–§3.5): union over parts.159 for (const part of analysis.parts) {160 if (part.network) {161 requests.push({162 capability: "network.access",163 subject: part.text,164 display: `Network access: ${part.text}`,165 alwaysPatterns: [],166 riskNotes: [],167 });168 }169 if (part.gitModify) {170 requests.push({171 capability: "git.modify",172 subject: part.text,173 display: `Modify git state: ${part.text}`,174 alwaysPatterns: [],175 riskNotes: [],176 });177 }178 for (const outsidePath of part.outsideWrites) {179 requests.push({180 capability: "file.write.outsideProject",181 subject: outsidePath,182 display: `Write outside the project: ${outsidePath}`,183 alwaysPatterns: [],184 riskNotes: ["Filesystem write outside the project root (detected from the command)."],185 });186 }187 }188 for (const outsidePath of analysis.outsideRedirectWrites) {189 requests.push({190 capability: "file.write.outsideProject",191 subject: outsidePath,192 display: `Write outside the project: ${outsidePath}`,193 alwaysPatterns: [],194 riskNotes: ["Output redirect targets a file outside the project root."],195 });196 }197 if (analysis.classification === "obfuscated" && analysis.rawNetworkHint) {198 requests.push({199 capability: "network.access",200 subject: analysis.collapsed,201 display: `Network access: ${analysis.collapsed}`,202 alwaysPatterns: [],203 riskNotes: ["A network tool name appears in a command KHAELOR could not fully parse."],204 });205 }206 return requests;207}208209/**210 * The normative per-tool mapping (PERMISSION_MODEL.md §2). Anything not211 * expressible here is a design error — unknown tools/actions fail closed212 * with an error, never a silent empty request list.213 */214export function mapToolCapabilities(215 toolName: string,216 input: unknown,217 ctx: CapabilityMappingContext,218): Result<CapabilityRequest[], KhaelorError> {219 if (input === null || typeof input !== "object" || Array.isArray(input)) {220 return err(invalidInput(toolName, "tool input must be an object"));221 }222 const raw = input as Record<string, unknown>;223224 switch (toolName) {225 case "read": {226 const filePath = requireString(toolName, raw, "file_path");227 if (!filePath.ok) return filePath;228 const subject = resolveSubjectPath(filePath.value, ctx);229 return ok([fileReadRequest(subject, `Read ${subject}`)]);230 }231 case "glob":232 case "grep": {233 const pathValue = raw["path"];234 if (pathValue !== undefined && typeof pathValue !== "string") {235 return err(invalidInput(toolName, `"path" must be a string when present`));236 }237 const subject = resolveSubjectPath(pathValue ?? ctx.cwd, ctx);238 return ok([fileReadRequest(subject, `Search ${subject}`)]);239 }240 case "write":241 case "edit": {242 const filePath = requireString(toolName, raw, "file_path");243 if (!filePath.ok) return filePath;244 return ok([fileWriteRequest(toolName, filePath.value, ctx)]);245 }246 case "bash": {247 const command = requireString(toolName, raw, "command");248 if (!command.ok) return command;249 return ok(commandRequests(command.value, "process.execute", ctx));250 }251 case "process": {252 const action = requireString(toolName, raw, "action");253 if (!action.ok) return action;254 switch (action.value) {255 case "start": {256 const command = requireString(toolName, raw, "command");257 if (!command.ok) return command;258 return ok(commandRequests(command.value, "process.background", ctx));259 }260 case "list":261 case "read":262 case "stop":263 // Pure observation / control of KHAELOR-owned state — no request (§2).264 return ok([]);265 case "write": {266 const id = requireString(toolName, raw, "id");267 if (!id.ok) return id;268 const targetCommand = ctx.processCommandLookup?.(id.value) ?? id.value;269 return ok([270 {271 capability: "process.background",272 subject: `stdin:${targetCommand}`,273 display: `Send input to ${targetCommand}`,274 alwaysPatterns: [],275 riskNotes: [],276 },277 ]);278 }279 default:280 return err(invalidInput(toolName, `unknown action "${action.value}"`));281 }282 }283 default:284 return err(285 new KhaelorError(286 "internal",287 `No capability mapping for tool "${toolName}" — a new behavior needs a new capability or an ADR amendment, never a bypass (PERMISSION_MODEL.md §2).`,288 { tool: toolName },289 ),290 );291 }292}293