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.4 KB · 255 lines typescript
Raw Blame History
1/**2 * KHAELOR3 * File: src/permissions/rules.ts4 * Description: Permission rules — wildcard matching, last-match-wins evaluator, layer merging, shipped defaults, hardline floor wiring (PERMISSION_MODEL.md §4).5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910import type { PermissionAction } from "../config/index.js";11import { KhaelorError, err, ok } from "../shared/index.js";12import type { Result } from "../shared/index.js";13import { hardlineMatch } from "./bash-analysis.js";14import { COMMAND_CAPABILITIES } from "./capabilities.js";15import type { Capability, CapabilityRequest } from "./capabilities.js";16import { collapseWhitespace } from "./paths.js";1718export type { PermissionAction };1920// ───────────────────────────── rule format (§4.1) ─────────────────────────────2122/** Provenance of a rule — later layers win by position (§4.2). */23export type RuleSource = "default" | "user" | "project" | "session" | "hardline";2425export interface PermissionRule {26  /** Capability pattern; wildcards allowed: "file.write.*", "*". */27  capability: string;28  /** Subject pattern; wildcards allowed: "git push *", "/Users/x/notes/*". Default "*". */29  pattern?: string;30  action: PermissionAction;31  /** Provenance, filled by the loader/merger. */32  source?: RuleSource;33}3435/** Synthetic rule reported when the hardline floor fires (§4.2). */36export const HARDLINE_RULE: Readonly<PermissionRule> = Object.freeze({37  capability: "*",38  pattern: "*",39  action: "deny" as PermissionAction,40  source: "hardline" as RuleSource,41});4243export interface Decision {44  action: PermissionAction;45  /** The matched rule (provenance for the panel and audit trail). Absent when unmatched → ask. */46  rule?: PermissionRule;47}4849// ───────────────────────────── wildcard matching (§4.1) ─────────────────────────────5051const REGEX_SPECIALS = /[.*+?^${}()|[\]\\]/g;5253function escapeRegExp(text: string): string {54  return text.replace(REGEX_SPECIALS, "\\$&");55}5657const wildcardCache = new Map<string, RegExp>();5859/**60 * `*` matches any run of characters (including `/` in paths); matching is61 * case-sensitive; a pattern without `*` must match the subject exactly.62 */63export function wildcardMatch(pattern: string, subject: string): boolean {64  if (!pattern.includes("*")) return pattern === subject;65  let regex = wildcardCache.get(pattern);66  if (regex === undefined) {67    regex = new RegExp(`^${pattern.split("*").map(escapeRegExp).join("[\\s\\S]*")}$`);68    wildcardCache.set(pattern, regex);69  }70  return regex.test(subject);71}7273// ───────────────────────── evaluation: last match wins (§4.3) ─────────────────────────7475/**76 * The tight OpenCode-style evaluator: hardline floor first, then the LAST77 * matching rule wins on both fields; unmatched defaults to `ask`.78 */79export function evaluate(rules: readonly PermissionRule[], req: CapabilityRequest): Decision {80  const isCommand = COMMAND_CAPABILITIES.has(req.capability);81  const subject = isCommand ? collapseWhitespace(req.subject) : req.subject;82  if (isCommand && hardlineMatch(subject) !== null) {83    return { action: "deny", rule: HARDLINE_RULE };84  }85  const rule = rules.findLast((r) => {86    if (!wildcardMatch(r.capability, req.capability)) return false;87    const pattern = r.pattern ?? "*";88    if (req.exactOnly === true && r.action === "allow") {89      // Fail closed (§3.2): only an exact-subject rule can ALLOW this request.90      return collapseWhitespace(pattern) === subject;91    }92    return wildcardMatch(pattern, subject);93  });94  return rule ? { action: rule.action, rule } : { action: "ask" };95}9697// ───────────────────────── combination rule (§4.4) ─────────────────────────9899/** deny beats ask beats allow; an empty request set is allowed (pure observation). */100export function combineDecisions(decisions: Decision[]): PermissionAction {101  if (decisions.some((d) => d.action === "deny")) return "deny";102  if (decisions.some((d) => d.action === "ask")) return "ask";103  return "allow";104}105106// ───────────────────────── layering (§4.2) ─────────────────────────107108/** Tag every rule in a layer with its provenance. */109export function tagRules(rules: readonly PermissionRule[], source: RuleSource): PermissionRule[] {110  return rules.map((r) => ({ ...r, source }));111}112113/**114 * The effective ruleset is plain array concatenation — later layers win by115 * position: defaults ++ user ++ project ++ session grants.116 */117export function mergeRuleLayers(118  ...layers: (readonly PermissionRule[] | undefined)[]119): PermissionRule[] {120  const merged: PermissionRule[] = [];121  for (const layer of layers) {122    if (layer !== undefined) merged.push(...layer);123  }124  return merged;125}126127// ───────────────────────── config normalization (§4.1) ─────────────────────────128129const PERMISSION_ACTIONS: readonly string[] = ["allow", "ask", "deny"];130131function invalid(source: string, message: string): KhaelorError {132  return new KhaelorError("config-invalid", `${source}: ${message}`, { source });133}134135/**136 * Expand a raw `permissions` config section into ordered rules:137 * shorthand (`capability → action`) and nested (`capability → { pattern →138 * action }`) forms in source key order, then the explicit `rules` array139 * appended (§4.1). Every rule is tagged with the layer's provenance.140 */141export function normalizePermissionsSection(142  value: unknown,143  source: Exclude<RuleSource, "hardline">,144  origin = "permissions",145): Result<PermissionRule[], KhaelorError> {146  if (value === null || typeof value !== "object" || Array.isArray(value)) {147    return err(invalid(origin, "permissions section must be a JSON object"));148  }149  const raw = value as Record<string, unknown>;150  const out: PermissionRule[] = [];151152  for (const [key, entry] of Object.entries(raw)) {153    if (key === "rules") continue; // appended after the shorthand expansion154    if (typeof entry === "string") {155      if (!PERMISSION_ACTIONS.includes(entry)) {156        return err(invalid(origin, `"${key}" must be one of: ${PERMISSION_ACTIONS.join(", ")}`));157      }158      out.push({ capability: key, action: entry as PermissionAction, source });159      continue;160    }161    if (entry !== null && typeof entry === "object" && !Array.isArray(entry)) {162      for (const [pattern, action] of Object.entries(entry as Record<string, unknown>)) {163        if (typeof action !== "string" || !PERMISSION_ACTIONS.includes(action)) {164          return err(165            invalid(origin, `"${key}.${pattern}" must be one of: ${PERMISSION_ACTIONS.join(", ")}`),166          );167        }168        out.push({ capability: key, pattern, action: action as PermissionAction, source });169      }170      continue;171    }172    return err(invalid(origin, `"${key}" must be an action string or a pattern → action object`));173  }174175  if ("rules" in raw) {176    const rules = raw["rules"];177    if (!Array.isArray(rules)) {178      return err(invalid(origin, `"rules" must be an array`));179    }180    for (const [index, item] of rules.entries()) {181      if (item === null || typeof item !== "object" || Array.isArray(item)) {182        return err(invalid(origin, `"rules[${index}]" must be an object`));183      }184      const rule = item as Record<string, unknown>;185      const capability = rule["capability"];186      const action = rule["action"];187      const pattern = rule["pattern"];188      if (typeof capability !== "string" || capability.length === 0) {189        return err(invalid(origin, `"rules[${index}].capability" must be a non-empty string`));190      }191      if (typeof action !== "string" || !PERMISSION_ACTIONS.includes(action)) {192        return err(193          invalid(origin, `"rules[${index}].action" must be one of: ${PERMISSION_ACTIONS.join(", ")}`),194        );195      }196      if (pattern !== undefined && typeof pattern !== "string") {197        return err(invalid(origin, `"rules[${index}].pattern" must be a string when present`));198      }199      out.push({200        capability,201        ...(pattern !== undefined ? { pattern } : {}),202        action: action as PermissionAction,203        source,204      });205    }206  }207  return ok(out);208}209210// ───────────────────────── shipped defaults (§4.5) ─────────────────────────211212function d(capability: Capability | string, pattern: string, action: PermissionAction): PermissionRule {213  return { capability, pattern, action, source: "default" };214}215216/** Default policy shipped with V1 — safe but not annoying (PERMISSION_MODEL.md §4.5, verbatim). */217export const DEFAULT_RULES: readonly PermissionRule[] = Object.freeze([218  // reads: free, except secrets-shaped files219  d("file.read", "*", "allow"),220  d("file.read", "*.env", "ask"),221  d("file.read", "*.env.*", "ask"),222  d("file.read", "*.env.example", "allow"),223  d("file.read", "*.pem", "ask"),224  d("file.read", "*/.ssh/*", "ask"),225226  // writes: project free, outside asks227  d("file.write.project", "*", "allow"),228  d("file.write.outsideProject", "*", "ask"),229230  // commands: ask by default, with a read-only allowlist so common231  // inspection never prompts (the arity suggester grows this per project)232  d("process.execute", "*", "ask"),233  d("process.execute", "git status*", "allow"),234  d("process.execute", "git diff*", "allow"),235  d("process.execute", "git log*", "allow"),236  d("process.execute", "git show*", "allow"),237  d("process.execute", "git branch", "allow"),238  d("process.execute", "ls*", "allow"),239  d("process.execute", "pwd", "allow"),240  d("process.execute", "which *", "allow"),241  d("process.execute", "cat *", "allow"),242  d("process.execute", "wc *", "allow"),243  d("process.execute", "head *", "allow"),244  d("process.execute", "tail *", "allow"),245246  // background processes ask by default; stdin to an already-approved247  // background process is allowed (general rule FIRST — last match wins,248  // mirroring the process.execute block above).249  d("process.background", "*", "ask"),250  d("process.background", "stdin:*", "allow"),251252  d("network.access", "*", "ask"),253  d("git.modify", "*", "ask"),254]);255