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/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