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 "design":232 // Pure phase-gate bookkeeping — records events, touches nothing (v2 §1).233 return ok([]);234 case "remember": {235 // Writes exclusively to the project memory file (v2 §5).236 const subject = resolveSubjectPath(".khaelor/MEMORY.md", ctx);237 return ok([238 {239 capability: "file.write.project",240 subject,241 display: `Append to project memory ${subject}`,242 alwaysPatterns: [subject],243 riskNotes: [],244 },245 ]);246 }247 case "symbols":248 case "refs": {249 // Read-only queries over the local semantic index (v2 §3).250 const subject = resolveSubjectPath(ctx.cwd, ctx);251 return ok([fileReadRequest(subject, `Query symbol index ${subject}`)]);252 }253 case "glob":254 case "grep": {255 const pathValue = raw["path"];256 if (pathValue !== undefined && typeof pathValue !== "string") {257 return err(invalidInput(toolName, `"path" must be a string when present`));258 }259 const subject = resolveSubjectPath(pathValue ?? ctx.cwd, ctx);260 return ok([fileReadRequest(subject, `Search ${subject}`)]);261 }262 case "write":263 case "edit": {264 const filePath = requireString(toolName, raw, "file_path");265 if (!filePath.ok) return filePath;266 return ok([fileWriteRequest(toolName, filePath.value, ctx)]);267 }268 case "bash": {269 const command = requireString(toolName, raw, "command");270 if (!command.ok) return command;271 return ok(commandRequests(command.value, "process.execute", ctx));272 }273 case "process": {274 const action = requireString(toolName, raw, "action");275 if (!action.ok) return action;276 switch (action.value) {277 case "start": {278 const command = requireString(toolName, raw, "command");279 if (!command.ok) return command;280 return ok(commandRequests(command.value, "process.background", ctx));281 }282 case "list":283 case "read":284 case "stop":285 // Pure observation / control of KHAELOR-owned state — no request (§2).286 return ok([]);287 case "write": {288 const id = requireString(toolName, raw, "id");289 if (!id.ok) return id;290 const targetCommand = ctx.processCommandLookup?.(id.value) ?? id.value;291 return ok([292 {293 capability: "process.background",294 subject: `stdin:${targetCommand}`,295 display: `Send input to ${targetCommand}`,296 alwaysPatterns: [],297 riskNotes: [],298 },299 ]);300 }301 default:302 return err(invalidInput(toolName, `unknown action "${action.value}"`));303 }304 }305 default:306 return err(307 new KhaelorError(308 "internal",309 `No capability mapping for tool "${toolName}" — a new behavior needs a new capability or an ADR amendment, never a bypass (PERMISSION_MODEL.md §2).`,310 { tool: toolName },311 ),312 );313 }314}315