SPB Git

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%
10.3 KB · 293 lines typescript
Raw Blame History
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