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%
11.1 KB · 315 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 "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