/** * KHAELOR * File: src/permissions/rules.ts * Description: Permission rules — wildcard matching, last-match-wins evaluator, layer merging, shipped defaults, hardline floor wiring (PERMISSION_MODEL.md §4). * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import type { PermissionAction } from "../config/index.js"; import { KhaelorError, err, ok } from "../shared/index.js"; import type { Result } from "../shared/index.js"; import { hardlineMatch } from "./bash-analysis.js"; import { COMMAND_CAPABILITIES } from "./capabilities.js"; import type { Capability, CapabilityRequest } from "./capabilities.js"; import { collapseWhitespace } from "./paths.js"; export type { PermissionAction }; // ───────────────────────────── rule format (§4.1) ───────────────────────────── /** Provenance of a rule — later layers win by position (§4.2). */ export type RuleSource = "default" | "user" | "project" | "session" | "hardline"; export interface PermissionRule { /** Capability pattern; wildcards allowed: "file.write.*", "*". */ capability: string; /** Subject pattern; wildcards allowed: "git push *", "/Users/x/notes/*". Default "*". */ pattern?: string; action: PermissionAction; /** Provenance, filled by the loader/merger. */ source?: RuleSource; } /** Synthetic rule reported when the hardline floor fires (§4.2). */ export const HARDLINE_RULE: Readonly = Object.freeze({ capability: "*", pattern: "*", action: "deny" as PermissionAction, source: "hardline" as RuleSource, }); export interface Decision { action: PermissionAction; /** The matched rule (provenance for the panel and audit trail). Absent when unmatched → ask. */ rule?: PermissionRule; } // ───────────────────────────── wildcard matching (§4.1) ───────────────────────────── const REGEX_SPECIALS = /[.*+?^${}()|[\]\\]/g; function escapeRegExp(text: string): string { return text.replace(REGEX_SPECIALS, "\\$&"); } const wildcardCache = new Map(); /** * `*` matches any run of characters (including `/` in paths); matching is * case-sensitive; a pattern without `*` must match the subject exactly. */ export function wildcardMatch(pattern: string, subject: string): boolean { if (!pattern.includes("*")) return pattern === subject; let regex = wildcardCache.get(pattern); if (regex === undefined) { regex = new RegExp(`^${pattern.split("*").map(escapeRegExp).join("[\\s\\S]*")}$`); wildcardCache.set(pattern, regex); } return regex.test(subject); } // ───────────────────────── evaluation: last match wins (§4.3) ───────────────────────── /** * The tight OpenCode-style evaluator: hardline floor first, then the LAST * matching rule wins on both fields; unmatched defaults to `ask`. */ export function evaluate(rules: readonly PermissionRule[], req: CapabilityRequest): Decision { const isCommand = COMMAND_CAPABILITIES.has(req.capability); const subject = isCommand ? collapseWhitespace(req.subject) : req.subject; if (isCommand && hardlineMatch(subject) !== null) { return { action: "deny", rule: HARDLINE_RULE }; } const rule = rules.findLast((r) => { if (!wildcardMatch(r.capability, req.capability)) return false; const pattern = r.pattern ?? "*"; if (req.exactOnly === true && r.action === "allow") { // Fail closed (§3.2): only an exact-subject rule can ALLOW this request. return collapseWhitespace(pattern) === subject; } return wildcardMatch(pattern, subject); }); return rule ? { action: rule.action, rule } : { action: "ask" }; } // ───────────────────────── combination rule (§4.4) ───────────────────────── /** deny beats ask beats allow; an empty request set is allowed (pure observation). */ export function combineDecisions(decisions: Decision[]): PermissionAction { if (decisions.some((d) => d.action === "deny")) return "deny"; if (decisions.some((d) => d.action === "ask")) return "ask"; return "allow"; } // ───────────────────────── layering (§4.2) ───────────────────────── /** Tag every rule in a layer with its provenance. */ export function tagRules(rules: readonly PermissionRule[], source: RuleSource): PermissionRule[] { return rules.map((r) => ({ ...r, source })); } /** * The effective ruleset is plain array concatenation — later layers win by * position: defaults ++ user ++ project ++ session grants. */ export function mergeRuleLayers( ...layers: (readonly PermissionRule[] | undefined)[] ): PermissionRule[] { const merged: PermissionRule[] = []; for (const layer of layers) { if (layer !== undefined) merged.push(...layer); } return merged; } // ───────────────────────── config normalization (§4.1) ───────────────────────── const PERMISSION_ACTIONS: readonly string[] = ["allow", "ask", "deny"]; function invalid(source: string, message: string): KhaelorError { return new KhaelorError("config-invalid", `${source}: ${message}`, { source }); } /** * Expand a raw `permissions` config section into ordered rules: * shorthand (`capability → action`) and nested (`capability → { pattern → * action }`) forms in source key order, then the explicit `rules` array * appended (§4.1). Every rule is tagged with the layer's provenance. */ export function normalizePermissionsSection( value: unknown, source: Exclude, origin = "permissions", ): Result { if (value === null || typeof value !== "object" || Array.isArray(value)) { return err(invalid(origin, "permissions section must be a JSON object")); } const raw = value as Record; const out: PermissionRule[] = []; for (const [key, entry] of Object.entries(raw)) { if (key === "rules") continue; // appended after the shorthand expansion if (typeof entry === "string") { if (!PERMISSION_ACTIONS.includes(entry)) { return err(invalid(origin, `"${key}" must be one of: ${PERMISSION_ACTIONS.join(", ")}`)); } out.push({ capability: key, action: entry as PermissionAction, source }); continue; } if (entry !== null && typeof entry === "object" && !Array.isArray(entry)) { for (const [pattern, action] of Object.entries(entry as Record)) { if (typeof action !== "string" || !PERMISSION_ACTIONS.includes(action)) { return err( invalid(origin, `"${key}.${pattern}" must be one of: ${PERMISSION_ACTIONS.join(", ")}`), ); } out.push({ capability: key, pattern, action: action as PermissionAction, source }); } continue; } return err(invalid(origin, `"${key}" must be an action string or a pattern → action object`)); } if ("rules" in raw) { const rules = raw["rules"]; if (!Array.isArray(rules)) { return err(invalid(origin, `"rules" must be an array`)); } for (const [index, item] of rules.entries()) { if (item === null || typeof item !== "object" || Array.isArray(item)) { return err(invalid(origin, `"rules[${index}]" must be an object`)); } const rule = item as Record; const capability = rule["capability"]; const action = rule["action"]; const pattern = rule["pattern"]; if (typeof capability !== "string" || capability.length === 0) { return err(invalid(origin, `"rules[${index}].capability" must be a non-empty string`)); } if (typeof action !== "string" || !PERMISSION_ACTIONS.includes(action)) { return err( invalid(origin, `"rules[${index}].action" must be one of: ${PERMISSION_ACTIONS.join(", ")}`), ); } if (pattern !== undefined && typeof pattern !== "string") { return err(invalid(origin, `"rules[${index}].pattern" must be a string when present`)); } out.push({ capability, ...(pattern !== undefined ? { pattern } : {}), action: action as PermissionAction, source, }); } } return ok(out); } // ───────────────────────── shipped defaults (§4.5) ───────────────────────── function d(capability: Capability | string, pattern: string, action: PermissionAction): PermissionRule { return { capability, pattern, action, source: "default" }; } /** Default policy shipped with V1 — safe but not annoying (PERMISSION_MODEL.md §4.5, verbatim). */ export const DEFAULT_RULES: readonly PermissionRule[] = Object.freeze([ // reads: free, except secrets-shaped files d("file.read", "*", "allow"), d("file.read", "*.env", "ask"), d("file.read", "*.env.*", "ask"), d("file.read", "*.env.example", "allow"), d("file.read", "*.pem", "ask"), d("file.read", "*/.ssh/*", "ask"), // writes: project free, outside asks d("file.write.project", "*", "allow"), d("file.write.outsideProject", "*", "ask"), // commands: ask by default, with a read-only allowlist so common // inspection never prompts (the arity suggester grows this per project) d("process.execute", "*", "ask"), d("process.execute", "git status*", "allow"), d("process.execute", "git diff*", "allow"), d("process.execute", "git log*", "allow"), d("process.execute", "git show*", "allow"), d("process.execute", "git branch", "allow"), d("process.execute", "ls*", "allow"), d("process.execute", "pwd", "allow"), d("process.execute", "which *", "allow"), d("process.execute", "cat *", "allow"), d("process.execute", "wc *", "allow"), d("process.execute", "head *", "allow"), d("process.execute", "tail *", "allow"), // background processes ask by default; stdin to an already-approved // background process is allowed (general rule FIRST — last match wins, // mirroring the process.execute block above). d("process.background", "*", "ask"), d("process.background", "stdin:*", "allow"), d("network.access", "*", "ask"), d("git.modify", "*", "ask"), ]);